@beyondwork/docx-react-component 1.0.27 → 1.0.28
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 +4 -14
- package/src/api/public-types.ts +8 -5
- package/src/ui/WordReviewEditor.tsx +0 -11
- package/src/ui/editor-surface-controller.tsx +0 -2
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +33 -27
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +24 -53
- package/src/runtime/collab-review-sync.ts +0 -254
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +0 -40
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beyondwork/docx-react-component",
|
|
3
3
|
"publisher": "beyondwork",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.28",
|
|
5
5
|
"description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
|
|
6
6
|
"packageManager": "pnpm@10.30.3",
|
|
7
7
|
"type": "module",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"test:repo": "node scripts/run-repo-tests.mjs core",
|
|
96
96
|
"test:repo:all": "node scripts/run-repo-tests.mjs all",
|
|
97
97
|
"test:repo:optional": "node scripts/run-repo-tests.mjs optional",
|
|
98
|
+
"test:repo:browser-ui": "node scripts/run-repo-tests.mjs browser-ui",
|
|
98
99
|
"test:wcag-audit": "node scripts/run-repo-tests.mjs wcag-audit",
|
|
99
100
|
"test:harness": "pnpm --filter @docx-react-component/react-word-editor-harness test",
|
|
100
101
|
"lint": "pnpm run lint:no-authored-js && pnpm run lint:docs-contracts && pnpm run lint:tsgo && pnpm run lint:tsgo:harness",
|
|
@@ -155,15 +156,7 @@
|
|
|
155
156
|
"prosemirror-view": "^1.41.7",
|
|
156
157
|
"react": "^19.2.0",
|
|
157
158
|
"react-dom": "^19.2.0",
|
|
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 }
|
|
159
|
+
"tailwindcss": "^4.2.2"
|
|
167
160
|
},
|
|
168
161
|
"devDependencies": {
|
|
169
162
|
"@chllming/wave-orchestration": "^0.9.15",
|
|
@@ -181,10 +174,7 @@
|
|
|
181
174
|
"react": "19.2.4",
|
|
182
175
|
"react-dom": "19.2.4",
|
|
183
176
|
"tsup": "^8.3.0",
|
|
184
|
-
"tsx": "^4.21.0"
|
|
185
|
-
"y-prosemirror": "^1.3.7",
|
|
186
|
-
"y-protocols": "^1.0.7",
|
|
187
|
-
"yjs": "^13.6.30"
|
|
177
|
+
"tsx": "^4.21.0"
|
|
188
178
|
},
|
|
189
179
|
"pnpm": {
|
|
190
180
|
"onlyBuiltDependencies": [
|
package/src/api/public-types.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
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";
|
|
2
7
|
|
|
3
|
-
export type FieldFamily =
|
|
4
|
-
export type FieldRefreshStatus =
|
|
5
|
-
export type SupportedFieldFamily =
|
|
8
|
+
export type FieldFamily = FieldFamilyType;
|
|
9
|
+
export type FieldRefreshStatus = FieldRefreshStatusType;
|
|
10
|
+
export type SupportedFieldFamily = SupportedFieldFamilyType;
|
|
6
11
|
|
|
7
12
|
export type ExternalDocumentSource =
|
|
8
13
|
| {
|
|
@@ -1383,8 +1388,6 @@ export interface WordReviewEditorRef {
|
|
|
1383
1388
|
export interface WordReviewEditorProps {
|
|
1384
1389
|
documentId: string;
|
|
1385
1390
|
currentUser: EditorUser;
|
|
1386
|
-
ydoc?: import('yjs').Doc;
|
|
1387
|
-
awareness?: import("y-protocols/awareness").Awareness;
|
|
1388
1391
|
initialDocx?: Uint8Array | ArrayBuffer;
|
|
1389
1392
|
initialSessionState?: EditorSessionState;
|
|
1390
1393
|
initialSnapshot?: PersistedEditorSnapshot;
|
|
@@ -169,7 +169,6 @@ 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";
|
|
173
172
|
|
|
174
173
|
export {
|
|
175
174
|
__createFallbackRuntime,
|
|
@@ -504,8 +503,6 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
504
503
|
function WordReviewEditor(props, ref) {
|
|
505
504
|
const {
|
|
506
505
|
currentUser,
|
|
507
|
-
ydoc,
|
|
508
|
-
awareness,
|
|
509
506
|
hostAdapter,
|
|
510
507
|
datastore,
|
|
511
508
|
documentId,
|
|
@@ -701,12 +698,6 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
701
698
|
activeRuntime.setDocumentMode(suggestionsEnabled ? "suggesting" : "editing");
|
|
702
699
|
}, [activeRuntime, suggestionsEnabled]);
|
|
703
700
|
|
|
704
|
-
useEffect(() => {
|
|
705
|
-
if (!ydoc || !runtime) return;
|
|
706
|
-
const handle = createCollabReviewSync(ydoc, runtime);
|
|
707
|
-
return () => handle.destroy();
|
|
708
|
-
}, [ydoc, runtime]);
|
|
709
|
-
|
|
710
701
|
useEffect(() => {
|
|
711
702
|
runtimeViewStateSeedRef.current = {
|
|
712
703
|
workspaceMode: viewState.workspaceMode,
|
|
@@ -1714,8 +1705,6 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1714
1705
|
<EditorSurfaceController
|
|
1715
1706
|
ref={surfaceRef}
|
|
1716
1707
|
currentUser={currentUser}
|
|
1717
|
-
ydoc={ydoc}
|
|
1718
|
-
awareness={awareness}
|
|
1719
1708
|
snapshot={snapshot}
|
|
1720
1709
|
canonicalDocument={canonicalDocument}
|
|
1721
1710
|
documentNavigation={documentNavigation}
|
|
@@ -20,8 +20,6 @@ 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;
|
|
25
23
|
snapshot: RuntimeRenderSnapshot;
|
|
26
24
|
canonicalDocument: CanonicalDocumentEnvelope;
|
|
27
25
|
documentNavigation: DocumentNavigationSnapshot;
|
|
@@ -9,13 +9,7 @@ import {
|
|
|
9
9
|
} from "../../ui/headless/selection-helpers";
|
|
10
10
|
import type { PositionMap } from "./pm-position-map";
|
|
11
11
|
|
|
12
|
-
export interface
|
|
13
|
-
onSelectionChange: (selection: SelectionSnapshot) => void;
|
|
14
|
-
getPositionMap: () => PositionMap | null;
|
|
15
|
-
isSelectionSyncSuppressed?: () => boolean;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
|
|
12
|
+
export interface CommandBridgeCallbacks {
|
|
19
13
|
onInsertText: (text: string) => void;
|
|
20
14
|
onDeleteBackward: () => void;
|
|
21
15
|
onDeleteForward: () => void;
|
|
@@ -26,20 +20,48 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
|
|
|
26
20
|
onUndo: () => void;
|
|
27
21
|
onRedo: () => void;
|
|
28
22
|
onBlockedInput?: (command: "paste" | "drop", message: string) => void;
|
|
23
|
+
onSelectionChange: (selection: SelectionSnapshot) => void;
|
|
24
|
+
getPositionMap: () => PositionMap | null;
|
|
25
|
+
isSelectionSyncSuppressed?: () => boolean;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
28
|
const bridgeKey = new PluginKey("command-bridge");
|
|
32
29
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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({
|
|
37
56
|
view() {
|
|
38
57
|
return {
|
|
39
58
|
update(view, prevState) {
|
|
40
59
|
if (callbacks.isSelectionSyncSuppressed?.()) {
|
|
41
60
|
return;
|
|
42
61
|
}
|
|
62
|
+
if (isComposing) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
43
65
|
if (!view.state.selection.eq(prevState.selection)) {
|
|
44
66
|
const posMap = callbacks.getPositionMap();
|
|
45
67
|
if (!posMap) return;
|
|
@@ -60,22 +82,6 @@ export function createSelectionSyncPlugin(
|
|
|
60
82
|
};
|
|
61
83
|
},
|
|
62
84
|
});
|
|
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);
|
|
79
85
|
|
|
80
86
|
// Text input hook: intercept typed characters.
|
|
81
87
|
const inputPlugin = new Plugin({
|
|
@@ -39,8 +39,6 @@ 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";
|
|
44
42
|
import { buildDecorations } from "./pm-decorations";
|
|
45
43
|
import { createContextualInteractionPlugin } from "./pm-contextual-ui";
|
|
46
44
|
import {
|
|
@@ -70,8 +68,6 @@ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
|
|
|
70
68
|
*/
|
|
71
69
|
export interface TwProseMirrorSurfaceProps {
|
|
72
70
|
currentUser: EditorUser;
|
|
73
|
-
ydoc?: import("yjs").Doc;
|
|
74
|
-
awareness?: import("y-protocols/awareness").Awareness;
|
|
75
71
|
snapshot: RuntimeRenderSnapshot;
|
|
76
72
|
canonicalDocument: CanonicalDocumentEnvelope;
|
|
77
73
|
documentNavigation: DocumentNavigationSnapshot;
|
|
@@ -240,46 +236,32 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
240
236
|
],
|
|
241
237
|
);
|
|
242
238
|
|
|
243
|
-
const isCollabMode = Boolean(props.ydoc);
|
|
244
|
-
|
|
245
239
|
// Create PM plugins (stable across renders — callbacks accessed via ref)
|
|
246
240
|
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
|
-
|
|
274
241
|
return [
|
|
275
|
-
...
|
|
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
|
+
}),
|
|
276
258
|
createContextualInteractionPlugin({
|
|
277
259
|
onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
|
|
278
260
|
onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
|
|
279
261
|
}),
|
|
280
262
|
createSearchPlugin(),
|
|
281
263
|
];
|
|
282
|
-
}, [props.onCommentActivated, props.onRevisionActivated
|
|
264
|
+
}, [props.onCommentActivated, props.onRevisionActivated]);
|
|
283
265
|
|
|
284
266
|
const applyDecorationProps = useCallback(
|
|
285
267
|
(view: EditorView, positionMap: PositionMap): void => {
|
|
@@ -321,14 +303,10 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
321
303
|
],
|
|
322
304
|
);
|
|
323
305
|
|
|
306
|
+
// Create or update the PM document only when the structural key changes.
|
|
324
307
|
useEffect(() => {
|
|
325
308
|
if (!mountRef.current || !surface) return;
|
|
326
309
|
|
|
327
|
-
// Collab mode: y-prosemirror owns the doc after initial mount
|
|
328
|
-
if (isCollabMode && viewRef.current) {
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
310
|
if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
|
|
333
311
|
return;
|
|
334
312
|
}
|
|
@@ -358,23 +336,19 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
358
336
|
incrementInvalidationCounter("pm.laneA.rebuilds");
|
|
359
337
|
|
|
360
338
|
if (!viewRef.current) {
|
|
339
|
+
// First time surface is available — create the EditorView
|
|
361
340
|
const view = new EditorView(mountRef.current, {
|
|
362
341
|
state,
|
|
363
342
|
nodeViews: tableNodeViews,
|
|
364
343
|
editable: () => canEdit,
|
|
365
344
|
decorations: () => decorations,
|
|
345
|
+
dispatchTransaction(tr) {
|
|
346
|
+
const newState = view.state.apply(tr);
|
|
347
|
+
view.updateState(newState);
|
|
348
|
+
},
|
|
366
349
|
});
|
|
367
350
|
viewRef.current = view;
|
|
368
351
|
recordPerfSample("pm.mount");
|
|
369
|
-
|
|
370
|
-
if (isCollabMode && props.ydoc) {
|
|
371
|
-
const yXmlFragment = props.ydoc.getXmlFragment("prosemirror");
|
|
372
|
-
if (yXmlFragment.length === 0) {
|
|
373
|
-
props.ydoc.transact(() => {
|
|
374
|
-
prosemirrorToYXmlFragment(view.state.doc, yXmlFragment);
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
352
|
} else {
|
|
379
353
|
suppressSelectionEchoRef.current = true;
|
|
380
354
|
viewRef.current.updateState(state);
|
|
@@ -398,12 +372,10 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
398
372
|
}, [
|
|
399
373
|
applyDecorationProps,
|
|
400
374
|
documentBuildKey,
|
|
401
|
-
isCollabMode,
|
|
402
375
|
surface,
|
|
403
376
|
snapshot.selection,
|
|
404
377
|
plugins,
|
|
405
378
|
props.mediaPreviews,
|
|
406
|
-
props.ydoc,
|
|
407
379
|
]);
|
|
408
380
|
|
|
409
381
|
// Update decorations and editability without rebuilding the PM document.
|
|
@@ -436,7 +408,6 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
436
408
|
]);
|
|
437
409
|
|
|
438
410
|
useEffect(() => {
|
|
439
|
-
if (isCollabMode) return;
|
|
440
411
|
const view = viewRef.current;
|
|
441
412
|
const positionMap = positionMapRef.current;
|
|
442
413
|
if (!view || !surface || !positionMap) {
|
|
@@ -458,7 +429,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
458
429
|
queueMicrotask(() => {
|
|
459
430
|
suppressSelectionEchoRef.current = false;
|
|
460
431
|
});
|
|
461
|
-
}, [
|
|
432
|
+
}, [snapshot.selection, surface]);
|
|
462
433
|
|
|
463
434
|
useEffect(() => {
|
|
464
435
|
if (!pendingSelectionProbeRef.current) {
|
|
@@ -1,254 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
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
|
-
}
|