@beyondwork/docx-react-component 1.0.33 → 1.0.34

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.33",
4
+ "version": "1.0.34",
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,31 @@
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:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
99
+ "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
100
+ "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
101
+ "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
102
+ "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
103
+ "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
104
+ "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
105
+ "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
106
+ "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
107
+ "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
108
+ "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
109
+ "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
110
+ "wave:launch:managed": "bash scripts/wave-launch.sh",
111
+ "wave:status": "bash scripts/wave-status.sh",
112
+ "wave:watch": "bash scripts/wave-watch.sh --follow",
113
+ "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
114
+ "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
115
+ "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
116
+ },
91
117
  "keywords": [
92
118
  "docx",
93
119
  "word",
@@ -130,7 +156,15 @@
130
156
  "prosemirror-view": "^1.41.7",
131
157
  "react": "^19.2.0",
132
158
  "react-dom": "^19.2.0",
133
- "tailwindcss": "^4.2.2"
159
+ "tailwindcss": "^4.2.2",
160
+ "yjs": "^13.6.0",
161
+ "y-prosemirror": "^1.2.0",
162
+ "y-protocols": "^1.0.0"
163
+ },
164
+ "peerDependenciesMeta": {
165
+ "yjs": { "optional": true },
166
+ "y-prosemirror": { "optional": true },
167
+ "y-protocols": { "optional": true }
134
168
  },
135
169
  "devDependencies": {
136
170
  "@chllming/wave-orchestration": "^0.9.15",
@@ -148,31 +182,19 @@
148
182
  "react": "19.2.4",
149
183
  "react-dom": "19.2.4",
150
184
  "tsup": "^8.3.0",
151
- "tsx": "^4.21.0"
185
+ "tsx": "^4.21.0",
186
+ "y-prosemirror": "^1.3.7",
187
+ "y-protocols": "^1.0.7",
188
+ "yjs": "^13.6.30"
152
189
  },
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:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
160
- "test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
161
- "test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
162
- "lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
163
- "lint:docs-contracts": "bash scripts/check-reference-load-contract.sh",
164
- "lint:no-authored-js": "bash scripts/check-no-authored-js.sh",
165
- "lint:tsgo": "tsgo --noEmit -p tsconfig.build.json",
166
- "lint:tsgo:harness": "pnpm --filter @docx-react-component/react-word-editor-harness lint:tsgo",
167
- "context7:api-check": "bash scripts/context7-export-env.sh run bash scripts/context7-api-check.sh",
168
- "wave:doctor": "bash scripts/context7-export-env.sh run pnpm exec wave doctor --json",
169
- "wave:dry-run": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main --dry-run --no-dashboard",
170
- "wave:launch": "bash scripts/context7-export-env.sh run pnpm exec wave launch --lane main",
171
- "wave:launch:managed": "bash scripts/wave-launch.sh",
172
- "wave:status": "bash scripts/wave-status.sh",
173
- "wave:watch": "bash scripts/wave-watch.sh --follow",
174
- "wave:dashboard:current": "bash scripts/wave-dashboard-attach.sh current",
175
- "wave:dashboard:global": "bash scripts/wave-dashboard-attach.sh global",
176
- "harness:dev": "pnpm --filter @docx-react-component/react-word-editor-harness dev"
190
+ "pnpm": {
191
+ "onlyBuiltDependencies": [
192
+ "esbuild",
193
+ "sharp"
194
+ ],
195
+ "overrides": {
196
+ "react": "19.2.4",
197
+ "react-dom": "19.2.4"
198
+ }
177
199
  }
