@beyondwork/docx-react-component 1.0.26-rc2 → 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 +49 -27
- package/src/api/public-types.ts +5 -8
- package/src/core/selection/review-anchors.ts +6 -1
- package/src/io/export/serialize-comments.ts +4 -10
- package/src/review/store/comment-remapping.ts +12 -2
- package/src/runtime/collab-review-sync.ts +254 -0
- package/src/runtime/document-runtime.ts +2 -2
- package/src/runtime/session-capabilities.ts +1 -1
- package/src/ui/WordReviewEditor.tsx +11 -0
- package/src/ui/editor-surface-controller.tsx +2 -0
- package/src/ui-tailwind/editor-surface/pm-collab-plugins.ts +40 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +27 -33
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +53 -20
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.
|
|
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
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
package/src/api/public-types.ts
CHANGED
|
@@ -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 =
|
|
9
|
-
export type FieldRefreshStatus =
|
|
10
|
-
export type SupportedFieldFamily =
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
270
|
+
if (!paragraph) {
|
|
277
271
|
skippedCommentIds.push(thread.commentId);
|
|
278
272
|
continue;
|
|
279
273
|
}
|
|
280
274
|
|
|
281
|
-
const startIndex =
|
|
282
|
-
const endIndex =
|
|
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);
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
getAnchorRange,
|
|
5
5
|
mapReviewAnchor,
|
|
6
6
|
mappingTouchesAnchorContent,
|
|
7
|
+
rangeStaysWithinSingleParagraph,
|
|
7
8
|
type ReviewAnchor,
|
|
8
9
|
} from "../../core/selection/review-anchors.ts";
|
|
9
10
|
import type { TransactionMapping } from "../../core/selection/mapping.ts";
|
|
@@ -27,7 +28,7 @@ export function remapCommentThreads(
|
|
|
27
28
|
const comments = Object.fromEntries(
|
|
28
29
|
Object.entries(options.comments).map(([commentId, comment]) => [
|
|
29
30
|
commentId,
|
|
30
|
-
remapCommentThread(comment, options.mapping),
|
|
31
|
+
remapCommentThread(comment, options.mapping, options.nextContent),
|
|
31
32
|
]),
|
|
32
33
|
);
|
|
33
34
|
const detachedCommentIds = Object.values(comments)
|
|
@@ -44,13 +45,14 @@ export function remapCommentThreads(
|
|
|
44
45
|
export function remapCommentThread(
|
|
45
46
|
comment: CommentThreadRecord,
|
|
46
47
|
mapping: TransactionMapping,
|
|
48
|
+
nextContent: unknown,
|
|
47
49
|
): CommentThreadRecord {
|
|
48
50
|
if (comment.anchor.kind === "detached") {
|
|
49
51
|
return comment;
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
const mappedAnchor = mapReviewAnchor(comment.anchor, mapping);
|
|
53
|
-
const anchor = normalizeCommentAnchor(comment.anchor, mappedAnchor, mapping);
|
|
55
|
+
const anchor = normalizeCommentAnchor(comment.anchor, mappedAnchor, mapping, nextContent);
|
|
54
56
|
|
|
55
57
|
return {
|
|
56
58
|
...comment,
|
|
@@ -62,6 +64,7 @@ function normalizeCommentAnchor(
|
|
|
62
64
|
previousAnchor: ReviewAnchor,
|
|
63
65
|
mappedAnchor: ReviewAnchor,
|
|
64
66
|
mapping: TransactionMapping,
|
|
67
|
+
nextContent: unknown,
|
|
65
68
|
): ReviewAnchor {
|
|
66
69
|
if (mappedAnchor.kind === "detached") {
|
|
67
70
|
return mappedAnchor;
|
|
@@ -80,6 +83,13 @@ function normalizeCommentAnchor(
|
|
|
80
83
|
return detachReviewAnchor(previousRange, detachReason(mapping));
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
if (
|
|
87
|
+
mappedAnchor.kind === "range" &&
|
|
88
|
+
!rangeStaysWithinSingleParagraph(nextContent, mappedAnchor.range)
|
|
89
|
+
) {
|
|
90
|
+
return detachReviewAnchor(previousRange, "invalidatedByStructureChange");
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
return mappedAnchor;
|
|
84
94
|
}
|
|
85
95
|
|
|
@@ -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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
...
|
|
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) {
|