diff --git a/web/package-lock.json b/web/package-lock.json index d2766aaf..c64099e4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,6 +12,7 @@ "@tiptap/core": "^3.22.5", "@tiptap/extension-bubble-menu": "^3.22.5", "@tiptap/extension-code-block-lowlight": "^3.22.5", + "@tiptap/extension-collaboration": "^3.22.5", "@tiptap/extension-link": "^3.22.5", "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/extension-table": "^3.22.5", @@ -20,13 +21,15 @@ "@tiptap/pm": "^3.20.4", "@tiptap/starter-kit": "^3.22.5", "@tiptap/suggestion": "^3.22.5", + "@tiptap/y-tiptap": "^3.0.3", "diff": "^9.0.0", "dompurify": "^3.4.2", "lowlight": "^3.3.0", "mermaid": "^11.14.0", "qrcode": "^1.5.4", "svelte-dnd-action": "^0.9.69", - "tiptap-markdown": "^0.9.0" + "tiptap-markdown": "^0.9.0", + "yjs": "^13.6.30" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -773,6 +776,22 @@ "lowlight": "^2 || ^3" } }, + "node_modules/@tiptap/extension-collaboration": { + "version": "3.22.5", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.22.5.tgz", + "integrity": "sha512-3rax34HKSo8L5ihv5iGxISNBUIdVP0Fq9O6T/mq4kQOLhVhNbqwr9I/lWOvaz24nPE7u5Vkz0Ii3zWGlzxyPPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.22.5", + "@tiptap/pm": "3.22.5", + "@tiptap/y-tiptap": "^3.0.2", + "yjs": "^13" + } + }, "node_modules/@tiptap/extension-document": { "version": "3.22.5", "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz", @@ -1128,6 +1147,26 @@ "@tiptap/pm": "3.22.5" } }, + "node_modules/@tiptap/y-tiptap": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.3.tgz", + "integrity": "sha512-8UvuV4lTisCE9cMTc/X8kRyTn9edUO7Kball0I6wb17VwZSjNDfh/YKtP4O5vcPawEzFHQIvZGq/k1h37kAf0w==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.100" + }, + "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" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2418,6 +2457,16 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/katex": { "version": "0.16.45", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", @@ -2482,6 +2531,27 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3849,6 +3919,27 @@ "node": ">=8" } }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -3890,6 +3981,23 @@ "node": ">=6" } }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/web/package.json b/web/package.json index 297739bd..0ad5ff69 100644 --- a/web/package.json +++ b/web/package.json @@ -35,6 +35,7 @@ "@tiptap/core": "^3.22.5", "@tiptap/extension-bubble-menu": "^3.22.5", "@tiptap/extension-code-block-lowlight": "^3.22.5", + "@tiptap/extension-collaboration": "3.22.5", "@tiptap/extension-link": "^3.22.5", "@tiptap/extension-placeholder": "^3.22.5", "@tiptap/extension-table": "^3.22.5", @@ -43,12 +44,14 @@ "@tiptap/pm": "^3.20.4", "@tiptap/starter-kit": "^3.22.5", "@tiptap/suggestion": "^3.22.5", + "@tiptap/y-tiptap": "^3.0.3", "diff": "^9.0.0", "dompurify": "^3.4.2", "lowlight": "^3.3.0", "mermaid": "^11.14.0", "qrcode": "^1.5.4", "svelte-dnd-action": "^0.9.69", - "tiptap-markdown": "^0.9.0" + "tiptap-markdown": "^0.9.0", + "yjs": "^13.6.30" } } diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index 1327fc66..686234e0 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -5,6 +5,8 @@ import { Plugin } from '@tiptap/pm/state'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import StarterKit from '@tiptap/starter-kit'; + import { Collaboration } from '@tiptap/extension-collaboration'; + import type * as Y from 'yjs'; import TaskList from '@tiptap/extension-task-list'; import TaskItem from '@tiptap/extension-task-item'; import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'; @@ -342,11 +344,29 @@ let { content = '', editable = true, + ydoc, onUpdate, onEditor, }: { content?: string; editable?: boolean; + /** + * Optional Yjs document to bind this editor to via the Tiptap + * Collaboration extension (PLAN-1248). When set, the y-tiptap + * binding takes ownership of document state and undo/redo — + * StarterKit's history is disabled below so the two systems + * don't fight over keystrokes. + * + * When undefined (the default), the editor behaves exactly as + * it did pre-collab: a single ProseMirror Y-Doc-less Doc with + * StarterKit's built-in undoRedo. This keeps every existing + * call site backward-compatible. + * + * The WebSocket provider that syncs ydoc with the server is + * wired by the editor's host route (TASK-1260); this prop just + * accepts the constructed Y.Doc from there. + */ + ydoc?: Y.Doc; onUpdate?: (markdown: string) => void; onEditor?: (editor: Editor) => void; } = $props(); @@ -488,10 +508,16 @@ const getAttachmentUrl = (uuid: string, variant?: AttachmentVariant) => wsSlug ? api.attachments.downloadUrl(wsSlug, uuid, variant) : `pad-attachment:${uuid}`; + // When a Y.Doc is supplied, the Collaboration extension owns + // undo/redo (Yjs maintains its own history that survives peer + // edits correctly) and StarterKit's undoRedo would fight it. + // In Tiptap v3 the option is `undoRedo: false`; v2's `history` + // key was renamed during the v3 migration. const extensions = [ StarterKit.configure({ codeBlock: false, link: false, // We use our own SafeLink extension below + ...(ydoc ? { undoRedo: false } : {}), }), MermaidCodeBlock.configure({ HTMLAttributes: { class: 'code-block' }, @@ -550,6 +576,15 @@ }, }), AttachmentChip.configure({ getDownloadUrl: getAttachmentUrl, workspaceSlug: wsSlug }), + // When a Y.Doc is provided, register the Collaboration + // extension so the y-tiptap binding takes over document + // state. Without ydoc this slot is empty and the editor + // behaves exactly as it did pre-collab. The `field` option + // defaults to "default" which matches what the WS provider + // (TASK-1260) and tests will use; explicit here so the + // shape is grep-able from a future RedisOpBus / multi-Doc + // path that might want a different field name per item. + ...(ydoc ? [Collaboration.configure({ document: ydoc, field: 'default' })] : []), AttachmentUpload.configure({ upload: async (file) => { if (!wsSlug) { @@ -761,9 +796,26 @@ }); }); - // Sync content when prop changes (e.g. doc switch, external update) + // Sync content when prop changes (e.g. doc switch, external update). + // + // CRITICAL: when ydoc is set, this path MUST NOT run. Y.Doc is the + // authoritative state under collab; calling editor.commands.setContent + // would route through the y-tiptap binding as a LOCAL ProseMirror + // change and overwrite peers' state with stale REST markdown on + // every prop refresh / item switch. Markdown→Y.Doc seeding for the + // first-edit-on-empty case is TASK-1262's concern; it goes through + // Y.Doc's own primitives, not setContent. const tracker: { prev: string | undefined } = { prev: undefined }; $effect(() => { + if (ydoc) { + // Capture the initial value so a future ydoc=undefined render + // (host route swapping back to non-collab mode) doesn't see + // `prev === undefined` and skip the first sync. In practice + // the host route doesn't switch ydoc on/off mid-editor today, + // but the cheap update here keeps the contract honest. + tracker.prev = content; + return; + } if (tracker.prev === undefined) { // First run: capture initial value without syncing tracker.prev = content;