mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
feat(editor): wire optional ydoc + Collaboration extension (TASK-1258) (#456)
* feat(editor): wire optional ydoc + Collaboration extension into Editor.svelte (TASK-1258)
Productionizes the Yjs binding pattern verified in the
feat/yjs-tiptap-spike branch (TASK-1245 sandbox). Editor.svelte now
accepts an optional `ydoc?: Y.Doc` prop; when set, it registers the
Tiptap Collaboration extension and disables StarterKit's undoRedo
so the y-tiptap binding takes over document state + history.
Editor.svelte changes:
- Imports: `Collaboration` from `@tiptap/extension-collaboration`,
type-only `Y.Doc` from `yjs` (the actual constructor lives in the
caller route — TASK-1260 wires the WS provider).
- Props: new optional `ydoc?: Y.Doc`. When undefined the editor's
extension list and behaviour are byte-identical to today;
every existing call site stays backward-compatible.
- Extensions:
· StarterKit.configure now spreads `{ undoRedo: false }` IFF
ydoc is set (Yjs owns history when collab is active; v3's
option is `undoRedo`, the v2 `history` key was renamed).
· `...(ydoc ? [Collaboration.configure({ document: ydoc,
field: 'default' })] : [])` slot at the end of the extension
array — empty when ydoc is undefined, so existing single-Doc
usage is unaffected.
Dependencies added to web/package.json:
- `yjs@^13.6.30`
- `@tiptap/extension-collaboration@3.22.5` — EXACT pin (no caret)
because @tiptap/core is on 3.22.5 and the latest published
3.23.x of extension-collaboration requires a matching core. The
CLAUDE.md note in TASK-1269 codifies the multi-package coordinated
bump rule.
- `@tiptap/y-tiptap@^3.0.3`
Out of scope (per task spec): WS provider plumbing (TASK-1260),
lifecycle binding to item-detail page (TASK-1260), first-edit seed
from markdown (TASK-1262), CollaborationCursor extension
(TASK-1264).
Parent: PLAN-1248. Phase 1 — Backend foundation complete; Phase 2
is the next logical block to start.
* fix(editor): gate content-prop sync $effect when ydoc is active per Codex review (round 1)
P1: Editor.svelte's existing $effect that calls
editor.commands.setContent on prop-content changes (item switch,
external REST update) would route through the y-tiptap binding as a
LOCAL ProseMirror change when Collaboration is registered —
overwriting peers' Y.Doc state with stale REST markdown on every
content prop refresh.
Add an early return in the $effect when ydoc is set. Y.Doc is the
authoritative state under collab; markdown→Y.Doc seeding for the
first-edit-on-empty case lives in TASK-1262 and uses Y.Doc's own
primitives, NOT setContent.
The early return also captures `tracker.prev = content` so a future
host route that swapped ydoc on/off mid-editor wouldn't see the
non-collab branch's `prev === undefined` and accidentally skip its
first sync. In practice ydoc is set once per editor mount today,
but the cheap capture keeps the contract honest.
This commit is contained in:
Generated
+109
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user