178
- }
200
+ }
@@ -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
  | {
@@ -1930,6 +1925,8 @@ export interface WordReviewEditorChromeOptions {
1930
1925
  export interface WordReviewEditorProps {
1931
1926
  documentId: string;
1932
1927
  currentUser: EditorUser;
1928
+ ydoc?: import('yjs').Doc;
1929
+ awareness?: import("y-protocols/awareness").Awareness;
1933
1930
  initialDocx?: Uint8Array | ArrayBuffer;
1934
1931
  initialSessionState?: EditorSessionState;
1935
1932
  initialSnapshot?: PersistedEditorSnapshot;
@@ -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
+ }
@@ -197,6 +197,7 @@ import {
197
197
  resolveChromePreset,
198
198
  resolveChromeVisibilityForPreset,
199
199
  } from "../ui-tailwind/chrome/chrome-preset-model.ts";
200
+ import { createCollabReviewSync } from "../runtime/collab-review-sync.ts";
200
201
 
201
202
  export {
202
203
  __createFallbackRuntime,
@@ -615,6 +616,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
615
616
  function WordReviewEditor(props, ref) {
616
617
  const {
617
618
  currentUser,
619
+ ydoc,
620
+ awareness,
618
621
  hostAdapter,
619
622
  datastore,
620
623
  documentId,
@@ -982,6 +985,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
982
985
  [activeReviewQueueItemId, activeRuntime, reviewQueueSnapshot],
983
986
  );
984
987
 
988
+ useEffect(() => {
989
+ if (!ydoc || !runtime) return;
990
+ const handle = createCollabReviewSync(ydoc, runtime);
991
+ return () => handle.destroy();
992
+ }, [ydoc, runtime]);
993
+
985
994
  useEffect(() => {
986
995
  runtimeViewStateSeedRef.current = {
987
996
  workspaceMode: viewState.workspaceMode,
@@ -2209,6 +2218,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
2209
2218
  <EditorSurfaceController
2210
2219
  ref={surfaceRef}
2211
2220
  currentUser={currentUser}
2221
+ ydoc={ydoc}
2222
+ awareness={awareness}
2212
2223
  snapshot={snapshot}
2213
2224
  canonicalDocument={canonicalDocument}
2214
2225
  documentNavigation={documentNavigation}
@@ -23,6 +23,8 @@ import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-st
23
23
 
24
24
  export interface EditorSurfaceControllerProps {
25
25
  currentUser: EditorUser;
26
+ ydoc?: import('yjs').Doc;
27
+ awareness?: import("y-protocols/awareness").Awareness;
26
28
  snapshot: RuntimeRenderSnapshot;
27
29
  canonicalDocument: CanonicalDocumentEnvelope;
28
30
  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
  import { resolveSurfaceShortcut } from "../../ui/runtime-shortcut-dispatch";
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;
@@ -84,6 +62,22 @@ export function createCommandBridgePlugins(
84
62
  };
85
63
  },
86
64
  });
65
+ }
66
+
67
+ export function createCommandBridgePlugins(
68
+ callbacks: CommandBridgeCallbacks,
69
+ ): Plugin[] {
70
+ let isComposing = false;
71
+
72
+ const filterPlugin = new Plugin({
73
+ key: bridgeKey,
74
+ filterTransaction(tr) {
75
+ if (!tr.docChanged) return true;
76
+ return false;
77
+ },
78
+ });
79
+
80
+ const selectionPlugin = createSelectionSyncPlugin(callbacks);
87
81
 
88
82
  // Text input hook: intercept typed characters.
89
83
  const inputPlugin = new Plugin({
@@ -42,6 +42,8 @@ import {
42
42
  createCommandBridgePlugins,
43
43
  type CommandBridgeCallbacks,
44
44
  } from "./pm-command-bridge";
45
+ import { createCollabPlugins } from "./pm-collab-plugins";
46
+ import { prosemirrorToYXmlFragment } from "y-prosemirror";
45
47
  import { buildDecorations } from "./pm-decorations";
46
48
  import { createContextualInteractionPlugin } from "./pm-contextual-ui";
47
49
  import {
@@ -71,6 +73,8 @@ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
71
73
  */
72
74
  export interface TwProseMirrorSurfaceProps {
73
75
  currentUser: EditorUser;
76
+ ydoc?: import("yjs").Doc;
77
+ awareness?: import("y-protocols/awareness").Awareness;
74
78
  snapshot: RuntimeRenderSnapshot;
75
79
  canonicalDocument: CanonicalDocumentEnvelope;
76
80
  documentNavigation: DocumentNavigationSnapshot;
@@ -246,32 +250,46 @@ export const TwProseMirrorSurface = forwardRef<
246
250
  ],
247
251
  );
248
252
 
253
+ const isCollabMode = Boolean(props.ydoc);
254
+
249
255
  // Create PM plugins (stable across renders — callbacks accessed via ref)
250
256
  const plugins = useMemo(() => {
257
+ const selectionCallbacks = {
258
+ onSelectionChange: (sel: SelectionSnapshot) => callbacksRef.current?.onSelectionChange(sel),
259
+ getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
260
+ isSelectionSyncSuppressed: () =>
261
+ callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
262
+ };
263
+
264
+ const corePlugins = props.ydoc
265
+ ? createCollabPlugins({
266
+ ydoc: props.ydoc,
267
+ awareness: props.awareness,
268
+ selectionCallbacks,
269
+ })
270
+ : createCommandBridgePlugins({
271
+ ...selectionCallbacks,
272
+ onInsertText: (text) => callbacksRef.current?.onInsertText(text),
273
+ onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
274
+ onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
275
+ onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
276
+ onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
277
+ onInsertTab: () => callbacksRef.current?.onInsertTab(),
278
+ onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
279
+ onUndo: () => callbacksRef.current?.onUndo(),
280
+ onRedo: () => callbacksRef.current?.onRedo(),
281
+ onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
282
+ });
283
+
251
284
  return [
252
- ...createCommandBridgePlugins({
253
- onInsertText: (text) => callbacksRef.current?.onInsertText(text),
254
- onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
255
- onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
256
- onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
257
- onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
258
- onInsertTab: () => callbacksRef.current?.onInsertTab(),
259
- onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
260
- onUndo: () => callbacksRef.current?.onUndo(),
261
- onRedo: () => callbacksRef.current?.onRedo(),
262
- onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
263
- onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
264
- getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
265
- isSelectionSyncSuppressed: () =>
266
- callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
267
- }),
285
+ ...corePlugins,
268
286
  createContextualInteractionPlugin({
269
287
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
270
288
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
271
289
  }),
272
290
  createSearchPlugin(),
273
291
  ];
274
- }, [props.onCommentActivated, props.onRevisionActivated]);
292
+ }, [props.onCommentActivated, props.onRevisionActivated, props.ydoc, props.awareness]);
275
293
 
276
294
  const applyDecorationProps = useCallback(
277
295
  (view: EditorView, positionMap: PositionMap): void => {
@@ -318,10 +336,14 @@ export const TwProseMirrorSurface = forwardRef<
318
336
  ],
319
337
  );
320
338
 
321
- // Create or update the PM document only when the structural key changes.
322
339
  useEffect(() => {
323
340
  if (!mountRef.current || !surface) return;
324
341
 
342
+ // Collab mode: y-prosemirror owns the doc after initial mount
343
+ if (isCollabMode && viewRef.current) {
344
+ return;
345
+ }
346
+
325
347
  if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
326
348
  return;
327
349
  }
@@ -355,19 +377,23 @@ export const TwProseMirrorSurface = forwardRef<
355
377
  incrementInvalidationCounter("pm.laneA.rebuilds");
356
378
 
357
379
  if (!viewRef.current) {
358
- // First time surface is available — create the EditorView
359
380
  const view = new EditorView(mountRef.current, {
360
381
  state,
361
382
  nodeViews: tableNodeViews,
362
383
  editable: () => canEdit,
363
384
  decorations: () => decorations,
364
- dispatchTransaction(tr) {
365
- const newState = view.state.apply(tr);
366
- view.updateState(newState);
367
- },
368
385
  });
369
386
  viewRef.current = view;
370
387
  recordPerfSample("pm.mount");
388
+
389
+ if (isCollabMode && props.ydoc) {
390
+ const yXmlFragment = props.ydoc.getXmlFragment("prosemirror");
391
+ if (yXmlFragment.length === 0) {
392
+ props.ydoc.transact(() => {
393
+ prosemirrorToYXmlFragment(view.state.doc, yXmlFragment);
394
+ });
395
+ }
396
+ }
371
397
  } else {
372
398
  suppressSelectionEchoRef.current = true;
373
399
  viewRef.current.updateState(state);
@@ -391,10 +417,12 @@ export const TwProseMirrorSurface = forwardRef<
391
417
  }, [
392
418
  applyDecorationProps,
393
419
  documentBuildKey,
420
+ isCollabMode,
394
421
  surface,
395
422
  snapshot.selection,
396
423
  plugins,
397
424
  props.mediaPreviews,
425
+ props.ydoc,
398
426
  ]);
399
427
 
400
428
  // Update decorations and editability without rebuilding the PM document.
@@ -427,6 +455,7 @@ export const TwProseMirrorSurface = forwardRef<
427
455
  ]);
428
456
 
429
457
  useEffect(() => {
458
+ if (isCollabMode) return;
430
459
  const view = viewRef.current;
431
460
  const positionMap = positionMapRef.current;
432
461
  if (!view || !surface || !positionMap) {
@@ -448,7 +477,7 @@ export const TwProseMirrorSurface = forwardRef<
448
477
  queueMicrotask(() => {
449
478
  suppressSelectionEchoRef.current = false;
450
479
  });
451
- }, [snapshot.selection, surface]);
480
+ }, [isCollabMode, snapshot.selection, surface]);
452
481
 
453
482
  useEffect(() => {
454
483
  if (!pendingSelectionProbeRef.current) {