From b5ca1dafb8f223710ed9b64af4d397b50eee5275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 18 Feb 2026 23:34:50 +0000 Subject: [PATCH] feat: improve plan comments (#249) --- apps/pnpm-lock.yaml | 144 +++++- apps/web/app/globals.css | 109 +++-- .../editors/tiptap/comment-mark.tsx | 445 ++++++++++++++++++ .../editors/tiptap/plan-bubble-menu.tsx | 149 ++++++ .../editors/tiptap/plan-drag-handle.tsx | 19 + .../editors/tiptap/plan-slash-commands.ts | 188 ++++++++ .../editors/tiptap/plan-slash-menu.tsx | 69 +++ .../tiptap/tiptap-comment-highlight.ts | 232 --------- .../editors/tiptap/tiptap-plan-editor.tsx | 302 ++++++------ apps/web/components/task/chat/popup-menu.tsx | 11 +- .../task/plan-selection-popover.tsx | 181 ++++--- apps/web/components/task/task-plan-panel.tsx | 136 +++--- .../domains/comments/use-plan-comments.ts | 11 +- apps/web/lib/state/slices/comments/types.ts | 2 + apps/web/package.json | 4 + 15 files changed, 1452 insertions(+), 550 deletions(-) create mode 100644 apps/web/components/editors/tiptap/comment-mark.tsx create mode 100644 apps/web/components/editors/tiptap/plan-bubble-menu.tsx create mode 100644 apps/web/components/editors/tiptap/plan-drag-handle.tsx create mode 100644 apps/web/components/editors/tiptap/plan-slash-commands.ts create mode 100644 apps/web/components/editors/tiptap/plan-slash-menu.tsx delete mode 100644 apps/web/components/editors/tiptap/tiptap-comment-highlight.ts diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 6df4707481..1697cf06bf 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -257,9 +257,18 @@ importers: '@tiptap/extension-document': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-drag-handle': + specifier: ^3.20.0 + version: 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)) + '@tiptap/extension-drag-handle-react': + specifier: ^3.20.0 + version: 3.20.0(@tiptap/extension-drag-handle@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.19.0)(@tiptap/react@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/extension-hard-break': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-highlight': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/extension-history': specifier: ^3.19.0 version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) @@ -299,6 +308,9 @@ importers: '@tiptap/extension-text': specifier: ^3.19.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-underline': + specifier: ^3.19.0 + version: 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/pm': specifier: ^3.19.0 version: 3.19.0 @@ -2618,11 +2630,37 @@ packages: peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-collaboration@3.20.0': + resolution: {integrity: sha512-JItmI4U0i4kqorO114u24hM9k945IdaQ6Uc2DEtPBFFuS8cepJf2zw+ulAT1kAx6ZRiNvNpT9M7w+J0mWRn+Sg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@tiptap/y-tiptap': ^3.0.2 + yjs: ^13 + '@tiptap/extension-document@3.19.0': resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==} peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-drag-handle-react@3.20.0': + resolution: {integrity: sha512-f/rW/S6UiO8RCKgjioUh6zYdNmrSLzOBlKAt87HBN+TYBD54ZIaYTJYFjdPhonTpOgRTlcuUGGFBN+WCsE1+Bw==} + peerDependencies: + '@tiptap/extension-drag-handle': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@tiptap/react': ^3.20.0 + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + + '@tiptap/extension-drag-handle@3.20.0': + resolution: {integrity: sha512-CzLRyxZe5QddQey0RUWJUvICyhuRnU/jvzMIYlFvMxM7W97sZ2ggk0cRThlRt2pRUoSr8mmmUnobiorpISmksA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-collaboration': ^3.20.0 + '@tiptap/extension-node-range': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@tiptap/y-tiptap': ^3.0.2 + '@tiptap/extension-dropcursor@3.19.0': resolution: {integrity: sha512-sf3dEZXiLvsGqVK2maUIzXY6qtYYCvBumag7+VPTMGQ0D4hiZ1X/4ukt4+6VXDg5R2WP1CoIt/QvUetUjWNhbQ==} peerDependencies: @@ -2650,6 +2688,11 @@ packages: peerDependencies: '@tiptap/core': ^3.19.0 + '@tiptap/extension-highlight@3.20.0': + resolution: {integrity: sha512-ANL1wFz0s1ScNR4uBfO0s6Sz+qqGp2u6ynrCVk6TCT3d10CQ+gD1gSDTrVRC3CtlMKtHHH4fYrFAq284+J0gKA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-history@3.19.0': resolution: {integrity: sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==} peerDependencies: @@ -2700,6 +2743,12 @@ packages: '@tiptap/pm': ^3.19.0 '@tiptap/suggestion': ^3.19.0 + '@tiptap/extension-node-range@3.20.0': + resolution: {integrity: sha512-XeKKTV88VuJ4Mh0Rxvc/PPzG76cb44sE+rB4u0J/ms63R/WFTm6yJQlCgUVGnGeHleSlrWuZY8gGSuoljmQzqg==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/pm': ^3.20.0 + '@tiptap/extension-ordered-list@3.19.0': resolution: {integrity: sha512-cxGsINquwHYE1kmhAcLNLHAofmoDEG6jbesR5ybl7tU5JwtKVO7S/xZatll2DU1dsDAXWPWEeeMl4e/9svYjCg==} peerDependencies: @@ -2756,10 +2805,10 @@ packages: peerDependencies: '@tiptap/core': ^3.19.0 - '@tiptap/extension-underline@3.19.0': - resolution: {integrity: sha512-800MGEWfG49j10wQzAFiW/ele1HT04MamcL8iyuPNu7ZbjbGN2yknvdrJlRy7hZlzIrVkZMr/1tz62KN33VHIw==} + '@tiptap/extension-underline@3.20.0': + resolution: {integrity: sha512-LzNXuy2jwR/y+ymoUqC72TiGzbOCjioIjsDu0MNYpHuHqTWPK5aV9Mh0nbZcYFy/7fPlV1q0W139EbJeYBZEAQ==} peerDependencies: - '@tiptap/core': ^3.19.0 + '@tiptap/core': ^3.20.0 '@tiptap/extensions@3.19.0': resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} @@ -2789,6 +2838,16 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/y-tiptap@3.0.2': + resolution: {integrity: sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -4689,6 +4748,9 @@ packages: resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} engines: {node: '>=16'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -4792,6 +4854,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -6426,6 +6493,12 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6445,6 +6518,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -8582,10 +8659,34 @@ snapshots: dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + yjs: 13.6.29 + '@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-drag-handle-react@3.20.0(@tiptap/extension-drag-handle@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)))(@tiptap/pm@3.19.0)(@tiptap/react@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tiptap/extension-drag-handle': 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)) + '@tiptap/pm': 3.19.0 + '@tiptap/react': 3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + '@tiptap/extension-drag-handle@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-collaboration@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29))(@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-collaboration': 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-node-range': 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/y-tiptap': 3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + '@tiptap/extension-dropcursor@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) @@ -8609,6 +8710,10 @@ snapshots: dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-highlight@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-history@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) @@ -8651,6 +8756,11 @@ snapshots: '@tiptap/pm': 3.19.0 '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-node-range@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/extension-ordered-list@3.19.0(@tiptap/extension-list@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': dependencies: '@tiptap/extension-list': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) @@ -8696,7 +8806,7 @@ snapshots: dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) - '@tiptap/extension-underline@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + '@tiptap/extension-underline@3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) @@ -8766,7 +8876,7 @@ snapshots: '@tiptap/extension-paragraph': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/extension-strike': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/extension-text': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) - '@tiptap/extension-underline': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-underline': 3.20.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 @@ -8775,6 +8885,15 @@ snapshots: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 + '@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -10946,6 +11065,8 @@ snapshots: isexe@3.1.1: {} + isomorphic.js@0.2.5: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -11039,6 +11160,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.30.2: optional: true @@ -13236,6 +13361,11 @@ snapshots: is-wsl: 3.1.0 powershell-utils: 0.1.0 + y-protocols@1.0.7(yjs@13.6.29): + dependencies: + lib0: 0.2.117 + yjs: 13.6.29 + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13254,6 +13384,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 97cf5df011..2a50f995bc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -735,9 +735,9 @@ line-height: 1.3 !important; } -.tiptap-plan-wrapper .tiptap h1 { font-size: 0.875rem !important; } -.tiptap-plan-wrapper .tiptap h2 { font-size: 0.8125rem !important; } -.tiptap-plan-wrapper .tiptap h3 { font-size: 0.75rem !important; } +.tiptap-plan-wrapper .tiptap h1:not(.mermaid-container):not(.mermaid-container *) { font-size: 1.125rem !important; } +.tiptap-plan-wrapper .tiptap h2:not(.mermaid-container):not(.mermaid-container *) { font-size: 0.9375rem !important; } +.tiptap-plan-wrapper .tiptap h3:not(.mermaid-container):not(.mermaid-container *) { font-size: 0.8125rem !important; } /* Paragraphs */ .tiptap-plan-wrapper .tiptap p { @@ -863,10 +863,18 @@ font-weight: 600; } -/* Comment highlights */ +/* Highlight marks */ +.tiptap-plan-wrapper mark { + background-color: rgba(255, 212, 0, 0.3); + border-radius: 2px; + padding: 1px 0; +} + +/* Comment highlights — inline mark spans (like Highlight extension) */ .tiptap-plan-wrapper .comment-highlight { - background-color: rgba(59, 130, 246, 0.45); + background-color: color-mix(in oklch, var(--accent) 70%, transparent); border-radius: 3px; + border-bottom: 2px solid color-mix(in oklch, var(--accent-foreground) 25%, transparent); cursor: pointer; transition: background-color 0.15s ease; padding: 2px 1px; @@ -875,7 +883,61 @@ } .tiptap-plan-wrapper .comment-highlight:hover { - background-color: rgba(59, 130, 246, 0.6); + background-color: color-mix(in oklch, var(--accent) 90%, transparent); +} + +/* Comment badge — single widget decoration at end of mark range */ +.tiptap-plan-wrapper .comment-badge { + position: relative; + display: inline-block; + width: 0; + height: 0; + overflow: visible; + vertical-align: top; +} + +.tiptap-plan-wrapper .comment-badge svg { + position: absolute; + top: -6px; + left: 2px; + width: 11px; + height: 11px; + color: rgba(99, 102, 241, 0.8); + cursor: pointer; + transition: color 0.15s ease; + pointer-events: auto; +} + +.tiptap-plan-wrapper .comment-badge:hover svg { + color: rgba(99, 102, 241, 1); +} + +/* Drag handle */ +.plan-drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-left: 4px; + border-radius: 4px; + color: var(--muted-foreground); + opacity: 0; + cursor: grab; + transition: opacity 0.15s, background-color 0.15s; +} + +.plan-drag-handle:hover { + background-color: var(--muted); + opacity: 1 !important; +} + +.tiptap-plan-wrapper:hover .plan-drag-handle { + opacity: 0.5; +} + +.plan-drag-handle:active { + cursor: grabbing; } /* Mermaid diagram styles */ @@ -908,41 +970,6 @@ font-size: 0.75rem; } -/* Lowlight syntax colors for TipTap plan code blocks */ -.tiptap-plan-wrapper .tiptap pre code .hljs-comment, -.tiptap-plan-wrapper .tiptap pre code .hljs-quote { - color: var(--muted-foreground); - font-style: italic; -} - -.tiptap-plan-wrapper .tiptap pre code .hljs-keyword, -.tiptap-plan-wrapper .tiptap pre code .hljs-selector-tag, -.tiptap-plan-wrapper .tiptap pre code .hljs-built_in { - color: oklch(0.65 0.2 300); -} - -.tiptap-plan-wrapper .tiptap pre code .hljs-string, -.tiptap-plan-wrapper .tiptap pre code .hljs-attr, -.tiptap-plan-wrapper .tiptap pre code .hljs-addition { - color: oklch(0.7 0.15 150); -} - -.tiptap-plan-wrapper .tiptap pre code .hljs-number, -.tiptap-plan-wrapper .tiptap pre code .hljs-literal { - color: oklch(0.7 0.15 60); -} - -.tiptap-plan-wrapper .tiptap pre code .hljs-title, -.tiptap-plan-wrapper .tiptap pre code .hljs-section, -.tiptap-plan-wrapper .tiptap pre code .hljs-type { - color: oklch(0.75 0.15 80); -} - -.tiptap-plan-wrapper .tiptap pre code .hljs-name, -.tiptap-plan-wrapper .tiptap pre code .hljs-tag { - color: oklch(0.65 0.2 20); -} - /* Monaco Editor - Diff gutter indicators (colors from --git-addition / --git-deletion CSS vars) */ .monaco-diff-added-gutter { width: 3px !important; diff --git a/apps/web/components/editors/tiptap/comment-mark.tsx b/apps/web/components/editors/tiptap/comment-mark.tsx new file mode 100644 index 0000000000..6d14a820a2 --- /dev/null +++ b/apps/web/components/editors/tiptap/comment-mark.tsx @@ -0,0 +1,445 @@ +/** + * TipTap Mark extension for plan comments. + * + * Uses a native Mark (like the Highlight extension) for the background — + * marks are part of the ProseMirror document model, so deleting marked text + * automatically kills the mark ("zombie comment" fix). + * + * A single badge per comment is placed via a widget decoration at the end + * of the mark range, avoiding the per-text-node duplication that + * ReactMarkViewRenderer caused on multi-line selections. + */ + +import { Mark, mergeAttributes } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type CommentForEditor = { + id: string; + selectedText: string; + from?: number; + to?: number; +}; + +type CommentMarkOptions = { + onOrphanedComments: (ids: string[]) => void; + onCommentClick: (id: string, position: { x: number; y: number }) => void; +}; + +// --------------------------------------------------------------------------- +// Text-search utilities (moved from tiptap-comment-highlight.ts) +// --------------------------------------------------------------------------- + +export const MIN_COMMENT_TEXT_LENGTH = 3; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type DocLike = any; + +function getTextWithPositions(doc: DocLike): { text: string; positions: number[] } { + let text = ''; + const positions: number[] = []; + + doc.descendants((node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => { + if (node.isText && node.text) { + for (let i = 0; i < node.text.length; i++) { + positions.push(pos + i); + text += node.text[i]; + } + } else if (node.type.name === 'hardBreak') { + positions.push(-1); + text += ' '; + } else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) { + positions.push(-1); + text += '\n'; + } + }); + + return { text, positions }; +} + +function normalizeForSearch(text: string): string { + return text + .replace(/\r\n/g, '\n') + .replace(/[\t ]+/g, ' ') + .replace(/\n+/g, '\n') + .trim() + .toLowerCase(); +} + +function buildNormToOrigMap(fullText: string, normalizedFullText: string): number[] { + const normalizedChars = normalizedFullText.split(''); + const origChars = fullText.split(''); + const normToOrig: number[] = []; + let oi = 0; + for (let ni = 0; ni < normalizedChars.length; ni++) { + while ( + oi < origChars.length && + origChars[oi].toLowerCase() !== normalizedChars[ni] && + /\s/.test(origChars[oi]) + ) { + oi++; + } + normToOrig[ni] = oi; + oi++; + } + return normToOrig; +} + +function resolveTextSearchPositions( + normalizedIndex: number, + length: number, + fullText: string, + normalizedFullText: string, + positions: number[], +): { from: number; to: number } | null { + const normToOrig = buildNormToOrigMap(fullText, normalizedFullText); + const startOrig = normToOrig[normalizedIndex] ?? 0; + const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig; + + let from = -1; + let to = -1; + for (let i = startOrig; i <= endOrig && i < positions.length; i++) { + if (positions[i] >= 0) { + if (from === -1) from = positions[i]; + to = positions[i] + 1; + } + } + if (from === -1 || to === -1) return null; + return { from, to }; +} + +function findCommentByTextSearch( + comment: CommentForEditor, + fullText: string, + normalizedFullText: string, + positions: number[], +): { from: number; to: number } | null { + const searchText = normalizeForSearch(comment.selectedText); + if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) return null; + + let normalizedIndex = normalizedFullText.indexOf(searchText); + if (normalizedIndex !== -1) { + return resolveTextSearchPositions(normalizedIndex, searchText.length, fullText, normalizedFullText, positions); + } + + const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]); + if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) { + normalizedIndex = normalizedFullText.indexOf(firstLine); + if (normalizedIndex !== -1) { + return resolveTextSearchPositions(normalizedIndex, firstLine.length, fullText, normalizedFullText, positions); + } + } + + return null; +} + +// --------------------------------------------------------------------------- +// Helpers: collect mark IDs from a doc +// --------------------------------------------------------------------------- + +function collectCommentIds(doc: DocLike, markTypeName: string): Set { + const ids = new Set(); + doc.descendants((node: { marks?: Array<{ type: { name: string }; attrs: { commentId?: string } }> }) => { + if (node.marks) { + for (const mark of node.marks) { + if (mark.type.name === markTypeName && mark.attrs.commentId) { + ids.add(mark.attrs.commentId); + } + } + } + }); + return ids; +} + +// --------------------------------------------------------------------------- +// Badge widget — single DOM element per comment, placed via decoration +// --------------------------------------------------------------------------- + +function createCommentBadge(commentId: string): HTMLSpanElement { + const badge = document.createElement('span'); + badge.className = 'comment-badge'; + badge.setAttribute('data-comment-id', commentId); + badge.innerHTML = + '' + + ''; + return badge; +} + +/** Build one widget decoration per commentId at the end of its mark range. */ +function buildBadgeDecorations(doc: DocLike, markTypeName: string): DecorationSet { + const lastPos = new Map(); + + doc.descendants( + (node: { marks?: Array<{ type: { name: string }; attrs: { commentId?: string } }>; nodeSize: number }, pos: number) => { + if (!node.marks) return; + for (const mark of node.marks) { + if (mark.type.name === markTypeName && mark.attrs.commentId) { + const end = pos + node.nodeSize; + const cur = lastPos.get(mark.attrs.commentId); + if (!cur || end > cur) lastPos.set(mark.attrs.commentId, end); + } + } + }, + ); + + const decos: Decoration[] = []; + for (const [commentId, endPos] of lastPos) { + decos.push( + Decoration.widget(endPos, () => createCommentBadge(commentId), { + side: 1, + key: `badge-${commentId}`, + }), + ); + } + return DecorationSet.create(doc, decos); +} + +// --------------------------------------------------------------------------- +// Plugin keys +// --------------------------------------------------------------------------- + +const orphanPluginKey = new PluginKey('commentMark-orphanDetection'); +const badgePluginKey = new PluginKey('commentMark-badges'); + +// --------------------------------------------------------------------------- +// Helper: find commentId at a document position +// --------------------------------------------------------------------------- + +type MarkEntry = { type: { name: string }; attrs: { commentId?: string } }; + +function commentIdAtPos(doc: DocLike, pos: number, markTypeName: string): string | null { + if (pos < 0 || pos >= doc.content.size) return null; + const $pos = doc.resolve(pos); + const after = $pos.parent.childAfter($pos.parentOffset); + if (!after.node) return null; + const mark = (after.node.marks as MarkEntry[]).find( + (m) => m.type.name === markTypeName && m.attrs.commentId, + ); + return mark?.attrs.commentId ?? null; +} + +// --------------------------------------------------------------------------- +// Mark Extension +// --------------------------------------------------------------------------- + +export const CommentMark = Mark.create({ + name: 'commentMark', + + // Coexist with bold, italic, highlight, etc. + excludes: '', + + // Typing at the edge of a mark should NOT extend it + inclusive: false, + + addOptions() { + return { + onOrphanedComments: () => {}, + onCommentClick: () => {}, + }; + }, + + addAttributes() { + return { + commentId: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-comment-id]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes({ class: 'comment-highlight', 'data-comment-id': HTMLAttributes.commentId }, HTMLAttributes), + 0, + ]; + }, + + // Transparent in markdown — just outputs the inner text + addStorage() { + return { + markdown: { + serialize: { open: '', close: '', mixable: true, expelEnclosingWhitespace: true }, + parse: {}, + }, + }; + }, + + // Backspace/Delete inside a comment mark removes the entire mark (keeps text). + // Orphan detection then cleans up the comment from the store. + addKeyboardShortcuts() { + const markName = this.name; + + /** Remove all instances of commentMark with the given ID from the doc. */ + const removeCommentById = (commentId: string): boolean => { + const { state } = this.editor; + const type = state.schema.marks[markName]; + const markToRemove = type.create({ commentId }); + const { tr } = state; + tr.removeMark(0, state.doc.content.size, markToRemove); + this.editor.view.dispatch(tr); + return true; + }; + + return { + Backspace: () => { + const { selection } = this.editor.state; + if (!selection.empty) return false; + // Backspace targets the char at pos-1 + const id = commentIdAtPos(this.editor.state.doc, selection.$from.pos - 1, markName); + if (!id) return false; + return removeCommentById(id); + }, + Delete: () => { + const { selection } = this.editor.state; + if (!selection.empty) return false; + // Delete targets the char at pos + const id = commentIdAtPos(this.editor.state.doc, selection.$from.pos, markName); + if (!id) return false; + return removeCommentById(id); + }, + }; + }, + + addProseMirrorPlugins() { + const markName = this.name; + const { onOrphanedComments } = this.options; + + return [ + // Single badge per commentId at the end of its range + new Plugin({ + key: badgePluginKey, + state: { + init(_, state) { + return buildBadgeDecorations(state.doc, markName); + }, + apply(tr, value, _, newState) { + if (tr.docChanged) return buildBadgeDecorations(newState.doc, markName); + return value; + }, + }, + props: { + decorations(state) { + return badgePluginKey.getState(state) ?? DecorationSet.empty; + }, + }, + }), + // Orphan detection — fire callback when a commentId disappears from the doc + new Plugin({ + key: orphanPluginKey, + appendTransaction(transactions, oldState, newState) { + const docChanged = transactions.some((tr) => tr.docChanged); + if (!docChanged) return null; + + const oldIds = collectCommentIds(oldState.doc, markName); + const newIds = collectCommentIds(newState.doc, markName); + + const orphaned: string[] = []; + for (const id of oldIds) { + if (!newIds.has(id)) orphaned.push(id); + } + + if (orphaned.length > 0) { + setTimeout(() => onOrphanedComments(orphaned), 0); + } + + return null; + }, + }), + ]; + }, +}); + +// --------------------------------------------------------------------------- +// Re-hydration: apply marks from comment store when editor mounts/remounts +// --------------------------------------------------------------------------- + +/** Resolve the position range for a single comment in the doc. */ +function resolveCommentRange( + comment: CommentForEditor, + doc: DocLike, + docSize: number, + getTextData: () => { fullText: string; normalizedFullText: string; positions: number[] }, +): { from: number; to: number } | null { + // Try position-based first — verify text matches to guard against stale positions + if (comment.from != null && comment.to != null && comment.from < comment.to) { + if (comment.from >= 0 && comment.to <= docSize + 1) { + const textAtRange = doc.textBetween(comment.from, comment.to, ' '); + if (normalizeForSearch(textAtRange) === normalizeForSearch(comment.selectedText)) { + return { from: comment.from, to: comment.to }; + } + } + } + + // Fallback to text search + const td = getTextData(); + return findCommentByTextSearch(comment, td.fullText, td.normalizedFullText, td.positions); +} + +/** + * Apply comment marks to the editor from the comment store. + * Skips comments that are already marked in the doc. + * Deferred via setTimeout to avoid flushSync-in-lifecycle errors. + */ +export function rehydrateCommentMarks( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor: any, + comments: CommentForEditor[], +): void { + if (!editor) return; + + // Defer to next tick — editor.view.dispatch triggers flushSync internally, + // which crashes if called inside a React useEffect. + setTimeout(() => { + if (editor.isDestroyed) return; + + const { doc, schema } = editor.state; + const markType = schema.marks.commentMark; + if (!markType) return; + + const existingIds = collectCommentIds(doc, 'commentMark'); + const wantedIds = new Set(comments.map((c) => c.id)); + let { tr } = editor.state; + let changed = false; + + // Remove marks whose IDs are no longer in the comments list + for (const id of existingIds) { + if (!wantedIds.has(id)) { + tr = tr.removeMark(0, doc.content.size, markType.create({ commentId: id })); + changed = true; + } + } + + // Add marks for comments not yet in the editor + const toApply = comments.filter((c) => !existingIds.has(c.id)); + if (toApply.length > 0) { + let textData: { fullText: string; normalizedFullText: string; positions: number[] } | null = null; + const getTextData = () => { + if (!textData) { + const { text, positions } = getTextWithPositions(doc); + textData = { fullText: text, normalizedFullText: normalizeForSearch(text), positions }; + } + return textData; + }; + + const docSize = doc.content.size; + for (const comment of toApply) { + const range = resolveCommentRange(comment, doc, docSize, getTextData); + if (range) { + tr = tr.addMark(range.from, range.to, markType.create({ commentId: comment.id })); + changed = true; + } + } + } + + if (changed) { + editor.view.dispatch(tr); + } + }, 0); +} diff --git a/apps/web/components/editors/tiptap/plan-bubble-menu.tsx b/apps/web/components/editors/tiptap/plan-bubble-menu.tsx new file mode 100644 index 0000000000..2f558cac8a --- /dev/null +++ b/apps/web/components/editors/tiptap/plan-bubble-menu.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { BubbleMenu } from '@tiptap/react/menus'; +import type { Editor } from '@tiptap/core'; +import type { EditorState } from '@tiptap/pm/state'; +import { + IconBold, IconItalic, IconUnderline, IconStrikethrough, + IconCode, IconHighlight, IconLink, IconMessage, +} from '@tabler/icons-react'; +import { cn } from '@/lib/utils'; +import type { TextSelection } from './tiptap-plan-editor'; + +type PlanBubbleMenuProps = { + editor: Editor; + onComment?: (selection: TextSelection) => void; +}; + +type ToggleButtonProps = { + icon: React.ElementType; + isActive: boolean; + onClick: () => void; + title: string; + accent?: boolean; +}; + +function ToggleButton({ icon: Icon, isActive, onClick, title, accent }: ToggleButtonProps) { + return ( + + ); +} + +function MenuSeparator() { + return
; +} + +function LinkInput({ onSubmit, onCancel }: { onSubmit: (url: string) => void; onCancel: () => void }) { + const [value, setValue] = useState(''); + return ( +
+ setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onSubmit(value); + if (e.key === 'Escape') onCancel(); + }} + placeholder="Paste link..." + className="text-xs bg-transparent outline-none px-2 py-1 w-48" + autoFocus + /> + +
+ ); +} + +function FormatToolbar({ editor, onLinkClick, onComment }: { + editor: Editor; + onLinkClick: () => void; + onComment?: () => void; +}) { + return ( +
+ editor.chain().focus().toggleBold().run()} /> + editor.chain().focus().toggleItalic().run()} /> + editor.chain().focus().toggleUnderline().run()} /> + editor.chain().focus().toggleStrike().run()} /> + + editor.chain().focus().toggleCode().run()} /> + editor.chain().focus().toggleHighlight().run()} /> + + {onComment && ( + <> + + + + )} +
+ ); +} + +function shouldShowBubbleMenu({ editor: ed, state }: { editor: Editor; state: EditorState }): boolean { + const { from, to } = state.selection; + if (from === to) return false; + if (ed.isActive('codeBlock')) return false; + return true; +} + +export function PlanBubbleMenu({ editor, onComment }: PlanBubbleMenuProps) { + const [showLinkInput, setShowLinkInput] = useState(false); + + const handleLinkClick = useCallback(() => { + const existing = editor.getAttributes('link').href as string | undefined; + if (existing) { + editor.chain().focus().unsetLink().run(); + } else { + setShowLinkInput(true); + } + }, [editor]); + + const handleLinkSubmit = useCallback((url: string) => { + if (url.trim()) { + editor.chain().focus().setLink({ href: url.trim() }).run(); + } + setShowLinkInput(false); + }, [editor]); + + const handleComment = useCallback(() => { + if (!onComment) return; + const { from, to } = editor.state.selection; + const text = editor.state.doc.textBetween(from, to, ' '); + if (!text.trim()) return; + const endCoords = editor.view.coordsAtPos(to); + onComment({ text: text.trim(), from, to, position: { x: endCoords.left, y: endCoords.bottom } }); + }, [editor, onComment]); + + return ( + + {showLinkInput + ? setShowLinkInput(false)} /> + : } + + ); +} diff --git a/apps/web/components/editors/tiptap/plan-drag-handle.tsx b/apps/web/components/editors/tiptap/plan-drag-handle.tsx new file mode 100644 index 0000000000..f10c9586eb --- /dev/null +++ b/apps/web/components/editors/tiptap/plan-drag-handle.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { DragHandle } from '@tiptap/extension-drag-handle-react'; +import { IconGripVertical } from '@tabler/icons-react'; +import type { Editor } from '@tiptap/core'; + +type PlanDragHandleProps = { + editor: Editor; +}; + +export function PlanDragHandle({ editor }: PlanDragHandleProps) { + return ( + +
+ +
+
+ ); +} diff --git a/apps/web/components/editors/tiptap/plan-slash-commands.ts b/apps/web/components/editors/tiptap/plan-slash-commands.ts new file mode 100644 index 0000000000..325f94d3f8 --- /dev/null +++ b/apps/web/components/editors/tiptap/plan-slash-commands.ts @@ -0,0 +1,188 @@ +import { Extension } from '@tiptap/core'; +import { PluginKey } from '@tiptap/pm/state'; +import Suggestion from '@tiptap/suggestion'; +import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'; +import type { Editor, Range } from '@tiptap/core'; +import type { Icon } from '@tabler/icons-react'; +import { + IconH1, IconH2, IconH3, + IconList, IconListNumbers, IconListCheck, + IconCode, IconQuote, IconTable, IconMinus, +} from '@tabler/icons-react'; +import type { MenuState } from '@/components/task/chat/tiptap-suggestion'; + +// ── Types ──────────────────────────────────────────────────────────── + +export type PlanSlashCommand = { + id: string; + label: string; + description: string; + icon: Icon; + category: string; + action: (editor: Editor, range: Range) => void; +}; + +// ── Command definitions ────────────────────────────────────────────── + +const PLAN_SLASH_COMMANDS: PlanSlashCommand[] = [ + // Text + { + id: 'heading1', label: 'Heading 1', description: 'Large heading', + icon: IconH1, category: 'Text', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run(); + }, + }, + { + id: 'heading2', label: 'Heading 2', description: 'Medium heading', + icon: IconH2, category: 'Text', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleHeading({ level: 2 }).run(); + }, + }, + { + id: 'heading3', label: 'Heading 3', description: 'Small heading', + icon: IconH3, category: 'Text', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleHeading({ level: 3 }).run(); + }, + }, + // Lists + { + id: 'bulletList', label: 'Bullet List', description: 'Unordered list', + icon: IconList, category: 'Lists', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + id: 'numberedList', label: 'Numbered List', description: 'Ordered list', + icon: IconListNumbers, category: 'Lists', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + id: 'taskList', label: 'Task List', description: 'Checklist items', + icon: IconListCheck, category: 'Lists', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + // Blocks + { + id: 'codeBlock', label: 'Code Block', description: 'Fenced code', + icon: IconCode, category: 'Blocks', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + }, + }, + { + id: 'blockquote', label: 'Blockquote', description: 'Quote text', + icon: IconQuote, category: 'Blocks', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleBlockquote().run(); + }, + }, + { + id: 'table', label: 'Table', description: '3x3 table', + icon: IconTable, category: 'Blocks', + action: (editor, range) => { + editor.chain().focus().deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + }, + }, + { + id: 'horizontalRule', label: 'Divider', description: 'Horizontal line', + icon: IconMinus, category: 'Blocks', + action: (editor, range) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }, + }, +]; + +// ── Empty state ────────────────────────────────────────────────────── + +const EMPTY_STATE: MenuState = { + isOpen: false, + items: [], + query: '', + clientRect: null, + command: null, +}; + +// ── Extension ──────────────────────────────────────────────────────── + +const PlanSlashPluginKey = new PluginKey('planSlashCommands'); + +export function createPlanSlashExtension( + setMenuState: (state: MenuState) => void, + onKeyDown: (event: KeyboardEvent) => boolean, +) { + return Extension.create({ + name: 'planSlashCommands', + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + char: '/', + pluginKey: PlanSlashPluginKey, + allowSpaces: false, + startOfLine: true, + + items: ({ query }) => { + if (!query) return PLAN_SLASH_COMMANDS; + const lq = query.toLowerCase(); + return PLAN_SLASH_COMMANDS.filter( + (cmd) => cmd.label.toLowerCase().includes(lq) || cmd.id.toLowerCase().includes(lq), + ); + }, + + command: ({ editor, range, props: cmd }) => { + cmd.action(editor, range); + }, + + render: () => ({ + onStart(props: SuggestionProps) { + if (props.items.length === 0) return; + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (cmd) => props.command(cmd), + }); + }, + + onUpdate(props: SuggestionProps) { + if (props.items.length === 0) { + setMenuState(EMPTY_STATE); + return; + } + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (cmd) => props.command(cmd), + }); + }, + + onKeyDown(kd: SuggestionKeyDownProps) { + if (kd.event.key === 'Escape') { + setMenuState(EMPTY_STATE); + return true; + } + return onKeyDown(kd.event); + }, + + onExit() { + setMenuState(EMPTY_STATE); + }, + }), + }), + ]; + }, + }); +} diff --git a/apps/web/components/editors/tiptap/plan-slash-menu.tsx b/apps/web/components/editors/tiptap/plan-slash-menu.tsx new file mode 100644 index 0000000000..69863ec1b4 --- /dev/null +++ b/apps/web/components/editors/tiptap/plan-slash-menu.tsx @@ -0,0 +1,69 @@ +'use client'; + +import { createElement } from 'react'; +import { PopupMenu, PopupMenuItem, useMenuItemRefs } from '@/components/task/chat/popup-menu'; +import type { MenuState } from '@/components/task/chat/tiptap-suggestion'; +import type { PlanSlashCommand } from './plan-slash-commands'; + +type PlanSlashMenuProps = { + menuState: MenuState; + selectedIndex: number; + setSelectedIndex: (index: number) => void; +}; + +export function PlanSlashMenu({ menuState, selectedIndex, setSelectedIndex }: PlanSlashMenuProps) { + const { setItemRef } = useMenuItemRefs(selectedIndex); + + if (!menuState.isOpen || menuState.items.length === 0) return null; + + // Group items by category + const grouped = new Map(); + let idx = 0; + for (const item of menuState.items) { + const existing = grouped.get(item.category); + if (existing) { + existing.items.push(item); + } else { + grouped.set(item.category, { items: [item], startIndex: idx }); + } + idx++; + } + + return ( + menuState.command?.(menuState.items[0]!)} + hasItems={menuState.items.length > 0} + placement="below" + > + {Array.from(grouped.entries()).map(([category, group]) => ( +
+
+ + {category} + +
+ {group.items.map((item) => { + const itemIndex = menuState.items.indexOf(item); + return ( + menuState.command?.(item)} + onMouseEnter={() => setSelectedIndex(itemIndex)} + itemRef={setItemRef(itemIndex)} + /> + ); + })} +
+ ))} +
+ ); +} diff --git a/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts b/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts deleted file mode 100644 index 2730a4b678..0000000000 --- a/apps/web/components/editors/tiptap/tiptap-comment-highlight.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * TipTap extension for comment highlight decorations. - * Ported from the Milkdown ProseMirror plugin in markdown-editor.tsx. - */ - -import { Extension } from '@tiptap/core'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; -import { Decoration, DecorationSet } from '@tiptap/pm/view'; - -export type CommentHighlight = { - id: string; - selectedText: string; - comment: string; -}; - -const MIN_COMMENT_TEXT_LENGTH = 3; - -const commentHighlightPluginKey = new PluginKey<{ - deco: DecorationSet; - comments: CommentHighlight[]; -}>('tiptap-comment-highlight'); - -// Helper to get plain text content and position mapping from a document -function getTextWithPositions(doc: { descendants: (fn: (node: { isText: boolean; text?: string; type: { name: string }; isBlock: boolean }, pos: number) => void) => void }): { text: string; positions: number[] } { - let text = ''; - const positions: number[] = []; - - doc.descendants((node, pos) => { - if (node.isText && node.text) { - for (let i = 0; i < node.text.length; i++) { - positions.push(pos + i); - text += node.text[i]; - } - } else if (node.type.name === 'hardBreak') { - positions.push(-1); - text += ' '; - } else if (node.isBlock && text.length > 0 && !text.endsWith('\n')) { - positions.push(-1); - text += '\n'; - } - }); - - return { text, positions }; -} - -function normalizeForSearch(text: string): string { - return text - .replace(/\r\n/g, '\n') - .replace(/[\t ]+/g, ' ') - .replace(/\n+/g, '\n') - .trim() - .toLowerCase(); -} - -function createCommentDecorations( - doc: Parameters[0], - comments: CommentHighlight[] -): DecorationSet { - const decorations: Decoration[] = []; - - if (comments.length === 0) { - return DecorationSet.empty; - } - - const { text: fullText, positions } = getTextWithPositions(doc); - const normalizedFullText = normalizeForSearch(fullText); - - for (const comment of comments) { - const searchText = normalizeForSearch(comment.selectedText); - if (!searchText || searchText.length < MIN_COMMENT_TEXT_LENGTH) continue; - - let normalizedIndex = normalizedFullText.indexOf(searchText); - - if (normalizedIndex === -1) { - const firstLine = normalizeForSearch(comment.selectedText.split('\n')[0]); - if (firstLine.length >= MIN_COMMENT_TEXT_LENGTH) { - normalizedIndex = normalizedFullText.indexOf(firstLine); - if (normalizedIndex !== -1) { - addDecorationsForRange({ normalizedIndex, length: firstLine.length, comment, fullText, positions, decorations }); - } - } - continue; - } - - addDecorationsForRange({ normalizedIndex, length: searchText.length, comment, fullText, positions, decorations }); - } - - return DecorationSet.create(doc, decorations); -} - -interface AddDecorationsForRangeParams { - normalizedIndex: number; - length: number; - comment: CommentHighlight; - fullText: string; - positions: number[]; - decorations: Decoration[]; -} - -/** Build a mapping from normalized character indices to original character indices. */ -function buildNormToOrigMap(fullText: string, normalizedFullText: string): number[] { - const normalizedChars = normalizedFullText.split(''); - const origChars = fullText.split(''); - const normToOrig: number[] = []; - let oi = 0; - for (let ni = 0; ni < normalizedChars.length; ni++) { - while ( - oi < origChars.length && - origChars[oi].toLowerCase() !== normalizedChars[ni] && - /\s/.test(origChars[oi]) - ) { - oi++; - } - normToOrig[ni] = oi; - oi++; - } - return normToOrig; -} - -/** Push a highlight decoration if the range is valid. */ -function pushDecoration( - decorations: Decoration[], - positions: number[], - fromIdx: number, - toIdx: number, - comment: CommentHighlight, -) { - const fromPos = positions[fromIdx]; - const toPos = positions[toIdx] + 1; - if (fromPos >= 0 && toPos > fromPos) { - decorations.push( - Decoration.inline(fromPos, toPos, { - class: 'comment-highlight', - 'data-comment-id': comment.id, - title: comment.comment, - }) - ); - } -} - -function addDecorationsForRange({ - normalizedIndex, - length, - comment, - fullText, - positions, - decorations, -}: AddDecorationsForRangeParams) { - const normalizedFullText = normalizeForSearch(fullText); - const normToOrig = buildNormToOrigMap(fullText, normalizedFullText); - - const startOrig = normToOrig[normalizedIndex] ?? 0; - const endOrig = normToOrig[normalizedIndex + length - 1] ?? startOrig; - - let rangeStart = -1; - - for (let i = startOrig; i <= endOrig && i < positions.length; i++) { - if (positions[i] === -1) { - if (rangeStart !== -1) { - pushDecoration(decorations, positions, rangeStart, i - 1, comment); - rangeStart = -1; - } - } else if (rangeStart === -1) { - rangeStart = i; - } - } - - if (rangeStart !== -1 && rangeStart < positions.length) { - const lastValidIndex = Math.min(endOrig, positions.length - 1); - pushDecoration(decorations, positions, rangeStart, lastValidIndex, comment); - } -} - -export const CommentHighlightExtension = Extension.create({ - name: 'commentHighlight', - - addOptions() { - return { - comments: [] as CommentHighlight[], - }; - }, - - addProseMirrorPlugins() { - const initialComments = this.options.comments as CommentHighlight[]; - - return [ - new Plugin({ - key: commentHighlightPluginKey, - state: { - init(_, state) { - return { - deco: createCommentDecorations(state.doc, initialComments), - comments: initialComments, - }; - }, - apply(tr, value, _, newState) { - const newComments = tr.getMeta(commentHighlightPluginKey); - if (newComments !== undefined) { - return { - deco: createCommentDecorations(newState.doc, newComments), - comments: newComments, - }; - } - if (tr.docChanged) { - return { - deco: createCommentDecorations(newState.doc, value.comments), - comments: value.comments, - }; - } - return value; - }, - }, - props: { - decorations(state) { - const pluginState = commentHighlightPluginKey.getState(state); - return pluginState?.deco ?? DecorationSet.empty; - }, - }, - }), - ]; - }, -}); - -// Helper to update comments from outside -export function updateCommentHighlights( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - editor: { view: { dispatch: (tr: any) => void; state: { tr: { setMeta: (key: PluginKey, value: unknown) => any } } } }, - comments: CommentHighlight[] -) { - const tr = editor.view.state.tr.setMeta(commentHighlightPluginKey, comments); - editor.view.dispatch(tr); -} diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index 3be5d74fa3..18864f45aa 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -1,11 +1,13 @@ 'use client'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTheme } from 'next-themes'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import Link from '@tiptap/extension-link'; +import Highlight from '@tiptap/extension-highlight'; +import Underline from '@tiptap/extension-underline'; import TaskList from '@tiptap/extension-task-list'; import TaskItem from '@tiptap/extension-task-item'; import { Table } from '@tiptap/extension-table'; @@ -15,12 +17,20 @@ import { TableHeader } from '@tiptap/extension-table-header'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import { Markdown } from 'tiptap-markdown'; import { common, createLowlight } from 'lowlight'; -import { CommentHighlightExtension, updateCommentHighlights, type CommentHighlight } from './tiptap-comment-highlight'; +import { CommentMark, rehydrateCommentMarks, MIN_COMMENT_TEXT_LENGTH, type CommentForEditor } from './comment-mark'; +import { createPlanSlashExtension, type PlanSlashCommand } from './plan-slash-commands'; +import { PlanSlashMenu } from './plan-slash-menu'; +import { PlanBubbleMenu } from './plan-bubble-menu'; +import { PlanDragHandle } from './plan-drag-handle'; +import type { MenuState } from '@/components/task/chat/tiptap-suggestion'; +import type { Editor } from '@tiptap/core'; -export type { CommentHighlight }; +export type { CommentForEditor }; export type TextSelection = { text: string; + from?: number; + to?: number; position: { x: number; y: number }; }; @@ -29,210 +39,238 @@ type TipTapPlanEditorProps = { onChange: (value: string) => void; placeholder?: string; onSelectionChange?: (selection: TextSelection | null) => void; - onSelectionEnd?: (selection: TextSelection | null) => void; - comments?: CommentHighlight[]; + comments?: CommentForEditor[]; onCommentClick?: (id: string, position: { x: number; y: number }) => void; + onCommentDeleted?: (ids: string[]) => void; + onEditorReady?: (editor: Editor) => void; }; -const MIN_COMMENT_TEXT_LENGTH = 3; - const lowlight = createLowlight(common); /** Build the TipTap editor extensions array. */ -function buildEditorExtensions(placeholder: string, comments: CommentHighlight[]) { +function buildEditorExtensions( + placeholder: string, + slashExtension: ReturnType, + onOrphanedComments: (ids: string[]) => void, +) { return [ StarterKit.configure({ codeBlock: false }), CodeBlockLowlight.configure({ lowlight }), Markdown.configure({ html: true, transformPastedText: true, transformCopiedText: true }), Placeholder.configure({ placeholder }), Link.configure({ openOnClick: false }), + Highlight, + Underline, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: false }), TableRow, TableCell, TableHeader, - CommentHighlightExtension.configure({ comments }), + CommentMark.configure({ onOrphanedComments }), + slashExtension, ]; } -/** Get the position of the selection focus point for positioning popovers. */ -function getSelectionFocusPosition(selection: Selection): { x: number; y: number } | null { - const focusNode = selection.focusNode; - if (!focusNode) return null; - - const focusRange = document.createRange(); - focusRange.setStart(focusNode, selection.focusOffset); - focusRange.setEnd(focusNode, selection.focusOffset); - const focusRect = focusRange.getBoundingClientRect(); - - let x = focusRect.right || focusRect.left; - let y = focusRect.bottom || focusRect.top; - - if (x === 0 && y === 0) { - const range = selection.getRangeAt(0); - const rects = range.getClientRects(); - const lastRect = rects.length > 0 ? rects[rects.length - 1] : range.getBoundingClientRect(); - x = lastRect.right; - y = lastRect.bottom; - } - - return { x, y }; -} - -/** Hook for selection-related event handlers (mouseup, mousedown, Cmd+I). */ -function useSelectionEvents( +/** Hook for Cmd+Shift+C keyboard shortcut to trigger comment popover. */ +function useCommentShortcut( wrapperRef: React.RefObject, + editorRef: React.RefObject | null>, onSelectionChangeRef: React.RefObject<((sel: TextSelection | null) => void) | undefined>, - onSelectionEndRef: React.RefObject<((sel: TextSelection | null) => void) | undefined>, ) { - // Handle mouseup to show floating button when selection ends - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - - const handleMouseUp = (e: MouseEvent) => { - if (!onSelectionEndRef.current) return; - const mouseX = e.clientX; - const mouseY = e.clientY; - - setTimeout(() => { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed || !selection.toString().trim()) { - onSelectionEndRef.current?.(null); - return; - } - const selectedText = selection.toString().trim(); - if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) { - onSelectionEndRef.current?.(null); - return; - } - onSelectionEndRef.current?.({ text: selectedText, position: { x: mouseX, y: mouseY } }); - }, 10); - }; - - const handleMouseDown = (e: MouseEvent) => { - if ((e.target as HTMLElement).closest('.floating-comment-btn')) return; - onSelectionEndRef.current?.(null); - }; - - wrapper.addEventListener('mouseup', handleMouseUp); - wrapper.addEventListener('mousedown', handleMouseDown); - return () => { - wrapper.removeEventListener('mouseup', handleMouseUp); - wrapper.removeEventListener('mousedown', handleMouseDown); - }; - }, [wrapperRef, onSelectionEndRef]); - - // Handle Cmd+I to trigger selection popover useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return; const handleKeyDown = (e: KeyboardEvent) => { - if (!((e.metaKey || e.ctrlKey) && e.key === 'i')) return; + if (!((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'c')) return; if (!onSelectionChangeRef.current) return; - const selection = window.getSelection(); - if (!selection) return; e.preventDefault(); e.stopImmediatePropagation(); - if (selection.isCollapsed || !selection.toString().trim()) return; - const selectedText = selection.toString().trim(); - if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) return; + const ed = editorRef.current; + if (!ed) return; - const pos = getSelectionFocusPosition(selection); - if (pos) { - onSelectionChangeRef.current({ text: selectedText, position: pos }); - } + const { from, to } = ed.state.selection; + if (from === to) return; + const text = ed.state.doc.textBetween(from, to, ' ').trim(); + if (text.length < MIN_COMMENT_TEXT_LENGTH) return; + + const endCoords = ed.view.coordsAtPos(to); + onSelectionChangeRef.current({ text, from, to, position: { x: endCoords.left, y: endCoords.bottom } }); }; wrapper.addEventListener('keydown', handleKeyDown, true); return () => wrapper.removeEventListener('keydown', handleKeyDown, true); - }, [wrapperRef, onSelectionChangeRef]); + }, [wrapperRef, editorRef, onSelectionChangeRef]); +} + +const EMPTY_SLASH_STATE: MenuState = { + isOpen: false, items: [], query: '', clientRect: null, command: null, +}; + +/** Handle keyboard events for slash menu navigation. */ +function handleSlashKeyDown( + event: KeyboardEvent, + menu: MenuState, + setIndex: React.Dispatch>, + indexRef: React.RefObject, +): boolean { + if (!menu.isOpen || !menu.items.length) return false; + const len = menu.items.length; + if (event.key === 'ArrowDown') { setIndex((p) => (p + 1) % len); return true; } + if (event.key === 'ArrowUp') { setIndex((p) => (p - 1 + len) % len); return true; } + if (event.key === 'Enter') { + const item = menu.items[indexRef.current]; + if (item && menu.command) menu.command(item); + return true; + } + return false; +} + +/** Slash command menu state and keyboard navigation. */ +function useSlashMenu() { + const [menuState, setMenuState] = useState>(EMPTY_SLASH_STATE); + const [selectedIndex, setSelectedIndex] = useState(0); + const selectedIndexRef = useRef(0); + useEffect(() => { setSelectedIndex(0); }, [menuState.items]); + + // Current handler — recreated when menuState changes, captures latest state + const keyDown = useCallback( + (event: KeyboardEvent) => handleSlashKeyDown(event, menuState, setSelectedIndex, selectedIndexRef), + [menuState], + ); + // Ref synced via layout effect so it's always current + const keyDownRef = useRef<(event: KeyboardEvent) => boolean>(keyDown); + useLayoutEffect(() => { keyDownRef.current = keyDown; }); + useLayoutEffect(() => { selectedIndexRef.current = selectedIndex; }); + + // Stable callback passed to extension — never changes, delegates via ref + const stableKeyDown = useCallback((event: KeyboardEvent) => keyDownRef.current(event), []); + + /* eslint-disable react-hooks/refs -- stableKeyDown reads ref for deferred access, not during render */ + const extension = useMemo( + () => createPlanSlashExtension(setMenuState, stableKeyDown), + [stableKeyDown], + ); + /* eslint-enable react-hooks/refs */ + + return { menuState, selectedIndex, setSelectedIndex, extension }; +} + +/** Comment highlight / badge click handler via event delegation. */ +function useCommentClickHandler( + wrapperRef: React.RefObject, + onCommentClickRef: React.RefObject<((id: string, pos: { x: number; y: number }) => void) | undefined>, +) { + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const handleClick = (e: MouseEvent) => { + if (!onCommentClickRef.current) return; + const target = e.target as HTMLElement; + // Check badge first, then the mark wrapper (comment-highlight) + const badge = target.closest('.comment-badge'); + if (badge) { + const commentId = badge.getAttribute('data-comment-id'); + if (commentId) { + e.preventDefault(); + e.stopPropagation(); + onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY }); + } + return; + } + // Clicked on highlighted text — read commentId directly from the mark span + const highlight = target.closest('.comment-highlight'); + if (!highlight) return; + const commentId = highlight.getAttribute('data-comment-id'); + if (commentId) { + e.preventDefault(); + e.stopPropagation(); + onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY }); + } + }; + wrapper.addEventListener('click', handleClick); + return () => wrapper.removeEventListener('click', handleClick); + }, [wrapperRef, onCommentClickRef]); } export function TipTapPlanEditor({ - value, - onChange, - placeholder = 'Start typing...', - onSelectionChange, - onSelectionEnd, - comments = [], - onCommentClick, + value, onChange, placeholder = 'Start typing...', + onSelectionChange, comments = [], onCommentClick, + onCommentDeleted, onEditorReady, }: TipTapPlanEditorProps) { const { resolvedTheme } = useTheme(); const wrapperRef = useRef(null); + const editorRef = useRef>(null); const onChangeRef = useRef(onChange); const onSelectionChangeRef = useRef(onSelectionChange); - const onSelectionEndRef = useRef(onSelectionEnd); const onCommentClickRef = useRef(onCommentClick); + const onCommentDeletedRef = useRef(onCommentDeleted); + const onEditorReadyRef = useRef(onEditorReady); const [isReady, setIsReady] = useState(false); - // Keep refs updated + const slash = useSlashMenu(); + useEffect(() => { onChangeRef.current = onChange; }, [onChange]); useEffect(() => { onSelectionChangeRef.current = onSelectionChange; }, [onSelectionChange]); - useEffect(() => { onSelectionEndRef.current = onSelectionEnd; }, [onSelectionEnd]); useEffect(() => { onCommentClickRef.current = onCommentClick; }, [onCommentClick]); + useEffect(() => { onCommentDeletedRef.current = onCommentDeleted; }, [onCommentDeleted]); + useEffect(() => { onEditorReadyRef.current = onEditorReady; }, [onEditorReady]); + const stableOrphanHandler = useCallback((ids: string[]) => { + onCommentDeletedRef.current?.(ids); + }, []); + + /* eslint-disable react-hooks/refs -- stableOrphanHandler reads ref for deferred access, not during render */ const extensions = useMemo( - () => buildEditorExtensions(placeholder, comments), - // eslint-disable-next-line react-hooks/exhaustive-deps - [placeholder], + () => buildEditorExtensions(placeholder, slash.extension, stableOrphanHandler), + [placeholder, slash.extension, stableOrphanHandler], ); + /* eslint-enable react-hooks/refs */ const editor = useEditor({ - immediatelyRender: false, - extensions, - content: value, + immediatelyRender: false, extensions, content: value, editorProps: { attributes: { class: 'tiptap-plan-editor' } }, onUpdate: ({ editor: ed }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined; onChangeRef.current(md ?? ed.getText()); }, - onCreate: () => { setIsReady(true); }, + onCreate: ({ editor: ed }) => { + setIsReady(true); + onEditorReadyRef.current?.(ed); + }, }); - // Update comment decorations when comments change + useEffect(() => { editorRef.current = editor; }, [editor]); + + // Re-hydrate comment marks when comments change or editor becomes ready useEffect(() => { if (!editor || !isReady) return; - try { - updateCommentHighlights(editor as Parameters[0], comments); - } catch { - // Editor might not be ready yet + try { rehydrateCommentMarks(editor, comments); } catch { + // Silently ignore — editor may be in a transitional state } }, [comments, editor, isReady]); - // Set up click handler for comment highlights via event delegation - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; + useCommentClickHandler(wrapperRef, onCommentClickRef); + useCommentShortcut(wrapperRef, editorRef, onSelectionChangeRef); - const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement; - const commentHighlight = target.closest('.comment-highlight'); - if (commentHighlight && onCommentClickRef.current) { - const commentId = commentHighlight.getAttribute('data-comment-id'); - if (commentId) { - onCommentClickRef.current(commentId, { x: e.clientX, y: e.clientY }); - } - } - }; - - wrapper.addEventListener('click', handleClick); - return () => wrapper.removeEventListener('click', handleClick); - }, []); - - useSelectionEvents(wrapperRef, onSelectionChangeRef, onSelectionEndRef); + const handleBubbleComment = useCallback((sel: TextSelection) => { + onSelectionChangeRef.current?.(sel); + }, [onSelectionChangeRef]); return ( -
+
+ {editor && isReady && ( + <> + + + + )} + {!isReady && (
Loading editor... diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx index fbf9c8ec79..d50554398c 100644 --- a/apps/web/components/task/chat/popup-menu.tsx +++ b/apps/web/components/task/chat/popup-menu.tsx @@ -18,6 +18,8 @@ export type PopupMenuProps = { children: ReactNode; emptyState?: ReactNode; hasItems?: boolean; + /** 'above' (default) positions bottom edge above cursor; 'below' positions top edge below cursor. */ + placement?: 'above' | 'below'; }; export function PopupMenu({ @@ -29,6 +31,7 @@ export function PopupMenu({ children, emptyState, hasItems = true, + placement = 'above', }: PopupMenuProps) { const menuRef = useRef(null); @@ -51,7 +54,7 @@ export function PopupMenu({ if (position) return position; if (clientRectFn) { const rect = clientRectFn(); - if (rect) return { x: rect.left, y: rect.top }; + if (rect) return { x: rect.left, y: placement === 'below' ? rect.bottom : rect.top }; } return null; })(); @@ -60,14 +63,16 @@ export function PopupMenu({ return null; } - // Calculate position (above cursor) with content-based width + // Calculate position with content-based width const menuStyle: React.CSSProperties = { position: 'fixed', left: Math.max(8, resolvedPosition.x), - bottom: window.innerHeight - resolvedPosition.y + 8, maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8), maxHeight: MENU_HEIGHT, zIndex: 50, + ...(placement === 'below' + ? { top: resolvedPosition.y + 8 } + : { bottom: window.innerHeight - resolvedPosition.y + 8 }), }; const menu = ( diff --git a/apps/web/components/task/plan-selection-popover.tsx b/apps/web/components/task/plan-selection-popover.tsx index 4680342a52..36fd31c645 100644 --- a/apps/web/components/task/plan-selection-popover.tsx +++ b/apps/web/components/task/plan-selection-popover.tsx @@ -1,7 +1,8 @@ 'use client'; import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { IconPlus, IconTrash } from '@tabler/icons-react'; +import { createPortal } from 'react-dom'; +import { IconPlus, IconTrash, IconGripHorizontal } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { Textarea } from '@kandev/ui/textarea'; import { cn } from '@/lib/utils'; @@ -16,21 +17,66 @@ type PlanSelectionPopoverProps = { position: SelectionPosition; onAdd: (comment: string, selectedText: string) => void; onClose: () => void; - editingComment?: string; // Pre-fill if editing existing comment - onDelete?: () => void; // Delete callback when editing existing comment + editingComment?: string; + onDelete?: () => void; }; +const POPOVER_WIDTH = 340; +const POPOVER_HEIGHT = 180; +const MARGIN = 8; + function computePopoverPosition(position: SelectionPosition): { left: number; top: number } { - const popoverWidth = 384; - const popoverHeight = 200; - const gap = 8; - let left = position.x + gap; - let top = position.y + gap; - if (left + popoverWidth > window.innerWidth - 16) left = Math.max(16, window.innerWidth - popoverWidth - 16); - if (top + popoverHeight > window.innerHeight - 16) top = Math.max(16, position.y - popoverHeight - gap); + // Place directly below cursor, aligned to left of click + let left = position.x; + let top = position.y + 4; + + // Clamp horizontal — keep popover on screen + if (left + POPOVER_WIDTH > window.innerWidth - MARGIN) { + left = window.innerWidth - POPOVER_WIDTH - MARGIN; + } + if (left < MARGIN) left = MARGIN; + + // If overflows bottom, flip above cursor + if (top + POPOVER_HEIGHT > window.innerHeight - MARGIN) { + top = position.y - POPOVER_HEIGHT - 4; + } + if (top < MARGIN) top = MARGIN; + return { left, top }; } +/** Drag support for the popover. */ +function useDrag() { + const [offset, setOffset] = useState({ dx: 0, dy: 0 }); + const dragging = useRef(false); + const startPos = useRef({ x: 0, y: 0 }); + const startOffset = useRef({ dx: 0, dy: 0 }); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + startPos.current = { x: e.clientX, y: e.clientY }; + startOffset.current = { ...offset }; + + const onMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return; + setOffset({ + dx: startOffset.current.dx + ev.clientX - startPos.current.x, + dy: startOffset.current.dy + ev.clientY - startPos.current.y, + }); + }; + const onMouseUp = () => { + dragging.current = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }, [offset]); + + return { offset, onMouseDown }; +} + function usePopoverDismiss(onClose: () => void, popoverRef: React.RefObject) { useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -58,6 +104,7 @@ export function PlanSelectionPopover({ const [comment, setComment] = useState(editingComment || ''); const textareaRef = useRef(null); const popoverRef = useRef(null); + const { offset, onMouseDown } = useDrag(); useEffect(() => { textareaRef.current?.focus(); }, []); usePopoverDismiss(onClose, popoverRef); @@ -73,67 +120,79 @@ export function PlanSelectionPopover({ }, [handleSubmit]); const isDisabled = !comment.trim(); - const previewText = selectedText.length > 80 ? selectedText.slice(0, 80).trim() + '…' : selectedText; + const previewText = selectedText.length > 80 ? selectedText.slice(0, 80).trim() + '\u2026' : selectedText; const { left, top } = computePopoverPosition(position); - return ( + // Portal to document.body to escape dockview's CSS transform containing block + return createPortal(
- {/* Text preview */} -

- “{previewText}” -

- - {/* Comment input */} -