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