@beyondwork/docx-react-component 1.0.26-rc → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.26rc",
4
+ "version": "1.0.26",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
+ "packageManager": "pnpm@10.30.3",
6
7
  "type": "module",
7
8
  "sideEffects": [
8
9
  "**/*.css"
@@ -88,6 +89,30 @@
88
89
  "./ui-tailwind/theme/editor-theme.css": "./src/ui-tailwind/theme/editor-theme.css"
89
90
  },
90
91
  "types": "./src/index.ts",
92
+ "scripts": {
93
+ "build": "tsup",
94
+ "test": "bash scripts/run-workspace-tests.sh",
95
+ "test:repo": "node scripts/run-repo-tests.mjs core",
96
+ "test:repo:all": "node scripts/run-repo-tests.mjs all",
97
+ "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
98
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
99
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
100
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
101
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
102
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
103
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
104
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
105
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
106
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
107
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
108
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
109
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
110
+ "wave:status": "bash scripts/wave-status.sh",
111
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
112
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
113
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
114
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
115
+ },
91
116
  "keywords": [
92
117
  "docx",
93
118
  "word",
@@ -130,7 +155,15 @@
130
155
  "prosemirror-view": "^1.41.7",
131
156
  "react": "^19.2.0",
132
157
  "react-dom": "^19.2.0",
133
- "tailwindcss": "^4.2.2"
158
+ "tailwindcss": "^4.2.2",
159
+ "yjs": "^13.6.0",
160
+ "y-prosemirror": "^1.2.0",
161
+ "y-protocols": "^1.0.0"
162
+ },
163
+ "peerDependenciesMeta": {
164
+ "yjs": { "optional": true },
165
+ "y-prosemirror": { "optional": true },
166
+ "y-protocols": { "optional": true }
134
167
  },
135
168
  "devDependencies": {
136
169
  "@chllming/wave-orchestration": "^0.9.15",
@@ -148,30 +181,19 @@
148
181
  "react": "19.2.4",
149
182
  "react-dom": "19.2.4",
150
183
  "tsup": "^8.3.0",
151
- "tsx": "^4.21.0"
184
+ "tsx": "^4.21.0",
185
+ "y-prosemirror": "^1.3.7",
186
+ "y-protocols": "^1.0.7",
187
+ "yjs": "^13.6.30"
152
188
  },
153
- "scripts": {
154
- "build": "tsup",
155
- "test": "bash scripts/run-workspace-tests.sh",
156
- "test:repo": "node scripts/run-repo-tests.mjs core",
157
- "test:repo:all": "node scripts/run-repo-tests.mjs all",
158
- "test:repo:optional": "node scripts/run-repo-tests.mjs optional",
159
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
160
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
161
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
162
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
163
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
164
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
165
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
166
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
167
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
168
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
169
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
170
- "wave:launch:managed": "bash scripts/wave-launch.sh",
171
- "wave:status": "bash scripts/wave-status.sh",
172
- "wave:watch": "bash scripts/wave-watch.sh --follow",
173
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
174
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
175
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
189
+ "pnpm": {
190
+ "onlyBuiltDependencies": [
191
+ "esbuild",
192
+ "sharp"
193
+ ],
194
+ "overrides": {
195
+ "react": "19.2.4",
196
+ "react-dom": "19.2.4"
197
+ }
176
198
  }
177
- }
199
+ }
@@ -1,13 +1,8 @@
1
1
  import type { PersistedEditorSnapshot as RuntimePersistedEditorSnapshot } from "../core/state/editor-state.ts";
2
- import type {
3
- FieldFamily as FieldFamilyType,
4
- FieldRefreshStatus as FieldRefreshStatusType,
5
- SupportedFieldFamily as SupportedFieldFamilyType,
6
- } from "../model/canonical-document.ts";
7
2
 
