@beyondwork/docx-react-component 1.0.32 → 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.32",
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({
@@ -261,6 +261,7 @@ export function buildDecorations(
261
261
  revisionModel: RevisionDecorationModel | undefined,
262
262
  markupDisplay: MarkupDisplay,
263
263
  showTrackedChanges = true,
264
+ suggestionsEnabled = false,
264
265
  workflowScopes?: readonly WorkflowScope[],
265
266
  activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
266
267
  workflowCandidates?: readonly WorkflowCandidateRange[],
@@ -324,6 +325,47 @@ export function buildDecorations(
324
325
  // Skip visual styling when tracked changes display is off
325
326
  if (!showTrackedChanges) continue;
326
327
 
328
+ const pmFrom = positionMap.runtimeToPm(rev.from);
329
+ const pmTo = positionMap.runtimeToPm(rev.to);
330
+ if (pmFrom >= pmTo) continue;
331
+
332
+ if (suggestionsEnabled) {
333
+ if (rev.kind === "insertion") {
334
+ decorations.push(
335
+ Decoration.inline(pmFrom, pmTo, {
336
+ class: "text-insert",
337
+ "data-revision-id": rev.revisionId,
338
+ }),
339
+ );
340
+ decorations.push(
341
+ Decoration.widget(pmFrom, () => {
342
+ const el = document.createElement("span");
343
+ el.textContent = "[";
344
+ el.className = "text-insert";
345
+ el.setAttribute("contenteditable", "false");
346
+ return el;
347
+ }, { side: -1, key: `${rev.revisionId}-open` }),
348
+ );
349
+ decorations.push(
350
+ Decoration.widget(pmTo, () => {
351
+ const el = document.createElement("span");
352
+ el.textContent = "]";
353
+ el.className = "text-insert";
354
+ el.setAttribute("contenteditable", "false");
355
+ return el;
356
+ }, { side: 1, key: `${rev.revisionId}-close` }),
357
+ );
358
+ } else if (rev.kind === "deletion") {
359
+ decorations.push(
360
+ Decoration.inline(pmFrom, pmTo, {
361
+ class: "text-danger line-through decoration-danger/80 decoration-1",
362
+ "data-revision-id": rev.revisionId,
363
+ }),
364
+ );
365
+ }
366
+ continue;
367
+ }
368
+
327
369
  const cls = getRevisionHighlightClass(
328
370
  revisionModel,
329
371
  rev.from,
@@ -332,16 +374,12 @@ export function buildDecorations(
332
374
  );
333
375
  if (!cls) continue;
334
376
 
335
- const pmFrom = positionMap.runtimeToPm(rev.from);
336
- const pmTo = positionMap.runtimeToPm(rev.to);
337
- if (pmFrom < pmTo) {
338
- decorations.push(
339
- Decoration.inline(pmFrom, pmTo, {
340
- class: cls,
341
- "data-revision-id": rev.revisionId,
342
- }),
343
- );
344
- }
377
+ decorations.push(
378
+ Decoration.inline(pmFrom, pmTo, {
379
+ class: cls,
380
+ "data-revision-id": rev.revisionId,
381
+ }),
382
+ );
345
383
  }
346
384
  }
347
385
 
@@ -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;
@@ -80,6 +84,7 @@ export interface TwProseMirrorSurfaceProps {
80
84
  activeRevisionId?: string;
81
85
  activeSelectionToolKind?: ActiveSelectionToolModel["kind"] | null;
82
86
  showTrackedChanges?: boolean;
87
+ suggestionsEnabled?: boolean;
83
88
  /** When true, the surface renders inside the page workspace (vs canvas). */
84
89
  isPageWorkspace?: boolean;
85
90
  onFocus: FocusEventHandler<HTMLDivElement>;
@@ -196,6 +201,7 @@ export const TwProseMirrorSurface = forwardRef<
196
201
  [snapshot.comments],
197
202
  );
198
203
  const showTrackedChanges = props.showTrackedChanges !== false;
204
+ const suggestionsEnabled = props.suggestionsEnabled ?? false;
199
205
  // Always create the revision model — needed for deletion hiding in clean mode
200
206
  // even when the tracked changes display toggle is off.
201
207
  const revisionModel = useMemo(
@@ -244,32 +250,46 @@ export const TwProseMirrorSurface = forwardRef<
244
250
  ],
245
251
  );
246
252
 
253
+ const isCollabMode = Boolean(props.ydoc);
254
+
247
255
  // Create PM plugins (stable across renders — callbacks accessed via ref)
248
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
+
249
284
  return [
250
- ...createCommandBridgePlugins({
251
- onInsertText: (text) => callbacksRef.current?.onInsertText(text),
252
- onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
253
- onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
254
- onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
255
- onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
256
- onInsertTab: () => callbacksRef.current?.onInsertTab(),
257
- onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
258
- onUndo: () => callbacksRef.current?.onUndo(),
259
- onRedo: () => callbacksRef.current?.onRedo(),
260
- onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
261
- onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
262
- getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
263
- isSelectionSyncSuppressed: () =>
264
- callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
265
- }),
285
+ ...corePlugins,
266
286
  createContextualInteractionPlugin({
267
287
  onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
268
288
  onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
269
289
  }),
270
290
  createSearchPlugin(),
271
291
  ];
272
- }, [props.onCommentActivated, props.onRevisionActivated]);
292
+ }, [props.onCommentActivated, props.onRevisionActivated, props.ydoc, props.awareness]);
273
293
 
