@beyondwork/docx-react-component 1.0.33 → 1.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +50 -28
- package/src/api/public-types.ts +5 -8
- package/src/runtime/collab-review-sync.ts +254 -0
- 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 -24
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.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
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
+
}
|
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
|
| {
|
|
@@ -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
|
|
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;
|
|
@@ -84,6 +62,22 @@ export function createCommandBridgePlugins(
|
|
|
84
62
|
};
|
|
85
63
|
},
|
|
86
64
|
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createCommandBridgePlugins(
|
|
68
|
+
callbacks: CommandBridgeCallbacks,
|
|
69
|
+
): Plugin[] {
|
|
70
|
+
let isComposing = false;
|
|
71
|
+
|
|
72
|
+
const filterPlugin = new Plugin({
|
|
73
|
+
key: bridgeKey,
|
|
74
|
+
filterTransaction(tr) {
|
|
75
|
+
if (!tr.docChanged) return true;
|
|
76
|
+
return false;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const selectionPlugin = createSelectionSyncPlugin(callbacks);
|
|
87
81
|
|
|
88
82
|
// Text input hook: intercept typed characters.
|
|
89
83
|
const inputPlugin = new Plugin({
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
createCommandBridgePlugins,
|
|
43
43
|
type CommandBridgeCallbacks,
|
|
44
44
|
} from "./pm-command-bridge";
|
|
45
|
+
import { createCollabPlugins } from "./pm-collab-plugins";
|
|
46
|
+
import { prosemirrorToYXmlFragment } from "y-prosemirror";
|
|
45
47
|
import { buildDecorations } from "./pm-decorations";
|
|
46
48
|
import { createContextualInteractionPlugin } from "./pm-contextual-ui";
|
|
47
49
|
import {
|
|
@@ -71,6 +73,8 @@ import type { MediaPreviewDescriptor } from "./pm-state-from-snapshot";
|
|
|
71
73
|
*/
|
|
72
74
|
export interface TwProseMirrorSurfaceProps {
|
|
73
75
|
currentUser: EditorUser;
|
|
76
|
+
ydoc?: import("yjs").Doc;
|
|
77
|
+
awareness?: import("y-protocols/awareness").Awareness;
|
|
74
78
|
snapshot: RuntimeRenderSnapshot;
|
|
75
79
|
canonicalDocument: CanonicalDocumentEnvelope;
|
|
76
80
|
documentNavigation: DocumentNavigationSnapshot;
|
|
@@ -246,32 +250,46 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
246
250
|
],
|
|
247
251
|
);
|
|
248
252
|
|
|
253
|
+
const isCollabMode = Boolean(props.ydoc);
|
|
254
|
+
|
|
249
255
|
// Create PM plugins (stable across renders — callbacks accessed via ref)
|
|
250
256
|
const plugins = useMemo(() => {
|
|
257
|
+
const selectionCallbacks = {
|
|
258
|
+
onSelectionChange: (sel: SelectionSnapshot) => callbacksRef.current?.onSelectionChange(sel),
|
|
259
|
+
getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
|
|
260
|
+
isSelectionSyncSuppressed: () =>
|
|
261
|
+
callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const corePlugins = props.ydoc
|
|
265
|
+
? createCollabPlugins({
|
|
266
|
+
ydoc: props.ydoc,
|
|
267
|
+
awareness: props.awareness,
|
|
268
|
+
selectionCallbacks,
|
|
269
|
+
})
|
|
270
|
+
: createCommandBridgePlugins({
|
|
271
|
+
...selectionCallbacks,
|
|
272
|
+
onInsertText: (text) => callbacksRef.current?.onInsertText(text),
|
|
273
|
+
onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
|
|
274
|
+
onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
|
|
275
|
+
onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
|
|
276
|
+
onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
|
|
277
|
+
onInsertTab: () => callbacksRef.current?.onInsertTab(),
|
|
278
|
+
onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
|
|
279
|
+
onUndo: () => callbacksRef.current?.onUndo(),
|
|
280
|
+
onRedo: () => callbacksRef.current?.onRedo(),
|
|
281
|
+
onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
|
|
282
|
+
});
|
|
283
|
+
|
|
251
284
|
return [
|
|
252
|
-
...
|
|
253
|
-
onInsertText: (text) => callbacksRef.current?.onInsertText(text),
|
|
254
|
-
onDeleteBackward: () => callbacksRef.current?.onDeleteBackward(),
|
|
255
|
-
onDeleteForward: () => callbacksRef.current?.onDeleteForward(),
|
|
256
|
-
onSplitParagraph: () => callbacksRef.current?.onSplitParagraph(),
|
|
257
|
-
onInsertHardBreak: () => callbacksRef.current?.onInsertHardBreak(),
|
|
258
|
-
onInsertTab: () => callbacksRef.current?.onInsertTab(),
|
|
259
|
-
onOutdentTab: () => callbacksRef.current?.onOutdentTab?.(),
|
|
260
|
-
onUndo: () => callbacksRef.current?.onUndo(),
|
|
261
|
-
onRedo: () => callbacksRef.current?.onRedo(),
|
|
262
|
-
onBlockedInput: (command, message) => callbacksRef.current?.onBlockedInput?.(command, message),
|
|
263
|
-
onSelectionChange: (sel) => callbacksRef.current?.onSelectionChange(sel),
|
|
264
|
-
getPositionMap: () => callbacksRef.current?.getPositionMap() ?? null,
|
|
265
|
-
isSelectionSyncSuppressed: () =>
|
|
266
|
-
callbacksRef.current?.isSelectionSyncSuppressed?.() ?? false,
|
|
267
|
-
}),
|
|
285
|
+
...corePlugins,
|
|
268
286
|
createContextualInteractionPlugin({
|
|
269
287
|
onCommentActivated: (commentId) => props.onCommentActivated?.(commentId),
|
|
270
288
|
onRevisionActivated: (revisionId) => props.onRevisionActivated?.(revisionId),
|
|
271
289
|
}),
|
|
272
290
|
createSearchPlugin(),
|
|
273
291
|
];
|
|
274
|
-
}, [props.onCommentActivated, props.onRevisionActivated]);
|
|
292
|
+
}, [props.onCommentActivated, props.onRevisionActivated, props.ydoc, props.awareness]);
|
|
275
293
|
|
|
276
294
|
const applyDecorationProps = useCallback(
|
|
277
295
|
(view: EditorView, positionMap: PositionMap): void => {
|
|
@@ -318,10 +336,14 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
318
336
|
],
|
|
319
337
|
);
|
|
320
338
|
|
|
321
|
-
// Create or update the PM document only when the structural key changes.
|
|
322
339
|
useEffect(() => {
|
|
323
340
|
if (!mountRef.current || !surface) return;
|
|
324
341
|
|
|
342
|
+
// Collab mode: y-prosemirror owns the doc after initial mount
|
|
343
|
+
if (isCollabMode && viewRef.current) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
325
347
|
if (viewRef.current && documentBuildKeyRef.current === documentBuildKey) {
|
|
326
348
|
return;
|
|
327
349
|
}
|
|
@@ -355,19 +377,23 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
355
377
|
incrementInvalidationCounter("pm.laneA.rebuilds");
|
|
356
378
|
|
|
357
379
|
if (!viewRef.current) {
|
|
358
|
-
// First time surface is available — create the EditorView
|
|
359
380
|
const view = new EditorView(mountRef.current, {
|
|
360
381
|
state,
|
|
361
382
|
nodeViews: tableNodeViews,
|
|
362
383
|
editable: () => canEdit,
|
|
363
384
|
decorations: () => decorations,
|
|
364
|
-
dispatchTransaction(tr) {
|
|
365
|
-
const newState = view.state.apply(tr);
|
|
366
|
-
view.updateState(newState);
|
|
367
|
-
},
|
|
368
385
|
});
|
|
369
386
|
viewRef.current = view;
|
|
370
387
|
recordPerfSample("pm.mount");
|
|
388
|
+
|
|
389
|
+
if (isCollabMode && props.ydoc) {
|
|
390
|
+
const yXmlFragment = props.ydoc.getXmlFragment("prosemirror");
|
|
391
|
+
if (yXmlFragment.length === 0) {
|
|
392
|
+
props.ydoc.transact(() => {
|
|
393
|
+
prosemirrorToYXmlFragment(view.state.doc, yXmlFragment);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
371
397
|
} else {
|
|
372
398
|
suppressSelectionEchoRef.current = true;
|
|
373
399
|
viewRef.current.updateState(state);
|
|
@@ -391,10 +417,12 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
391
417
|
}, [
|
|
392
418
|
applyDecorationProps,
|
|
393
419
|
documentBuildKey,
|
|
420
|
+
isCollabMode,
|
|
394
421
|
surface,
|
|
395
422
|
snapshot.selection,
|
|
396
423
|
plugins,
|
|
397
424
|
props.mediaPreviews,
|
|
425
|
+
props.ydoc,
|
|
398
426
|
]);
|
|
399
427
|
|
|
400
428
|
// Update decorations and editability without rebuilding the PM document.
|
|
@@ -427,6 +455,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
427
455
|
]);
|
|
428
456
|
|
|
429
457
|
useEffect(() => {
|
|
458
|
+
if (isCollabMode) return;
|
|
430
459
|
const view = viewRef.current;
|
|
431
460
|
const positionMap = positionMapRef.current;
|
|
432
461
|
if (!view || !surface || !positionMap) {
|
|
@@ -448,7 +477,7 @@ export const TwProseMirrorSurface = forwardRef<
|
|
|
448
477
|
queueMicrotask(() => {
|
|
449
478
|
suppressSelectionEchoRef.current = false;
|
|
450
479
|
});
|
|
451
|
-
}, [snapshot.selection, surface]);
|
|
480
|
+
}, [isCollabMode, snapshot.selection, surface]);
|
|
452
481
|
|
|
453
482
|
useEffect(() => {
|
|
454
483
|
if (!pendingSelectionProbeRef.current) {
|