8
- export type FieldFamily = FieldFamilyType;
9
- export type FieldRefreshStatus = FieldRefreshStatusType;
10
- export type SupportedFieldFamily = SupportedFieldFamilyType;
3
+ export type FieldFamily = import("../model/canonical-document.ts").FieldFamily;
4
+ export type FieldRefreshStatus = import("../model/canonical-document.ts").FieldRefreshStatus;
5
+ export type SupportedFieldFamily = import("../model/canonical-document.ts").SupportedFieldFamily;
11
6
 
12
7
  export type ExternalDocumentSource =
13
8
  | {
@@ -1388,6 +1383,8 @@ export interface WordReviewEditorRef {
1388
1383
  export interface WordReviewEditorProps {
1389
1384
  documentId: string;
1390
1385
  currentUser: EditorUser;
1386
+ ydoc?: import('yjs').Doc;
1387
+ awareness?: import("y-protocols/awareness").Awareness;
1391
1388
  initialDocx?: Uint8Array | ArrayBuffer;
1392
1389
  initialSessionState?: EditorSessionState;
1393
1390
  initialSnapshot?: PersistedEditorSnapshot;
@@ -104,6 +104,7 @@ export function rangeStaysWithinSingleParagraph(
104
104
  }
105
105
 
106
106
  export function canCreateDocxCommentAnchor(
107
+ content: unknown,
107
108
  anchor: ReviewAnchor,
108
109
  ): boolean {
109
110
  if (anchor.kind !== "range") {
@@ -111,7 +112,11 @@ export function canCreateDocxCommentAnchor(
111
112
  }
112
113
 
113
114
  const normalized = normalizeRange(anchor.range);
114
- return normalized.from !== normalized.to;
115
+ if (normalized.from === normalized.to) {
116
+ return false;
117
+ }
118
+
119
+ return rangeStaysWithinSingleParagraph(content, normalized);
115
120
  }
116
121
 
117
122
  function readSurfaceBlocks(
@@ -261,25 +261,19 @@ export function serializeCommentAnchorsIntoDocumentXml(
261
261
  continue;
262
262
  }
263
263
 
264
- const startParagraph = paragraphs.find(
264
+ const paragraph = paragraphs.find(
265
265
  (candidate) =>
266
266
  anchor.range.from >= candidate.start &&
267
- anchor.range.from <= candidate.end,
268
- );
269
-
270
- const endParagraph = paragraphs.find(
271
- (candidate) =>
272
- anchor.range.to >= candidate.start &&
273
267
  anchor.range.to <= candidate.end,
274
268
  );
275
269
 
276
- if (!startParagraph || !endParagraph) {
270
+ if (!paragraph) {
277
271
  skippedCommentIds.push(thread.commentId);
278
272
  continue;
279
273
  }
280
274
 
281
- const startIndex = startParagraph.boundaries.get(anchor.range.from);
282
- const endIndex = endParagraph.boundaries.get(anchor.range.to);
275
+ const startIndex = paragraph.boundaries.get(anchor.range.from);
276
+ const endIndex = paragraph.boundaries.get(anchor.range.to);
283
277
 
284
278
  if (startIndex === undefined || endIndex === undefined) {
285
279
  skippedCommentIds.push(thread.commentId);
@@ -0,0 +1,254 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type { DocumentRuntime, DocumentRuntimeEvent, Unsubscribe } from "./document-runtime.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Serialised shapes stored inside the Y.Maps
7
+ // ---------------------------------------------------------------------------
8
+
9
+ interface YCommentThread {
10
+ commentId: string;
11
+ status: "open" | "resolved" | "detached";
12
+ anchor: { kind: string; [key: string]: unknown };
13
+ createdAt: string;
14
+ createdBy: string;
15
+ authorId: string;
16
+ body: string;
17
+ entries: Array<{
18
+ entryId: string;
19
+ authorId: string;
20
+ body: string;
21
+ createdAt: string;
22
+ }>;
23
+ resolvedAt?: string;
24
+ resolvedBy?: string;
25
+ warningIds: string[];
26
+ sourceClientId: number;
27
+ }
28
+
29
+ interface YRevisionAction {
30
+ changeId: string;
31
+ action: "accept" | "reject";
32
+ sourceClientId: number;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Public API
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export interface CollabReviewSyncHandle {
40
+ destroy(): void;
41
+ }
42
+
43
+ export function createCollabReviewSync(
44
+ ydoc: Y.Doc,
45
+ runtime: DocumentRuntime,
46
+ ): CollabReviewSyncHandle {
47
+ const yComments = ydoc.getMap<YCommentThread>("comments");
48
+ const yRevisionActions = ydoc.getMap<YRevisionAction>("revisionActions");
49
+ const clientId = ydoc.clientID;
50
+
51
+ let suppressLocalEvents = false;
52
+
53
+ // --- Local → Yjs ---------------------------------------------------------
54
+
55
+ const unsubEvents: Unsubscribe = runtime.subscribeToEvents((event) => {
56
+ if (suppressLocalEvents) return;
57
+
58
+ switch (event.type) {
59
+ case "comment_added":
60
+ pushCommentToYjs(event.commentId);
61
+ break;
62
+ case "comment_resolved":
63
+ syncCommentFieldToYjs(event.commentId);
64
+ break;
65
+ case "change_accepted":
66
+ yRevisionActions.set(revisionActionKey(event.changeId, "accept"), {
67
+ changeId: event.changeId,
68
+ action: "accept",
69
+ sourceClientId: clientId,
70
+ });
71
+ break;
72
+ case "change_rejected":
73
+ yRevisionActions.set(revisionActionKey(event.changeId, "reject"), {
74
+ changeId: event.changeId,
75
+ action: "reject",
76
+ sourceClientId: clientId,
77
+ });
78
+ break;
79
+ }
80
+ });
81
+
82
+ function pushCommentToYjs(commentId: string): void {
83
+ const thread = runtime
84
+ .getRenderSnapshot()
85
+ .comments.threads.find((t) => t.commentId === commentId);
86
+ if (!thread) return;
87
+
88
+ yComments.set(commentId, {
89
+ commentId: thread.commentId,
90
+ status: thread.status,
91
+ anchor: thread.anchor,
92
+ createdAt: thread.createdAt,
93
+ createdBy: thread.createdBy,
94
+ authorId: thread.createdBy,
95
+ body: thread.entries[0]?.body ?? "",
96
+ entries: thread.entries.map((e) => ({
97
+ entryId: e.entryId,
98
+ authorId: e.authorId,
99
+ body: e.body,
100
+ createdAt: e.createdAt,
101
+ })),
102
+ resolvedAt: thread.resolvedAt,
103
+ resolvedBy: thread.resolvedBy,
104
+ warningIds: [],
105
+ sourceClientId: clientId,
106
+ });
107
+ }
108
+
109
+ function syncCommentFieldToYjs(commentId: string): void {
110
+ const existing = yComments.get(commentId);
111
+ const thread = runtime
112
+ .getRenderSnapshot()
113
+ .comments.threads.find((t) => t.commentId === commentId);
114
+ if (!existing || !thread) return;
115
+
116
+ yComments.set(commentId, {
117
+ ...existing,
118
+ status: thread.status,
119
+ entries: thread.entries.map((e) => ({
120
+ entryId: e.entryId,
121
+ authorId: e.authorId,
122
+ body: e.body,
123
+ createdAt: e.createdAt,
124
+ })),
125
+ resolvedAt: thread.resolvedAt,
126
+ resolvedBy: thread.resolvedBy,
127
+ sourceClientId: clientId,
128
+ });
129
+ }
130
+
131
+ // --- Yjs → Local ---------------------------------------------------------
132
+
133
+ function onCommentMapChange(event: Y.YMapEvent<YCommentThread>): void {
134
+ suppressLocalEvents = true;
135
+ try {
136
+ for (const [commentId, change] of event.changes.keys) {
137
+ const entry = yComments.get(commentId);
138
+ if (!entry || entry.sourceClientId === clientId) continue;
139
+
140
+ const existing = runtime
141
+ .getRenderSnapshot()
142
+ .comments.threads.find((t) => t.commentId === commentId);
143
+
144
+ if (change.action === "add" && !existing) {
145
+ runtime.dispatch({
146
+ type: "comment.add",
147
+ comment: {
148
+ commentId: entry.commentId,
149
+ status: entry.status,
150
+ anchor: entry.anchor as never,
151
+ createdAt: entry.createdAt,
152
+ createdBy: entry.createdBy,
153
+ authorId: entry.authorId,
154
+ body: entry.body,
155
+ entries: entry.entries,
156
+ warningIds: entry.warningIds,
157
+ isResolved: entry.status === "resolved",
158
+ metadata: { source: "runtime" },
159
+ },
160
+ });
161
+ } else if (change.action === "update" && existing) {
162
+ if (entry.status === "resolved" && existing.status !== "resolved") {
163
+ runtime.dispatch({
164
+ type: "comment.resolve",
165
+ commentId,
166
+ resolvedBy: entry.resolvedBy,
167
+ });
168
+ } else if (entry.status === "open" && existing.status === "resolved") {
169
+ runtime.dispatch({ type: "comment.reopen", commentId });
170
+ }
171
+
172
+ const localEntryCount = existing.entries.length;
173
+ if (entry.entries.length > localEntryCount) {
174
+ for (const newEntry of entry.entries.slice(localEntryCount)) {
175
+ runtime.dispatch({
176
+ type: "comment.add-reply",
177
+ commentId,
178
+ body: newEntry.body,
179
+ authorId: newEntry.authorId,
180
+ });
181
+ }
182
+ }
183
+ }
184
+ }
185
+ } finally {
186
+ suppressLocalEvents = false;
187
+ }
188
+ }
189
+
190
+ function onRevisionActionMapChange(event: Y.YMapEvent<YRevisionAction>): void {
191
+ suppressLocalEvents = true;
192
+ try {
193
+ for (const [, change] of event.changes.keys) {
194
+ if (change.action !== "add") continue;
195
+ const entries = [...yRevisionActions.entries()];
196
+ const latest = entries[entries.length - 1];
197
+ if (!latest) continue;
198
+ const entry = latest[1];
199
+ if (entry.sourceClientId === clientId) continue;
200
+
201
+ runtime.dispatch({
202
+ type: entry.action === "accept" ? "change.accept" : "change.reject",
203
+ changeId: entry.changeId,
204
+ });
205
+ }
206
+ } finally {
207
+ suppressLocalEvents = false;
208
+ }
209
+ }
210
+
211
+ yComments.observe(onCommentMapChange);
212
+ yRevisionActions.observe(onRevisionActionMapChange);
213
+
214
+ // --- Initial sync: push existing comments to Yjs if first client ----------
215
+
216
+ const snapshot = runtime.getRenderSnapshot();
217
+ if (yComments.size === 0 && snapshot.comments.threads.length > 0) {
218
+ ydoc.transact(() => {
219
+ for (const thread of snapshot.comments.threads) {
220
+ yComments.set(thread.commentId, {
221
+ commentId: thread.commentId,
222
+ status: thread.status,
223
+ anchor: thread.anchor,
224
+ createdAt: thread.createdAt,
225
+ createdBy: thread.createdBy,
226
+ authorId: thread.createdBy,
227
+ body: thread.entries[0]?.body ?? "",
228
+ entries: thread.entries.map((e) => ({
229
+ entryId: e.entryId,
230
+ authorId: e.authorId,
231
+ body: e.body,
232
+ createdAt: e.createdAt,
233
+ })),
234
+ resolvedAt: thread.resolvedAt,
235
+ resolvedBy: thread.resolvedBy,
236
+ warningIds: [],
237
+ sourceClientId: clientId,
238
+ });
239
+ }
240
+ });
241
+ }
242
+
243
+ return {
244
+ destroy() {
245
+ unsubEvents();
246
+ yComments.unobserve(onCommentMapChange);
247
+ yRevisionActions.unobserve(onRevisionActionMapChange);
248
+ },
249
+ };
250
+ }
251
+
252
+ function revisionActionKey(changeId: string, action: string): string {
253
+ return `${changeId}:${action}`;
254
+ }
@@ -1160,9 +1160,9 @@ export function createDocumentRuntime(
1160
1160
  const selection = params.anchor
1161
1161
  ? createSelectionFromPublicAnchor(params.anchor)
1162
1162
  : state.selection;
1163
- if (!canCreateDocxCommentAnchor(anchor)) {
1163
+ if (!canCreateDocxCommentAnchor(state.document.content, anchor)) {
1164
1164
  const message =
1165
- "DOCX comments must use a non-empty range.";
1165
+ "DOCX comments must use a non-empty range that stays within a single paragraph.";
1166
1166
  emitError({
1167
1167
  errorId: createSessionId("comment-anchor", clock()),
1168
1168
  code: "validation_failed",
@@ -110,7 +110,7 @@ export function deriveCapabilities(
110
110
  activeStory.kind === "main" &&
111
111
  !snapshot.selection.isCollapsed &&
112
112
  Boolean(snapshot.surface) &&
113
- canCreateDocxCommentAnchor(toRuntimeAnchor(snapshot.selection.activeRange));
113
+ canCreateDocxCommentAnchor(snapshot.surface, toRuntimeAnchor(snapshot.selection.activeRange));
114
114
  const canExport = isReady && !exportBlocked && !hasFatalError;
115
115
 
116
116
  // Revision capabilities
@@ -169,6 +169,7 @@ import {
169
169
  } from "./browser-export";
170
170
  import { EditorShellView } from "./editor-shell-view.tsx";
171
171
  import { EditorSurfaceController } from "./editor-surface-controller.tsx";
172
+ import { createCollabReviewSync } from "../runtime/collab-review-sync.ts";
172
173
 
173
174
  export {
174
175
  __createFallbackRuntime,
@@ -503,6 +504,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
503
504
  function WordReviewEditor(props, ref) {
504
505
  const {
505
506
  currentUser,
507
+ ydoc,
508
+ awareness,
506
509
  hostAdapter,
507
510
  datastore,
508
511
  documentId,
@@ -698,6 +701,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
698
701
  activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
699
702
  }, [activeRuntime, suggestionsEnabled]);
700
703
 
704
+ useEffect(() => {
705
+ if (!ydoc || !runtime) return;
706
+ const handle = createCollabReviewSync(ydoc, runtime);
707
+ return () => handle.destroy();
708
+ }, [ydoc, runtime]);
709
+
701
710
  useEffect(() => {
702
711
  runtimeViewStateSeedRef.current = {
703
712
  workspaceMode: viewState.workspaceMode,
@@ -1705,6 +1714,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1705
1714
  <EditorSurfaceController
1706
1715
  ref={surfaceRef}
1707
1716
  currentUser={currentUser}
1717
+ ydoc={ydoc}
1718
+ awareness={awareness}
1708
1719
  snapshot={snapshot}
1709
1720
  canonicalDocument={canonicalDocument}
1710
1721
  documentNavigation={documentNavigation}
@@ -20,6 +20,8 @@ import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-st
20
20
 
21
21
  export interface EditorSurfaceControllerProps {
22
22
  currentUser: EditorUser;
23
+ ydoc?: import('yjs').Doc;
24
+ awareness?: import("y-protocols/awareness").Awareness;
23
25
  snapshot: RuntimeRenderSnapshot;
24
26
  canonicalDocument: CanonicalDocumentEnvelope;
25
27
  documentNavigation: DocumentNavigationSnapshot;
@@ -0,0 +1,40 @@
1
+ import { type Plugin } from "prosemirror-state";
2
+ import { keymap } from "prosemirror-keymap";
3
+ import { columnResizing, tableEditing } from "prosemirror-tables";
4
+ import { yCursorPlugin, ySyncPlugin, yUndoPlugin, undo, redo } from "y-prosemirror";
5
+ import type { Awareness } from "y-protocols/awareness";
6
+ import type { Doc as YDoc } from "yjs";
7
+
8
+ import {
9
+ createSelectionSyncPlugin,
10
+ type SelectionSyncCallbacks,
11
+ } from "./pm-command-bridge";
12
+
13
+ export interface CollabPluginOptions {
14
+ ydoc: YDoc;
15
+ awareness?: Awareness;
16
+ selectionCallbacks: SelectionSyncCallbacks;
17
+ }
18
+
19
+ export function createCollabPlugins(options: CollabPluginOptions): Plugin[] {
20
+ const yXmlFragment = options.ydoc.getXmlFragment("prosemirror");
21
+
22
+ const plugins: Plugin[] = [
23
+ ySyncPlugin(yXmlFragment),
24
+ yUndoPlugin(),
25
+ keymap({
26
+ "Mod-z": undo,
27
+ "Mod-y": redo,
28
+ "Shift-Mod-z": redo,
29
+ }),
30
+ createSelectionSyncPlugin(options.selectionCallbacks),
31
+ tableEditing(),
32
+ columnResizing(),
33
+ ];
34
+
35
+ if (options.awareness) {
36
+ plugins.splice(1, 0, yCursorPlugin(options.awareness));
37
+ }
38
+
39
+ return plugins;
40
+ }
@@ -9,7 +9,13 @@ import {
9
9
  } from "../../ui/headless/selection-helpers";
10
10
  import type { PositionMap } from "./pm-position-map";
11
11
 
12
- export interface CommandBridgeCallbacks {
12
+ export interface SelectionSyncCallbacks {
13
+ onSelectionChange: (selection: SelectionSnapshot) => void;
14
+ getPositionMap: () => PositionMap | null;
15
+ isSelectionSyncSuppressed?: () => boolean;
16
+ }
17
+
18
+ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
13
19
  onInsertText: (text: string) => void;
14
20
  onDeleteBackward: () => void;
15
21
  onDeleteForward: () => void;
@@ -20,48 +26,20 @@ export interface CommandBridgeCallbacks {
20
26
  onUndo: () => void;
21
27
  onRedo: () => void;
22
28
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
23
- onSelectionChange: (selection: SelectionSnapshot) => void;
24
- getPositionMap: () => PositionMap | null;
25
- isSelectionSyncSuppressed?: () => boolean;
26
29
  }
27
30
 
28
31
  const bridgeKey = new PluginKey("command-bridge");
29
32
 
30
- /**
31
- * Creates ProseMirror plugins that intercept user input and dispatch
32
- * runtime commands instead of letting PM mutate its own state.
33
- *
34
- * All doc-changing transactions are blocked. Only the explicit input
35
- * hooks below are allowed to trigger runtime commands.
36
- */
37
- export function createCommandBridgePlugins(
38
- callbacks: CommandBridgeCallbacks,
39
- ): Plugin[] {
40
- let isComposing = false;
41
-
42
- // Transaction filter: block ALL doc-changing transactions.
43
- // The runtime is the sole authority for document mutations.
44
- const filterPlugin = new Plugin({
45
- key: bridgeKey,
46
- filterTransaction(tr) {
47
- // Allow selection-only and metadata-only transactions
48
- if (!tr.docChanged) return true;
49
- // Block doc changes — runtime handles mutations via callbacks
50
- return false;
51
- },
52
- });
53
-
54
- // Selection sync: when PM selection changes, notify the runtime.
55
- const selectionPlugin = new Plugin({
33
+ export function createSelectionSyncPlugin(
34
+ callbacks: SelectionSyncCallbacks,
35
+ ): Plugin {
36
+ return new Plugin({
56
37
  view() {
57
38
  return {
58
39
  update(view, prevState) {
59
40
  if (callbacks.isSelectionSyncSuppressed?.()) {
60
41
  return;
61
42
  }
62
- if (isComposing) {
63
- return;
64
- }
65
43
  if (!view.state.selection.eq(prevState.selection)) {
66
44
  const posMap = callbacks.getPositionMap();
67
45
  if (!posMap) return;
@@ -82,6 +60,22 @@ export function createCommandBridgePlugins(
82
60
  };
83
61
  },
84
62
  });
63
+ }
64
+
65
+ export function createCommandBridgePlugins(
66
+ callbacks: CommandBridgeCallbacks,
67
+ ): Plugin[] {
68
+ let isComposing = false;
69
+
70
+ const filterPlugin = new Plugin({
71
+ key: bridgeKey,
72
+ filterTransaction(tr) {
73
+ if (!tr.docChanged) return true;
74
+ return false;
75
+ },
76
+ });
77
+
78
+ const selectionPlugin = createSelectionSyncPlugin(callbacks);
85
79
 
86
80
  // Text input hook: intercept typed characters.
87
81
  const inputPlugin = new Plugin({
@@ -39,6 +39,8 @@ import {
39
39
  createCommandBridgePlugins,
40
40
  type CommandBridgeCallbacks,
41
41
  } from "./pm-command-bridge";
42
+ import { createCollabPlugins } from "./pm-collab-plugins";
43
+ import { prosemirrorToYXmlFragment } from "y-prosemirror";
42
44
  import { buildDecorations } from "./pm-decorations";
43
45
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
44
46
  import {
@@ -68,6 +70,8 @@ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
68
70
  */
69
71
  export interface TwProseMirrorSurfaceProps {
70
72
  currentUser: EditorUser;
73
+ ydoc?: import("yjs").Doc;
74
+ awareness?: import("y-protocols/awareness").Awareness;
71
75
  snapshot: RuntimeRenderSnapshot;
72
76
  canonicalDocument: CanonicalDocumentEnvelope;
73
77
  documentNavigation: DocumentNavigationSnapshot;
@@ -236,32 +240,46 @@ export const TwProseMirrorSurface = forwardRef<
236
240
  ],
237
241
  );
238
242
 
243
+ const isCollabMode = Boolean(props.ydoc);
244
+
239
245
  // Create PM plugins (stable across renders — callbacks accessed via ref)
240
246
  const plugins = useMemo(() => {
247
+ const selectionCallbacks = {
248
+ onSelectionChange: (sel: SelectionSnapshot) => callbacksRef.current?.onSelectionChange(sel),
249
+ getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
250
+ isSelectionSyncSuppressed: () =>
251
+ callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
252
+ };
253
+
254
+ const corePlugins = props.ydoc
255
+ ? createCollabPlugins({
256
+ ydoc: props.ydoc,
257
+ awareness: props.awareness,
258
+ selectionCallbacks,
259
+ })
260
+ : createCommandBridgePlugins({
261
+ ...selectionCallbacks,
262
+ onInsertText: (text) => callbacksRef.current?.onInsertText(text),
263
+ onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
264
+ onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
265
+ onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
266
+ onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
267
+ onInsertTab: () => callbacksRef.current?.onInsertTab(),
268
+ onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
269
+ onUndo: () => callbacksRef.current?.onUndo(),
270
+ onRedo: () => callbacksRef.current?.onRedo(),
271
+ onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
272
+ });
273
+
241
274
  return [
242
- ...createCommandBridgePlugins({
243
- onInsertText: (text) => callbacksRef.current?.onInsertText(text),
244
- onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
245
- onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
246
- onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
247
- onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
248
- onInsertTab: () => callbacksRef.current?.onInsertTab(),
249
- onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
250
- onUndo: () => callbacksRef.current?.onUndo(),
251
- onRedo: () => callbacksRef.current?.onRedo(),
252
- onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
253
- onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
254
- getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
255
- isSelectionSyncSuppressed: () =>
256
- callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
257
- }),
275
+ ...corePlugins,
258
276
  createContextualInteractionPlugin({
259
277
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
260
278
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
261
279
  }),
262
280
  createSearchPlugin(),
263
281
  ];
264
- }, [props.onCommentActivated, props.onRevisionActivated]);
282
+ }, [props.onCommentActivated, props.onRevisionActivated, props.ydoc, props.awareness]);
265
283
 
266
284
  const applyDecorationProps = useCallback(
267
285
  (view: EditorView, positionMap: PositionMap): void => {
@@ -303,10 +321,14 @@ export const TwProseMirrorSurface = forwardRef<
303
321
  ],
304
322
  );
305
323
 
306
- // Create or update the PM document only when the structural key changes.
307
324
  useEffect(() => {
308
325
  if (!mountRef.current || !surface) return;
309
326
 
327
+ // Collab mode: y-prosemirror owns the doc after initial mount
328
+ if (isCollabMode && viewRef.current) {
329
+ return;
330
+ }
331
+
310
332
  if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
311
333
  return;
312
334
  }
@@ -336,7 +358,6 @@ export const TwProseMirrorSurface = forwardRef<
336
358
  incrementInvalidationCounter("pm.laneA.rebuilds");
337
359
 
338
360
  if (!viewRef.current) {
339
- // First time surface is available — create the EditorView
340
361
  const view = new EditorView(mountRef.current, {
341
362
  state,
342
363
  nodeViews: tableNodeViews,
@@ -349,6 +370,15 @@ export const TwProseMirrorSurface = forwardRef<
349
370
  });
350
371
  viewRef.current = view;
351
372
  recordPerfSample("pm.mount");
373
+
374
+ if (isCollabMode && props.ydoc) {
375
+ const yXmlFragment = props.ydoc.getXmlFragment("prosemirror");
376
+ if (yXmlFragment.length === 0) {
377
+ props.ydoc.transact(() => {
378
+ prosemirrorToYXmlFragment(view.state.doc, yXmlFragment);
379
+ });
380
+ }
381
+ }
352
382
  } else {
353
383
  suppressSelectionEchoRef.current = true;
354
384
  viewRef.current.updateState(state);
@@ -372,10 +402,12 @@ export const TwProseMirrorSurface = forwardRef<
372
402
  }, [
373
403
  applyDecorationProps,
374
404
  documentBuildKey,
405
+ isCollabMode,
375
406
  surface,
376
407
  snapshot.selection,
377
408
  plugins,
378
409
  props.mediaPreviews,
410
+ props.ydoc,
379
411
  ]);
380
412
 
381
413
  // Update decorations and editability without rebuilding the PM document.
@@ -408,6 +440,7 @@ export const TwProseMirrorSurface = forwardRef<
408
440
  ]);
409
441
 
410
442
  useEffect(() => {
443
+ if (isCollabMode) return;
411
444
  const view = viewRef.current;
412
445
  const positionMap = positionMapRef.current;
413
446
  if (!view || !surface || !positionMap) {
@@ -429,7 +462,7 @@ export const TwProseMirrorSurface = forwardRef<
429
462
  queueMicrotask(() => {
430
463
  suppressSelectionEchoRef.current = false;
431
464
  });
432
- }, [snapshot.selection, surface]);
465
+ }, [isCollabMode, snapshot.selection, surface]);
433
466
 
434
467
  useEffect(() => {
435
468
  if (!pendingSelectionProbeRef.current) {