274
294
  const applyDecorationProps = useCallback(
275
295
  (view: EditorView, positionMap: PositionMap): void => {
@@ -280,6 +300,7 @@ export const TwProseMirrorSurface = forwardRef<
280
300
  revisionModel,
281
301
  markupDisplay,
282
302
  showTrackedChanges,
303
+ suggestionsEnabled,
283
304
  props.workflowScopes,
284
305
  snapshot.activeStory,
285
306
  props.workflowCandidates,
@@ -311,13 +332,18 @@ export const TwProseMirrorSurface = forwardRef<
311
332
  props.workflowScopes,
312
333
  revisionModel,
313
334
  showTrackedChanges,
335
+ suggestionsEnabled,
314
336
  ],
315
337
  );
316
338
 
317
- // Create or update the PM document only when the structural key changes.
318
339
  useEffect(() => {
319
340
  if (!mountRef.current || !surface) return;
320
341
 
342
+ // Collab mode: y-prosemirror owns the doc after initial mount
343
+ if (isCollabMode && viewRef.current) {
344
+ return;
345
+ }
346
+
321
347
  if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
322
348
  return;
323
349
  }
@@ -337,6 +363,7 @@ export const TwProseMirrorSurface = forwardRef<
337
363
  revisionModel,
338
364
  markupDisplay,
339
365
  showTrackedChanges,
366
+ suggestionsEnabled,
340
367
  props.workflowScopes,
341
368
  snapshot.activeStory,
342
369
  props.workflowCandidates,
@@ -350,19 +377,23 @@ export const TwProseMirrorSurface = forwardRef<
350
377
  incrementInvalidationCounter("pm.laneA.rebuilds");
351
378
 
352
379
  if (!viewRef.current) {
353
- // First time surface is available — create the EditorView
354
380
  const view = new EditorView(mountRef.current, {
355
381
  state,
356
382
  nodeViews: tableNodeViews,
357
383
  editable: () => canEdit,
358
384
  decorations: () => decorations,
359
- dispatchTransaction(tr) {
360
- const newState = view.state.apply(tr);
361
- view.updateState(newState);
362
- },
363
385
  });
364
386
  viewRef.current = view;
365
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
+ }
366
397
  } else {
367
398
  suppressSelectionEchoRef.current = true;
368
399
  viewRef.current.updateState(state);
@@ -386,10 +417,12 @@ export const TwProseMirrorSurface = forwardRef<
386
417
  }, [
387
418
  applyDecorationProps,
388
419
  documentBuildKey,
420
+ isCollabMode,
389
421
  surface,
390
422
  snapshot.selection,
391
423
  plugins,
392
424
  props.mediaPreviews,
425
+ props.ydoc,
393
426
  ]);
394
427
 
395
428
  // Update decorations and editability without rebuilding the PM document.
@@ -422,6 +455,7 @@ export const TwProseMirrorSurface = forwardRef<
422
455
  ]);
423
456
 
424
457
  useEffect(() => {
458
+ if (isCollabMode) return;
425
459
  const view = viewRef.current;
426
460
  const positionMap = positionMapRef.current;
427
461
  if (!view || !surface || !positionMap) {
@@ -443,7 +477,7 @@ export const TwProseMirrorSurface = forwardRef<
443
477
  queueMicrotask(() => {
444
478
  suppressSelectionEchoRef.current = false;
445
479
  });
446
- }, [snapshot.selection, surface]);
480
+ }, [isCollabMode, snapshot.selection, surface]);
447
481
 
448
482
  useEffect(() => {
449
483
  if (!pendingSelectionProbeRef.current) {