@beyondwork/docx-react-component 1.0.33 → 1.0.35
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 +20 -3
- package/src/api/public-types.ts +13 -8
- package/src/api/session-state.ts +24 -0
- package/src/core/commands/index.ts +73 -0
- package/src/index.ts +2 -0
- package/src/io/docx-session.ts +260 -3
- package/src/io/ooxml/workflow-payload.ts +122 -0
- package/src/model/snapshot.ts +58 -0
- package/src/runtime/collab-review-sync.ts +254 -0
- package/src/runtime/document-runtime.ts +4 -3
- package/src/runtime/surface-projection.ts +8 -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 +8 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +55 -24
package/src/model/snapshot.ts
CHANGED
|
@@ -124,12 +124,20 @@ export interface PersistedWorkflowMetadataSnapshot {
|
|
|
124
124
|
|
|
125
125
|
export interface PersistedWorkflowScope {
|
|
126
126
|
scopeId: string;
|
|
127
|
+
version?: number;
|
|
127
128
|
mode: "edit" | "suggest" | "comment" | "view";
|
|
128
129
|
anchor: Record<string, unknown>;
|
|
129
130
|
storyTarget?: Record<string, unknown>;
|
|
130
131
|
workItemId?: string;
|
|
131
132
|
label?: string;
|
|
132
133
|
domain?: "legal" | "commercial" | "finance" | "other";
|
|
134
|
+
metadata?: PersistedWorkflowScopeMetadataField[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface PersistedWorkflowScopeMetadataField {
|
|
138
|
+
key: string;
|
|
139
|
+
valueType?: "string" | "number" | "boolean" | "json";
|
|
140
|
+
value?: string | number | boolean | Record<string, unknown>;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
export interface PersistedWorkflowWorkItem {
|
|
@@ -726,12 +734,62 @@ function validateWorkflowScope(
|
|
|
726
734
|
issues.push({ path: `${path}.mode`, message: "mode must be edit, suggest, comment, or view." });
|
|
727
735
|
}
|
|
728
736
|
asPlainObject(record.anchor, `${path}.anchor`, issues);
|
|
737
|
+
if (record.version !== undefined && !Number.isInteger(record.version)) {
|
|
738
|
+
issues.push({ path: `${path}.version`, message: "version must be an integer." });
|
|
739
|
+
}
|
|
729
740
|
if (record.workItemId !== undefined) {
|
|
730
741
|
expectString(record.workItemId, `${path}.workItemId`, issues);
|
|
731
742
|
}
|
|
732
743
|
if (record.label !== undefined) {
|
|
733
744
|
expectString(record.label, `${path}.label`, issues);
|
|
734
745
|
}
|
|
746
|
+
if (record.metadata !== undefined) {
|
|
747
|
+
if (!Array.isArray(record.metadata)) {
|
|
748
|
+
issues.push({ path: `${path}.metadata`, message: "metadata must be an array." });
|
|
749
|
+
} else {
|
|
750
|
+
record.metadata.forEach((field, index) =>
|
|
751
|
+
validateWorkflowScopeMetadataField(field, `${path}.metadata[${index}]`, issues),
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function validateWorkflowScopeMetadataField(
|
|
758
|
+
value: unknown,
|
|
759
|
+
path: string,
|
|
760
|
+
issues: ModelValidationIssue[],
|
|
761
|
+
): void {
|
|
762
|
+
const record = asPlainObject(value, path, issues);
|
|
763
|
+
if (!record) {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
expectString(record.key, `${path}.key`, issues);
|
|
767
|
+
if (
|
|
768
|
+
record.valueType !== undefined &&
|
|
769
|
+
record.valueType !== "string" &&
|
|
770
|
+
record.valueType !== "number" &&
|
|
771
|
+
record.valueType !== "boolean" &&
|
|
772
|
+
record.valueType !== "json"
|
|
773
|
+
) {
|
|
774
|
+
issues.push({
|
|
775
|
+
path: `${path}.valueType`,
|
|
776
|
+
message: "valueType must be string, number, boolean, or json.",
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (
|
|
780
|
+
record.value !== undefined &&
|
|
781
|
+
typeof record.value !== "string" &&
|
|
782
|
+
typeof record.value !== "number" &&
|
|
783
|
+
typeof record.value !== "boolean"
|
|
784
|
+
) {
|
|
785
|
+
const nestedRecord = asPlainObject(record.value, `${path}.value`, issues);
|
|
786
|
+
if (!nestedRecord) {
|
|
787
|
+
issues.push({
|
|
788
|
+
path: `${path}.value`,
|
|
789
|
+
message: "value must be a string, number, boolean, or plain object.",
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
}
|
|
735
793
|
}
|
|
736
794
|
|
|
737
795
|
function validateWorkflowWorkItem(
|
|
@@ -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
|
+
}
|
|
@@ -3949,10 +3949,11 @@ function resolveSupportedFieldDisplay(
|
|
|
3949
3949
|
if (!field.fieldFamily || !isSupportedFieldFamily(field.fieldFamily)) {
|
|
3950
3950
|
return undefined;
|
|
3951
3951
|
}
|
|
3952
|
+
if (field.fieldFamily === "TOC") {
|
|
3953
|
+
return undefined;
|
|
3954
|
+
}
|
|
3952
3955
|
if (!field.fieldTarget) {
|
|
3953
|
-
return
|
|
3954
|
-
? undefined
|
|
3955
|
-
: { displayText: "", refreshStatus: "unresolvable" };
|
|
3956
|
+
return { displayText: "", refreshStatus: "unresolvable" };
|
|
3956
3957
|
}
|
|
3957
3958
|
if (field.fieldFamily === "REF") {
|
|
3958
3959
|
const result = resolveRefFieldText(document, bookmarkMap, field.fieldTarget);
|
|
@@ -1427,6 +1427,14 @@ function describePreservedInlinePreview(
|
|
|
1427
1427
|
};
|
|
1428
1428
|
}
|
|
1429
1429
|
|
|
1430
|
+
if (/\b(?:w:)?br\b[^>]*\b(?:w:)?type="page"/u.test(payloadReference)) {
|
|
1431
|
+
return {
|
|
1432
|
+
label: "Page break",
|
|
1433
|
+
detail: "Word page-break marker preserved for export safety.",
|
|
1434
|
+
presentation: "quiet-marker",
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1430
1438
|
if (/\b(?:w:)?permStart\b/u.test(payloadReference)) {
|
|
1431
1439
|
const editorGroup = /\bw:edGrp="([^"]+)"/u.exec(payloadReference)?.[1];
|
|
1432
1440
|
return {
|
|
@@ -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({
|
|
@@ -309,11 +309,11 @@ export function buildDecorations(
|
|
|
309
309
|
// This is the critical behavior: "hide tracked changes" must show
|
|
310
310
|
// the document as if accepted, not show deleted text as kept text.
|
|
311
311
|
if (markupDisplay === "clean" && rev.kind === "deletion") {
|
|
312
|
-
const
|
|
313
|
-
const
|
|
314
|
-
if (
|
|
312
|
+
const cleanPmFrom = positionMap.runtimeToPm(rev.from);
|
|
313
|
+
const cleanPmTo = positionMap.runtimeToPm(rev.to);
|
|
314
|
+
if (cleanPmFrom < cleanPmTo) {
|
|
315
315
|
decorations.push(
|
|
316
|
-
Decoration.inline(
|
|
316
|
+
Decoration.inline(cleanPmFrom, cleanPmTo, {
|
|
317
317
|
class: "hidden",
|
|
318
318
|
"data-revision-id": rev.revisionId,
|
|
319
319
|
}),
|
|
@@ -322,13 +322,11 @@ export function buildDecorations(
|
|
|
322
322
|
continue;
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
// Skip visual styling when tracked changes display is off
|
|
326
|
-
if (!showTrackedChanges) continue;
|
|
327
|
-
|
|
328
325
|
const pmFrom = positionMap.runtimeToPm(rev.from);
|
|
329
326
|
const pmTo = positionMap.runtimeToPm(rev.to);
|
|
330
327
|
if (pmFrom >= pmTo) continue;
|
|
331
328
|
|
|
329
|
+
// Suggestions styling is always shown regardless of showTrackedChanges toggle.
|
|
332
330
|
if (suggestionsEnabled) {
|
|
333
331
|
if (rev.kind === "insertion") {
|
|
334
332
|
decorations.push(
|
|
@@ -366,6 +364,9 @@ export function buildDecorations(
|
|
|
366
364
|
continue;
|
|
367
365
|
}
|
|
368
366
|
|
|
367
|
+
// Skip normal markup styling when tracked changes display is off
|
|
368
|
+
if (!showTrackedChanges) continue;
|
|
369
|
+
|
|
369
370
|
const cls = getRevisionHighlightClass(
|
|
370
371
|
revisionModel,
|
|
371
372
|
rev.from,
|
|
@@ -37,6 +37,7 @@ export function createSurfaceDocumentBuildKey(input: {
|
|
|
37
37
|
export function createSurfaceDecorationKey(input: {
|
|
38
38
|
markupDisplay: string;
|
|
39
39
|
showTrackedChanges: boolean;
|
|
40
|
+
suggestionsEnabled?: boolean;
|
|
40
41
|
canEdit: boolean;
|
|
41
42
|
activeCommentId?: string;
|
|
42
43
|
activeRevisionId?: string;
|
|
@@ -51,6 +52,7 @@ export function createSurfaceDecorationKey(input: {
|
|
|
51
52
|
return JSON.stringify({
|
|
52
53
|
markupDisplay: input.markupDisplay,
|
|
53
54
|
showTrackedChanges: input.showTrackedChanges,
|
|
55
|
+
suggestionsEnabled: input.suggestionsEnabled ?? false,
|
|
54
56
|
canEdit: input.canEdit,
|
|
55
57
|
activeCommentId: input.activeCommentId ?? null,
|
|
56
58
|
activeRevisionId: input.activeRevisionId ?? null,
|