@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.
@@ -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 field.fieldFamily === "TOC"
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 CommandBridgeCallbacks {
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
- * Creates ProseMirror plugins that intercept user input and dispatch
32
- * runtime commands instead of letting PM mutate its own state.
33
- *
34
- * All doc-changing transactions are blocked. Only the explicit input
35
- * hooks below are allowed to trigger runtime commands.
36
- */
37
- export function createCommandBridgePlugins(
38
- callbacks: CommandBridgeCallbacks,
39
- ): Plugin[] {
40
- let isComposing = false;
41
-
42
- // Transaction filter: block ALL doc-changing transactions.
43
- // The runtime is the sole authority for document mutations.
44
- const filterPlugin = new Plugin({
45
- key: bridgeKey,
46
- filterTransaction(tr) {
47
- // Allow selection-only and metadata-only transactions
48
- if (!tr.docChanged) return true;
49
- // Block doc changes — runtime handles mutations via callbacks
50
- return false;
51
- },
52
- });
53
-
54
- // Selection sync: when PM selection changes, notify the runtime.
55
- const selectionPlugin = new Plugin({
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 pmFrom = positionMap.runtimeToPm(rev.from);
313
- const pmTo = positionMap.runtimeToPm(rev.to);
314
- if (pmFrom < pmTo) {
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(pmFrom, pmTo, {
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,