@beyondwork/docx-react-component 1.0.1 → 1.0.2

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.
Files changed (172) hide show
  1. package/README.md +44 -104
  2. package/package.json +76 -46
  3. package/src/README.md +85 -0
  4. package/src/api/README.md +22 -0
  5. package/src/api/public-types.ts +525 -0
  6. package/src/compare/diff-engine.ts +530 -0
  7. package/src/compare/export-redlines.ts +162 -0
  8. package/src/compare/snapshot.ts +37 -0
  9. package/src/component-inventory.md +99 -0
  10. package/src/core/README.md +10 -0
  11. package/src/core/commands/README.md +3 -0
  12. package/src/core/commands/formatting-commands.ts +161 -0
  13. package/src/core/commands/image-commands.ts +144 -0
  14. package/src/core/commands/index.ts +1013 -0
  15. package/src/core/commands/list-commands.ts +370 -0
  16. package/src/core/commands/review-commands.ts +108 -0
  17. package/src/core/commands/text-commands.ts +119 -0
  18. package/src/core/schema/README.md +3 -0
  19. package/src/core/schema/text-schema.ts +512 -0
  20. package/src/core/selection/README.md +3 -0
  21. package/src/core/selection/mapping.ts +238 -0
  22. package/src/core/selection/review-anchors.ts +94 -0
  23. package/src/core/state/README.md +3 -0
  24. package/src/core/state/editor-state.ts +580 -0
  25. package/src/core/state/text-transaction.ts +276 -0
  26. package/src/formats/xlsx/io/parse-shared-strings.ts +41 -0
  27. package/src/formats/xlsx/io/parse-sheet.ts +289 -0
  28. package/src/formats/xlsx/io/parse-styles.ts +57 -0
  29. package/src/formats/xlsx/io/parse-workbook.ts +75 -0
  30. package/src/formats/xlsx/io/xlsx-session.ts +306 -0
  31. package/src/formats/xlsx/model/cell.ts +189 -0
  32. package/src/formats/xlsx/model/sheet.ts +244 -0
  33. package/src/formats/xlsx/model/styles.ts +118 -0
  34. package/src/formats/xlsx/model/workbook.ts +449 -0
  35. package/src/index.ts +45 -0
  36. package/src/io/README.md +10 -0
  37. package/src/io/docx-session.ts +1763 -0
  38. package/src/io/export/README.md +3 -0
  39. package/src/io/export/export-session.ts +165 -0
  40. package/src/io/export/minimal-docx.ts +115 -0
  41. package/src/io/export/reattach-preserved-parts.ts +54 -0
  42. package/src/io/export/serialize-comments.ts +876 -0
  43. package/src/io/export/serialize-footnotes.ts +217 -0
  44. package/src/io/export/serialize-headers-footers.ts +200 -0
  45. package/src/io/export/serialize-main-document.ts +982 -0
  46. package/src/io/export/serialize-numbering.ts +97 -0
  47. package/src/io/export/serialize-revisions.ts +389 -0
  48. package/src/io/export/serialize-runtime-revisions.ts +265 -0
  49. package/src/io/export/serialize-tables.ts +147 -0
  50. package/src/io/export/split-review-boundaries.ts +194 -0
  51. package/src/io/normalize/README.md +3 -0
  52. package/src/io/normalize/normalize-text.ts +437 -0
  53. package/src/io/ooxml/README.md +3 -0
  54. package/src/io/ooxml/parse-comments.ts +779 -0
  55. package/src/io/ooxml/parse-complex-content.ts +287 -0
  56. package/src/io/ooxml/parse-fields.ts +438 -0
  57. package/src/io/ooxml/parse-footnotes.ts +403 -0
  58. package/src/io/ooxml/parse-headers-footers.ts +483 -0
  59. package/src/io/ooxml/parse-inline-media.ts +431 -0
  60. package/src/io/ooxml/parse-main-document.ts +1846 -0
  61. package/src/io/ooxml/parse-numbering.ts +425 -0
  62. package/src/io/ooxml/parse-revisions.ts +658 -0
  63. package/src/io/ooxml/parse-shapes.ts +271 -0
  64. package/src/io/ooxml/parse-tables.ts +568 -0
  65. package/src/io/ooxml/parse-theme.ts +314 -0
  66. package/src/io/ooxml/part-manifest.ts +136 -0
  67. package/src/io/ooxml/revision-boundaries.ts +351 -0
  68. package/src/io/opc/README.md +3 -0
  69. package/src/io/opc/corrupt-package.ts +166 -0
  70. package/src/io/opc/docx-package.ts +74 -0
  71. package/src/io/opc/package-reader.ts +320 -0
  72. package/src/io/opc/package-writer.ts +273 -0
  73. package/src/legal/bookmarks.ts +196 -0
  74. package/src/legal/cross-references.ts +356 -0
  75. package/src/legal/defined-terms.ts +203 -0
  76. package/src/model/README.md +3 -0
  77. package/src/model/canonical-document.ts +1911 -0
  78. package/src/model/cds-1.0.0.ts +196 -0
  79. package/src/model/snapshot.ts +393 -0
  80. package/src/preservation/README.md +3 -0
  81. package/src/preservation/markup-compatibility.ts +48 -0
  82. package/src/preservation/opaque-fragment-store.ts +89 -0
  83. package/src/preservation/opaque-region.ts +233 -0
  84. package/src/preservation/package-preservation.ts +120 -0
  85. package/src/preservation/preserved-part-manifest.ts +56 -0
  86. package/src/preservation/relationship-retention.ts +57 -0
  87. package/src/preservation/store.ts +185 -0
  88. package/src/review/README.md +16 -0
  89. package/src/review/store/README.md +3 -0
  90. package/src/review/store/comment-anchors.ts +70 -0
  91. package/src/review/store/comment-remapping.ts +154 -0
  92. package/src/review/store/comment-store.ts +331 -0
  93. package/src/review/store/comment-thread.ts +109 -0
  94. package/src/review/store/revision-actions.ts +394 -0
  95. package/src/review/store/revision-store.ts +303 -0
  96. package/src/review/store/revision-types.ts +168 -0
  97. package/src/review/store/runtime-comment-store.ts +43 -0
  98. package/src/runtime/README.md +3 -0
  99. package/src/runtime/ai-action-policy.ts +764 -0
  100. package/src/runtime/document-runtime.ts +967 -0
  101. package/src/runtime/read-only-diagnostics-runtime.ts +232 -0
  102. package/src/runtime/review-runtime.ts +44 -0
  103. package/src/runtime/revision-runtime.ts +107 -0
  104. package/src/runtime/session-capabilities.ts +138 -0
  105. package/src/runtime/surface-projection.ts +570 -0
  106. package/src/runtime/table-commands.ts +87 -0
  107. package/src/runtime/table-schema.ts +140 -0
  108. package/src/runtime/virtualized-rendering.ts +258 -0
  109. package/src/ui/README.md +30 -0
  110. package/src/ui/WordReviewEditor.tsx +1504 -0
  111. package/src/ui/comments/README.md +3 -0
  112. package/src/ui/compatibility/README.md +3 -0
  113. package/src/ui/editor-surface/README.md +3 -0
  114. package/src/ui/headless/comment-decoration-model.ts +124 -0
  115. package/src/ui/headless/revision-decoration-model.ts +128 -0
  116. package/src/ui/headless/selection-helpers.ts +34 -0
  117. package/src/ui/headless/use-editor-keyboard.ts +98 -0
  118. package/src/ui/review/README.md +3 -0
  119. package/src/ui/shared/revision-filters.ts +31 -0
  120. package/src/ui/status/README.md +3 -0
  121. package/src/ui/theme/README.md +3 -0
  122. package/src/ui/toolbar/README.md +3 -0
  123. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +48 -0
  124. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +44 -0
  125. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +58 -0
  126. package/src/ui-tailwind/chrome/use-before-unload.ts +20 -0
  127. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +139 -0
  128. package/src/ui-tailwind/editor-surface/pm-decorations.ts +98 -0
  129. package/src/ui-tailwind/editor-surface/pm-position-map.ts +123 -0
  130. package/src/ui-tailwind/editor-surface/pm-schema.ts +452 -0
  131. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +327 -0
  132. package/src/ui-tailwind/editor-surface/search-plugin.ts +157 -0
  133. package/src/ui-tailwind/editor-surface/tw-caret.tsx +12 -0
  134. package/src/ui-tailwind/editor-surface/tw-editor-surface.tsx +150 -0
  135. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +118 -0
  136. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +52 -0
  137. package/src/ui-tailwind/editor-surface/tw-paragraph-block.tsx +151 -0
  138. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +215 -0
  139. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +111 -0
  140. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +122 -0
  141. package/src/ui-tailwind/index.ts +61 -0
  142. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +276 -0
  143. package/src/ui-tailwind/review/tw-health-panel.tsx +120 -0
  144. package/src/ui-tailwind/review/tw-review-rail.tsx +120 -0
  145. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +164 -0
  146. package/src/ui-tailwind/status/tw-status-bar.tsx +58 -0
  147. package/src/ui-tailwind/theme/editor-theme.css +190 -0
  148. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +48 -0
  149. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +231 -0
  150. package/src/ui-tailwind/tw-review-workspace.tsx +140 -0
  151. package/src/validation/README.md +3 -0
  152. package/src/validation/compatibility-engine.ts +317 -0
  153. package/src/validation/compatibility-report.ts +160 -0
  154. package/src/validation/diagnostics.ts +203 -0
  155. package/src/validation/import-diagnostics.ts +128 -0
  156. package/src/validation/low-priority-word-surfaces.ts +373 -0
  157. package/dist/chunk-32W6IVQE.js +0 -7725
  158. package/dist/chunk-32W6IVQE.js.map +0 -1
  159. package/dist/index.cjs +0 -23722
  160. package/dist/index.cjs.map +0 -1
  161. package/dist/index.d.cts +0 -7
  162. package/dist/index.d.ts +0 -7
  163. package/dist/index.js +0 -16011
  164. package/dist/index.js.map +0 -1
  165. package/dist/public-types-DqCURAz8.d.cts +0 -1152
  166. package/dist/public-types-DqCURAz8.d.ts +0 -1152
  167. package/dist/tailwind.cjs +0 -8295
  168. package/dist/tailwind.cjs.map +0 -1
  169. package/dist/tailwind.d.cts +0 -323
  170. package/dist/tailwind.d.ts +0 -323
  171. package/dist/tailwind.js +0 -553
  172. package/dist/tailwind.js.map +0 -1
@@ -0,0 +1,1504 @@
1
+ import React, {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+
11
+ import type {
12
+ AutosaveState,
13
+ EditorDatastoreAdapter,
14
+ CompatibilityReport,
15
+ EditorError,
16
+ EditorWarning,
17
+ ExportDocxOptions,
18
+ PersistedEditorSnapshot,
19
+ RuntimeRenderSnapshot,
20
+ SelectionSnapshot as PublicSelectionSnapshot,
21
+ ExportResult,
22
+ WordReviewEditorEvent,
23
+ WordReviewEditorProps,
24
+ WordReviewEditorRef,
25
+ } from "../api/public-types";
26
+ import {
27
+ createDetachedAnchor,
28
+ createNodeAnchor,
29
+ createRangeAnchor,
30
+ } from "../core/selection/mapping.ts";
31
+ import { createCanonicalDocumentId } from "../core/state/editor-state.ts";
32
+ import {
33
+ createDocumentRuntime,
34
+ type DocumentRuntime,
35
+ } from "../runtime/document-runtime.ts";
36
+ import { loadDocxEditorSession } from "../io/docx-session.ts";
37
+ import { exportSnapshotToMinimalDocx } from "../io/export/minimal-docx.ts";
38
+ import { DOCX_MIME_TYPE } from "../io/opc/docx-package.ts";
39
+ import { deriveCapabilities } from "../runtime/session-capabilities";
40
+ import { TwProseMirrorSurface } from "../ui-tailwind/editor-surface/tw-prosemirror-surface";
41
+ import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace";
42
+ import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail";
43
+ import type { ViewMode } from "../ui-tailwind/toolbar/tw-toolbar";
44
+ import type { MarkupDisplay } from "./headless/comment-decoration-model";
45
+
46
+ interface ResolvedSource {
47
+ source: "docx" | "snapshot" | "datastore";
48
+ sourceLabel?: string;
49
+ initialDocx?: Uint8Array | ArrayBuffer;
50
+ initialSnapshot?: PersistedEditorSnapshot;
51
+ }
52
+
53
+ interface CreateRuntimeArgs {
54
+ documentId: string;
55
+ readOnly: boolean;
56
+ source: ResolvedSource;
57
+ datastore?: EditorDatastoreAdapter;
58
+ currentUserId?: string;
59
+ }
60
+
61
+ interface RuntimeLifecycleHandlers {
62
+ onEvent?: (event: WordReviewEditorEvent) => void;
63
+ onWarning?: (warning: EditorWarning) => void;
64
+ onError?: (error: EditorError) => void;
65
+ }
66
+
67
+ interface WordReviewEditorRuntime extends DocumentRuntime {
68
+ getFatalError?(): EditorError | undefined;
69
+ dispose?(): void;
70
+ }
71
+
72
+ const VISUALLY_HIDDEN_STYLES: React.CSSProperties = {
73
+ position: "absolute",
74
+ width: "1px",
75
+ height: "1px",
76
+ padding: 0,
77
+ margin: "-1px",
78
+ overflow: "hidden",
79
+ clip: "rect(0 0 0 0)",
80
+ whiteSpace: "nowrap",
81
+ border: 0,
82
+ };
83
+
84
+ const ACCESSIBLE_REGION_ORDER = [
85
+ "toolbar",
86
+ "document",
87
+ "review-rail",
88
+ "status",
89
+ ] as const;
90
+
91
+ type AccessibleRegionId = (typeof ACCESSIBLE_REGION_ORDER)[number];
92
+
93
+ export function __createWordReviewEditorRefBridge(
94
+ runtime: WordReviewEditorRuntime,
95
+ ): WordReviewEditorRef {
96
+ return {
97
+ focus: () => runtime.focus(),
98
+ blur: () => runtime.blur(),
99
+ undo: () => runtime.undo(),
100
+ redo: () => runtime.redo(),
101
+ addComment: (params) => runtime.addComment(params),
102
+ openComment: (commentId) => runtime.openComment(commentId),
103
+ resolveComment: (commentId) => runtime.resolveComment(commentId),
104
+ reopenComment: (commentId) => runtime.reopenComment(commentId),
105
+ addCommentReply: (commentId, body) => runtime.addCommentReply(commentId, body),
106
+ editCommentBody: (commentId, body) => runtime.editCommentBody(commentId, body),
107
+ acceptChange: (changeId) => runtime.acceptChange(changeId),
108
+ rejectChange: (changeId) => runtime.rejectChange(changeId),
109
+ acceptAllChanges: () => runtime.acceptAllChanges(),
110
+ rejectAllChanges: () => runtime.rejectAllChanges(),
111
+ exportDocx: (options) => runtime.exportDocx(options),
112
+ getSnapshot: () => runtime.getPersistedSnapshot(),
113
+ getCompatibilityReport: () => runtime.getCompatibilityReport(),
114
+ getWarnings: () => runtime.getWarnings(),
115
+ };
116
+ }
117
+
118
+ export async function __resolveWordReviewEditorSource(
119
+ props: Pick<
120
+ WordReviewEditorProps,
121
+ | "documentId"
122
+ | "datastore"
123
+ | "externalDocSource"
124
+ | "initialDocx"
125
+ | "initialSnapshot"
126
+ | "initialSourceLabel"
127
+ >,
128
+ ): Promise<ResolvedSource> {
129
+ const explicitInitialCount =
130
+ Number(Boolean(props.initialDocx)) + Number(Boolean(props.initialSnapshot));
131
+ if (explicitInitialCount > 1) {
132
+ throw new Error("Provide exactly one of initialDocx or initialSnapshot.");
133
+ }
134
+
135
+ if (props.externalDocSource) {
136
+ return props.externalDocSource.kind === "docx"
137
+ ? {
138
+ source: "docx",
139
+ initialDocx: props.externalDocSource.bytes,
140
+ sourceLabel: props.externalDocSource.sourceLabel,
141
+ }
142
+ : {
143
+ source: "snapshot",
144
+ initialSnapshot: props.externalDocSource.snapshot,
145
+ sourceLabel: props.externalDocSource.sourceLabel,
146
+ };
147
+ }
148
+
149
+ if (props.initialSnapshot) {
150
+ return {
151
+ source: "snapshot",
152
+ initialSnapshot: props.initialSnapshot,
153
+ sourceLabel: props.initialSourceLabel,
154
+ };
155
+ }
156
+
157
+ if (props.initialDocx) {
158
+ return {
159
+ source: "docx",
160
+ initialDocx: props.initialDocx,
161
+ sourceLabel: props.initialSourceLabel,
162
+ };
163
+ }
164
+
165
+ if (!props.datastore) {
166
+ throw new Error(
167
+ `WordReviewEditor ${props.documentId} needs initialDocx, initialSnapshot, or datastore.load().`,
168
+ );
169
+ }
170
+
171
+ const loadResult = await props.datastore.load({
172
+ documentId: props.documentId,
173
+ });
174
+
175
+ if (!loadResult.source) {
176
+ throw new Error(`Datastore did not return a loadable source for ${props.documentId}.`);
177
+ }
178
+
179
+ return loadResult.source.kind === "docx"
180
+ ? {
181
+ source: "datastore",
182
+ initialDocx: loadResult.source.bytes,
183
+ sourceLabel: loadResult.source.sourceLabel,
184
+ }
185
+ : {
186
+ source: "datastore",
187
+ initialSnapshot: loadResult.source.snapshot,
188
+ sourceLabel: loadResult.source.sourceLabel,
189
+ };
190
+ }
191
+
192
+ export function __createFallbackRuntime(args: CreateRuntimeArgs): WordReviewEditorRuntime {
193
+ return createRuntime(args);
194
+ }
195
+
196
+ function createRuntime(
197
+ args: CreateRuntimeArgs,
198
+ handlers: RuntimeLifecycleHandlers = {},
199
+ ): WordReviewEditorRuntime {
200
+ const docxSession = args.source.initialDocx
201
+ ? loadDocxEditorSession({
202
+ documentId: args.documentId,
203
+ sourceLabel: args.source.sourceLabel,
204
+ bytes: args.source.initialDocx,
205
+ editorBuild: "dev",
206
+ })
207
+ : undefined;
208
+ const initialSnapshot =
209
+ args.source.initialSnapshot ??
210
+ docxSession?.initialSnapshot ??
211
+ createFallbackPersistedSnapshot(
212
+ args.documentId,
213
+ args.source.sourceLabel ?? "Generated shell snapshot",
214
+ );
215
+
216
+ return createDocumentRuntime({
217
+ documentId: args.documentId,
218
+ initialSnapshot,
219
+ sourceKind: args.source.source,
220
+ sourceLabel: args.source.sourceLabel,
221
+ readOnly: args.readOnly || docxSession?.readOnly,
222
+ editorBuild: initialSnapshot.editorBuild,
223
+ fatalError: docxSession?.fatalError,
224
+ exportDocx: async (snapshot, options) =>
225
+ docxSession
226
+ ? docxSession.exportDocx(snapshot, options)
227
+ : {
228
+ bytes: exportSnapshotToMinimalDocx(snapshot),
229
+ mimeType: DOCX_MIME_TYPE,
230
+ fileName: options?.fileName ?? `${args.documentId}.docx`,
231
+ },
232
+ onWarning: handlers.onWarning,
233
+ onError: handlers.onError,
234
+ defaultAuthorId: args.currentUserId,
235
+ });
236
+ }
237
+
238
+ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditorProps>(
239
+ function WordReviewEditor(props, ref) {
240
+ const {
241
+ currentUser,
242
+ datastore,
243
+ documentId,
244
+ externalDocSource,
245
+ externalDocumentRevision,
246
+ initialDocx,
247
+ initialSnapshot,
248
+ initialSourceLabel,
249
+ markupDisplay = "simple",
250
+ onError,
251
+ onEvent,
252
+ onWarning,
253
+ readOnly = false,
254
+ reviewMode = "review",
255
+ } = props;
256
+
257
+ const [runtime, setRuntime] = useState<WordReviewEditorRuntime | null>(null);
258
+ const [loadError, setLoadError] = useState<EditorError | null>(null);
259
+ const [viewMode, setViewMode] = useState<ViewMode>("canvas");
260
+ const liveMarkupDisplay: MarkupDisplay = viewMode === "document" ? "all" : "clean";
261
+ const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
262
+ const [showTrackedChanges, setShowTrackedChanges] = useState(false);
263
+ const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
264
+ const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
265
+ const shellRef = useRef<HTMLDivElement | null>(null);
266
+ const lastAnnouncedErrorIdRef = useRef<string | null>(null);
267
+ const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
268
+ const lastSavedRevisionTokenRef = useRef<string | null>(null);
269
+ const datastoreRef = useRef(datastore);
270
+ const onEventRef = useRef(onEvent);
271
+ const onWarningRef = useRef(onWarning);
272
+ const onErrorRef = useRef(onError);
273
+ const initialSourceRef = useRef<{
274
+ documentId: string;
275
+ initialDocx?: Uint8Array | ArrayBuffer;
276
+ initialSnapshot?: PersistedEditorSnapshot;
277
+ initialSourceLabel?: string;
278
+ } | null>(null);
279
+
280
+ if (!initialSourceRef.current || initialSourceRef.current.documentId !== documentId) {
281
+ initialSourceRef.current = {
282
+ documentId,
283
+ initialDocx,
284
+ initialSnapshot,
285
+ initialSourceLabel,
286
+ };
287
+ }
288
+
289
+ const stableInitialSource = initialSourceRef.current;
290
+ const sourceReloadKey = externalDocSource
291
+ ? `external:${externalDocSource.kind}:${externalDocumentRevision ?? "static"}`
292
+ : stableInitialSource?.initialSnapshot
293
+ ? "initial-snapshot"
294
+ : stableInitialSource?.initialDocx
295
+ ? "initial-docx"
296
+ : "datastore";
297
+
298
+ useEffect(() => {
299
+ datastoreRef.current = datastore;
300
+ onEventRef.current = onEvent;
301
+ onWarningRef.current = onWarning;
302
+ onErrorRef.current = onError;
303
+ }, [datastore, onError, onEvent, onWarning]);
304
+
305
+ useEffect(() => {
306
+ let cancelled = false;
307
+
308
+ async function loadRuntime(): Promise<void> {
309
+ setLoadError(null);
310
+
311
+ try {
312
+ const source = await __resolveWordReviewEditorSource({
313
+ documentId,
314
+ datastore: datastoreRef.current,
315
+ externalDocSource,
316
+ initialDocx: stableInitialSource?.initialDocx,
317
+ initialSnapshot: stableInitialSource?.initialSnapshot,
318
+ initialSourceLabel: stableInitialSource?.initialSourceLabel,
319
+ });
320
+
321
+ if (cancelled) {
322
+ return;
323
+ }
324
+
325
+ runtimeRef.current?.dispose?.();
326
+ const nextRuntime = createRuntime(
327
+ {
328
+ documentId,
329
+ readOnly,
330
+ source,
331
+ datastore: datastoreRef.current,
332
+ currentUserId: currentUser.userId,
333
+ },
334
+ {
335
+ onWarning: onWarningRef.current,
336
+ onError: onErrorRef.current,
337
+ },
338
+ );
339
+ emitEditorEvent({
340
+ datastore: datastoreRef.current,
341
+ onEvent: onEventRef.current,
342
+ event: createReadyEvent(nextRuntime, source.source),
343
+ });
344
+ runtimeRef.current = nextRuntime;
345
+ setRuntime(nextRuntime);
346
+ } catch (error) {
347
+ if (cancelled) {
348
+ return;
349
+ }
350
+
351
+ const normalized = normalizeEditorError(error);
352
+ setLoadError(normalized);
353
+ onErrorRef.current?.(normalized);
354
+ emitEditorEvent({
355
+ datastore: datastoreRef.current,
356
+ onEvent: onEventRef.current,
357
+ event: {
358
+ type: "error",
359
+ documentId,
360
+ error: normalized,
361
+ },
362
+ });
363
+ }
364
+ }
365
+
366
+ void loadRuntime();
367
+
368
+ return () => {
369
+ cancelled = true;
370
+ };
371
+ }, [
372
+ documentId,
373
+ readOnly,
374
+ sourceReloadKey,
375
+ ]);
376
+
377
+ useEffect(() => {
378
+ if (!runtime?.subscribeToEvents) {
379
+ return;
380
+ }
381
+
382
+ return runtime.subscribeToEvents((event) => {
383
+ emitEditorEvent({
384
+ datastore: datastoreRef.current,
385
+ onEvent: onEventRef.current,
386
+ event,
387
+ });
388
+ });
389
+ }, [runtime]);
390
+
391
+ useEffect(() => {
392
+ return () => {
393
+ if (autosaveTimerRef.current) {
394
+ clearTimeout(autosaveTimerRef.current);
395
+ autosaveTimerRef.current = null;
396
+ }
397
+ runtimeRef.current?.dispose?.();
398
+ runtimeRef.current = null;
399
+ };
400
+ }, []);
401
+
402
+ const optimisticRuntime = useMemo(
403
+ () =>
404
+ __createFallbackRuntime({
405
+ documentId,
406
+ readOnly,
407
+ currentUserId: currentUser.userId,
408
+ source: {
409
+ source: "snapshot",
410
+ initialSnapshot:
411
+ initialSnapshot ?? createFallbackPersistedSnapshot(documentId, initialSourceLabel),
412
+ sourceLabel: guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
413
+ },
414
+ datastore: datastoreRef.current,
415
+ }),
416
+ [
417
+ currentUser.userId,
418
+ documentId,
419
+ initialSnapshot,
420
+ initialSourceLabel,
421
+ readOnly,
422
+ externalDocSource?.kind,
423
+ externalDocSource?.sourceLabel,
424
+ ],
425
+ );
426
+
427
+ const fallbackSnapshot = useMemo(
428
+ () =>
429
+ loadError
430
+ ? createErrorSnapshot(documentId, loadError)
431
+ : createLoadingSnapshot(
432
+ documentId,
433
+ readOnly,
434
+ guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
435
+ ),
436
+ [
437
+ documentId,
438
+ externalDocSource,
439
+ initialSnapshot,
440
+ initialSourceLabel,
441
+ loadError,
442
+ readOnly,
443
+ ],
444
+ );
445
+
446
+ const snapshot = useSyncExternalStore(
447
+ (listener) => runtime?.subscribe(listener) ?? (() => undefined),
448
+ () => runtime?.getRenderSnapshot() ?? fallbackSnapshot,
449
+ () => runtime?.getRenderSnapshot() ?? fallbackSnapshot,
450
+ );
451
+
452
+ const activeRuntime = runtime ?? optimisticRuntime;
453
+
454
+ useImperativeHandle(
455
+ ref,
456
+ () => ({
457
+ focus: () => activeRuntime.focus(),
458
+ blur: () => activeRuntime.blur(),
459
+ undo: () => activeRuntime.undo(),
460
+ redo: () => activeRuntime.redo(),
461
+ addComment: (params) =>
462
+ activeRuntime.addComment({
463
+ ...params,
464
+ authorId: params.authorId ?? currentUser.userId,
465
+ }),
466
+ openComment: (commentId) => activeRuntime.openComment(commentId),
467
+ resolveComment: (commentId) => activeRuntime.resolveComment(commentId),
468
+ reopenComment: (commentId) => activeRuntime.reopenComment(commentId),
469
+ addCommentReply: (commentId, body) =>
470
+ activeRuntime.addCommentReply(commentId, body, currentUser.userId),
471
+ editCommentBody: (commentId, body) =>
472
+ activeRuntime.editCommentBody(commentId, body),
473
+ acceptChange: (changeId) => activeRuntime.acceptChange(changeId),
474
+ rejectChange: (changeId) => activeRuntime.rejectChange(changeId),
475
+ acceptAllChanges: () => activeRuntime.acceptAllChanges(),
476
+ rejectAllChanges: () => activeRuntime.rejectAllChanges(),
477
+ exportDocx: (options) =>
478
+ runtime
479
+ ? persistAndExport({
480
+ datastore: datastoreRef.current,
481
+ documentId,
482
+ runtime,
483
+ onError: onErrorRef.current,
484
+ onEvent: onEventRef.current,
485
+ options,
486
+ lastSavedRevisionTokenRef,
487
+ autosaveTimerRef,
488
+ })
489
+ : rejectExportWhileLoading({
490
+ documentId,
491
+ datastore: datastoreRef.current,
492
+ onError: onErrorRef.current,
493
+ onEvent: onEventRef.current,
494
+ }),
495
+ getSnapshot: () => activeRuntime.getPersistedSnapshot(),
496
+ getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
497
+ getWarnings: () => activeRuntime.getWarnings(),
498
+ }),
499
+ [activeRuntime, currentUser.userId, documentId, runtime],
500
+ );
501
+
502
+ useEffect(() => {
503
+ if (!datastoreRef.current || props.autosave?.enabled === false || !runtime || readOnly) {
504
+ if (autosaveTimerRef.current) {
505
+ clearTimeout(autosaveTimerRef.current);
506
+ autosaveTimerRef.current = null;
507
+ }
508
+ return;
509
+ }
510
+
511
+ if (!snapshot.isReady || !snapshot.isDirty) {
512
+ return;
513
+ }
514
+
515
+ if (lastSavedRevisionTokenRef.current === snapshot.revisionToken) {
516
+ return;
517
+ }
518
+
519
+ if (autosaveTimerRef.current) {
520
+ clearTimeout(autosaveTimerRef.current);
521
+ }
522
+
523
+ const debounceMs = props.autosave?.debounceMs ?? 800;
524
+ if (debounceMs <= 0) {
525
+ void persistSnapshot({
526
+ datastore: datastoreRef.current,
527
+ documentId,
528
+ runtime,
529
+ isAutosave: true,
530
+ onError: onErrorRef.current,
531
+ onEvent: onEventRef.current,
532
+ lastSavedRevisionTokenRef,
533
+ });
534
+ return;
535
+ }
536
+
537
+ autosaveTimerRef.current = setTimeout(() => {
538
+ void persistSnapshot({
539
+ datastore: datastoreRef.current,
540
+ documentId,
541
+ runtime,
542
+ isAutosave: true,
543
+ onError: onErrorRef.current,
544
+ onEvent: onEventRef.current,
545
+ lastSavedRevisionTokenRef,
546
+ });
547
+ }, debounceMs);
548
+
549
+ return () => {
550
+ if (autosaveTimerRef.current) {
551
+ clearTimeout(autosaveTimerRef.current);
552
+ autosaveTimerRef.current = null;
553
+ }
554
+ };
555
+ }, [
556
+ documentId,
557
+ props.autosave?.debounceMs,
558
+ props.autosave?.enabled,
559
+ readOnly,
560
+ runtime,
561
+ snapshot.isDirty,
562
+ snapshot.isReady,
563
+ snapshot.revisionToken,
564
+ ]);
565
+
566
+ // Auto-select the most relevant rail tab based on content.
567
+ // Health tab was moved to toolbar popover — rail has only comments/changes.
568
+ useEffect(() => {
569
+ if (
570
+ activeRailTab === "comments" &&
571
+ snapshot.comments.totalCount === 0 &&
572
+ snapshot.trackedChanges.totalCount > 0
573
+ ) {
574
+ setActiveRailTab("changes");
575
+ return;
576
+ }
577
+ }, [
578
+ activeRailTab,
579
+ loadError,
580
+ snapshot.comments.totalCount,
581
+ snapshot.compatibility.blockExport,
582
+ snapshot.fatalError,
583
+ snapshot.trackedChanges.totalCount,
584
+ ]);
585
+
586
+ function focusAnchor(anchor: PublicSelectionSnapshot["activeRange"]): void {
587
+ if (anchor.kind === "detached") {
588
+ return;
589
+ }
590
+
591
+ activeRuntime.dispatch({
592
+ type: "selection.set",
593
+ selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(anchor)),
594
+ });
595
+ }
596
+
597
+ function addReviewComment(): void {
598
+ activeRuntime.addComment({
599
+ anchor: snapshot.selection.activeRange,
600
+ body: "New review comment",
601
+ authorId: currentUser.userId,
602
+ });
603
+ setActiveRailTab("comments");
604
+ }
605
+
606
+ function exportCurrentDocument(): void {
607
+ void (runtime
608
+ ? persistAndExport({
609
+ datastore: datastoreRef.current,
610
+ documentId,
611
+ runtime,
612
+ onError: onErrorRef.current,
613
+ onEvent: onEventRef.current,
614
+ lastSavedRevisionTokenRef,
615
+ autosaveTimerRef,
616
+ })
617
+ : rejectExportWhileLoading({
618
+ documentId,
619
+ datastore: datastoreRef.current,
620
+ onError: onErrorRef.current,
621
+ onEvent: onEventRef.current,
622
+ }));
623
+ }
624
+
625
+ const selectionPreview = summarizeSelectionPreview(snapshot);
626
+ const capabilities = deriveCapabilities(snapshot, reviewMode);
627
+ const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
628
+ const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
629
+ const accessibilityStatusId = `${documentId}-accessibility-status`;
630
+ const accessibilityAlertId = `${documentId}-accessibility-alert`;
631
+
632
+ const dispatchSelection = (selection: PublicSelectionSnapshot) =>
633
+ activeRuntime.dispatch({
634
+ type: "selection.set",
635
+ selection: toRuntimeSelectionSnapshot(selection),
636
+ });
637
+
638
+ useEffect(() => {
639
+ const shell = shellRef.current;
640
+ if (!shell) {
641
+ return;
642
+ }
643
+
644
+ applyRegionAttributes(shell);
645
+ }, [runtime, snapshot.fatalError, snapshot.isReady]);
646
+
647
+ useEffect(() => {
648
+ const shell = shellRef.current;
649
+ const fatalError = loadError ?? snapshot.fatalError;
650
+ if (!shell || !fatalError) {
651
+ lastAnnouncedErrorIdRef.current = null;
652
+ return;
653
+ }
654
+
655
+ if (lastAnnouncedErrorIdRef.current === fatalError.errorId) {
656
+ return;
657
+ }
658
+
659
+ lastAnnouncedErrorIdRef.current = fatalError.errorId;
660
+ const alertTarget = shell.querySelector<HTMLElement>("[data-wre-alert='true']");
661
+ alertTarget?.focus();
662
+ }, [loadError, snapshot.fatalError]);
663
+
664
+ function handleShellKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>): void {
665
+ if (event.key !== "F6") {
666
+ return;
667
+ }
668
+
669
+ const shell = shellRef.current;
670
+ if (!shell) {
671
+ return;
672
+ }
673
+
674
+ event.preventDefault();
675
+ focusRelativeRegion(shell, event.shiftKey ? -1 : 1);
676
+ }
677
+
678
+ const editorCallbacks = {
679
+ onFocus: () => activeRuntime.focus(),
680
+ onBlur: () => activeRuntime.blur(),
681
+ onSelectionChange: dispatchSelection,
682
+ onInsertText: (text: string) => activeRuntime.dispatch({ type: "text.insert", text }),
683
+ onDeleteBackward: () => activeRuntime.dispatch({ type: "text.delete-backward" }),
684
+ onDeleteForward: () => activeRuntime.dispatch({ type: "text.delete-forward" }),
685
+ onInsertTab: () => activeRuntime.dispatch({ type: "text.insert-tab" }),
686
+ onInsertHardBreak: () => activeRuntime.dispatch({ type: "text.insert-hard-break" }),
687
+ onSplitParagraph: () => activeRuntime.dispatch({ type: "paragraph.split" }),
688
+ };
689
+
690
+ const reviewCallbacks = {
691
+ onUndo: () => activeRuntime.undo(),
692
+ onRedo: () => activeRuntime.redo(),
693
+ onAddComment: addReviewComment,
694
+ onExport: exportCurrentDocument,
695
+ onOpenComment: (thread: typeof snapshot.comments.threads[number]) => {
696
+ activeRuntime.openComment(thread.commentId);
697
+ focusAnchor(thread.anchor);
698
+ setActiveRailTab("comments");
699
+ },
700
+ onResolveComment: (commentId: string) => {
701
+ activeRuntime.resolveComment(commentId);
702
+ setActiveRailTab("comments");
703
+ },
704
+ onReopenComment: (commentId: string) => {
705
+ activeRuntime.reopenComment(commentId);
706
+ setActiveRailTab("comments");
707
+ },
708
+ onAddReply: (commentId: string, body: string) => {
709
+ activeRuntime.addCommentReply(commentId, body, currentUser.userId);
710
+ },
711
+ onEditBody: (commentId: string, body: string) => {
712
+ activeRuntime.editCommentBody(commentId, body);
713
+ },
714
+ onOpenRevision: (revision: typeof snapshot.trackedChanges.revisions[number]) => {
715
+ setActiveRevisionId(revision.revisionId);
716
+ focusAnchor(revision.anchor);
717
+ setActiveRailTab("changes");
718
+ },
719
+ onAcceptRevision: (revisionId: string) => {
720
+ activeRuntime.acceptChange(revisionId);
721
+ setActiveRailTab("changes");
722
+ },
723
+ onRejectRevision: (revisionId: string) => {
724
+ activeRuntime.rejectChange(revisionId);
725
+ setActiveRailTab("changes");
726
+ },
727
+ onAcceptAllChanges: () => {
728
+ activeRuntime.acceptAllChanges();
729
+ setActiveRailTab("changes");
730
+ },
731
+ onRejectAllChanges: () => {
732
+ activeRuntime.rejectAllChanges();
733
+ setActiveRailTab("changes");
734
+ },
735
+ };
736
+
737
+ return (
738
+ <div
739
+ ref={shellRef}
740
+ role="region"
741
+ aria-label={`Word review editor for ${snapshot.sourceLabel ?? documentId}`}
742
+ aria-describedby={`${accessibilityInstructionsId} ${accessibilityStatusId}${
743
+ diagnosticsModeMessage ? ` ${accessibilityAlertId}` : ""
744
+ }`}
745
+ className="relative h-full"
746
+ onKeyDownCapture={handleShellKeyDownCapture}
747
+ >
748
+ <p id={accessibilityInstructionsId} style={VISUALLY_HIDDEN_STYLES}>
749
+ Press F6 to move focus between the toolbar, document surface, review rail, and status bar.
750
+ </p>
751
+ <div
752
+ id={accessibilityStatusId}
753
+ role="status"
754
+ aria-live="polite"
755
+ aria-atomic="true"
756
+ style={VISUALLY_HIDDEN_STYLES}
757
+ >
758
+ {buildAccessibilityStatusMessage(snapshot, loadError ?? undefined)}
759
+ </div>
760
+ {diagnosticsModeMessage ? (
761
+ <div
762
+ id={accessibilityAlertId}
763
+ data-wre-alert="true"
764
+ role="alert"
765
+ aria-live="assertive"
766
+ aria-atomic="true"
767
+ tabIndex={-1}
768
+ className="border-b border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger"
769
+ >
770
+ {diagnosticsModeMessage}
771
+ </div>
772
+ ) : null}
773
+ <TwReviewWorkspace
774
+ snapshot={snapshot}
775
+ currentUserId={currentUser.userId}
776
+ capabilities={capabilities}
777
+ reviewMode={reviewMode}
778
+ viewMode={viewMode}
779
+ activeRailTab={activeRailTab}
780
+ activeCommentId={snapshot.comments.activeCommentId}
781
+ activeRevisionId={activeRevisionId}
782
+ showTrackedChanges={showTrackedChanges}
783
+ selectionPreview={selectionPreview}
784
+ onViewModeChange={setViewMode}
785
+ onActiveRailTabChange={setActiveRailTab}
786
+ onShowTrackedChangesChange={setShowTrackedChanges}
787
+ {...reviewCallbacks}
788
+ document={
789
+ <TwProseMirrorSurface
790
+ currentUser={currentUser}
791
+ snapshot={snapshot}
792
+ reviewMode={reviewMode}
793
+ markupDisplay={liveMarkupDisplay}
794
+ activeRevisionId={activeRevisionId}
795
+ showTrackedChanges={showTrackedChanges}
796
+ {...editorCallbacks}
797
+ onCommentActivated={(commentId) => {
798
+ activeRuntime.openComment(commentId);
799
+ setActiveRailTab("comments");
800
+ }}
801
+ onRevisionActivated={(revisionId) => {
802
+ setActiveRevisionId(revisionId);
803
+ setActiveRailTab("changes");
804
+ }}
805
+ />
806
+ }
807
+ />
808
+ </div>
809
+ );
810
+ },
811
+ );
812
+
813
+ function applyRegionAttributes(shell: HTMLElement): void {
814
+ const toolbar = shell.querySelector<HTMLElement>("header");
815
+ if (toolbar) {
816
+ toolbar.dataset.wreRegion = "toolbar";
817
+ toolbar.setAttribute("role", toolbar.getAttribute("role") ?? "toolbar");
818
+ toolbar.setAttribute("aria-label", toolbar.getAttribute("aria-label") ?? "Editor toolbar");
819
+ toolbar.tabIndex = -1;
820
+ }
821
+
822
+ const documentSurface = shell.querySelector<HTMLElement>('[aria-label="Document surface"]');
823
+ if (documentSurface) {
824
+ documentSurface.dataset.wreRegion = "document";
825
+ }
826
+
827
+ const reviewRail = shell.querySelector<HTMLElement>('[aria-label="Review rail"]');
828
+ if (reviewRail) {
829
+ reviewRail.dataset.wreRegion = "review-rail";
830
+ reviewRail.tabIndex = -1;
831
+ }
832
+
833
+ const statusBar = shell.querySelector<HTMLElement>("footer");
834
+ if (statusBar) {
835
+ statusBar.dataset.wreRegion = "status";
836
+ statusBar.setAttribute("role", statusBar.getAttribute("role") ?? "status");
837
+ statusBar.setAttribute("aria-label", statusBar.getAttribute("aria-label") ?? "Editor status bar");
838
+ statusBar.tabIndex = -1;
839
+ }
840
+ }
841
+
842
+ function focusRelativeRegion(shell: HTMLElement, direction: -1 | 1): void {
843
+ const activeRegion = getActiveRegionId(shell);
844
+ const startIndex =
845
+ activeRegion === null ? (direction === 1 ? -1 : 0) : ACCESSIBLE_REGION_ORDER.indexOf(activeRegion);
846
+
847
+ for (let offset = 1; offset <= ACCESSIBLE_REGION_ORDER.length; offset += 1) {
848
+ const index =
849
+ (startIndex + direction * offset + ACCESSIBLE_REGION_ORDER.length) %
850
+ ACCESSIBLE_REGION_ORDER.length;
851
+ const regionId = ACCESSIBLE_REGION_ORDER[index];
852
+ const target = findRegionFocusTarget(shell, regionId);
853
+ if (target) {
854
+ target.focus();
855
+ return;
856
+ }
857
+ }
858
+ }
859
+
860
+ function getActiveRegionId(shell: HTMLElement): AccessibleRegionId | null {
861
+ const activeElement = shell.ownerDocument.activeElement;
862
+ if (!(activeElement instanceof HTMLElement)) {
863
+ return null;
864
+ }
865
+
866
+ const region = activeElement.closest<HTMLElement>("[data-wre-region]");
867
+ const regionId = region?.dataset.wreRegion;
868
+ return isAccessibleRegionId(regionId) ? regionId : null;
869
+ }
870
+
871
+ function findRegionFocusTarget(
872
+ shell: HTMLElement,
873
+ regionId: AccessibleRegionId,
874
+ ): HTMLElement | null {
875
+ const region = shell.querySelector<HTMLElement>(`[data-wre-region="${regionId}"]`);
876
+ if (!region) {
877
+ return null;
878
+ }
879
+
880
+ if (regionId === "document" || regionId === "toolbar" || regionId === "review-rail" || regionId === "status") {
881
+ return region;
882
+ }
883
+ }
884
+
885
+ function isAccessibleRegionId(value: string | undefined): value is AccessibleRegionId {
886
+ return value === "toolbar" || value === "document" || value === "review-rail" || value === "status";
887
+ }
888
+
889
+ function buildAccessibilityStatusMessage(
890
+ snapshot: RuntimeRenderSnapshot,
891
+ loadError?: EditorError,
892
+ ): string {
893
+ if (loadError) {
894
+ return `Editor failed to load. ${loadError.message}`;
895
+ }
896
+
897
+ if (!snapshot.isReady) {
898
+ return "Editor loading. Document surface is not ready yet.";
899
+ }
900
+
901
+ if (snapshot.fatalError) {
902
+ return `Editor opened in diagnostics mode. ${snapshot.fatalError.message}`;
903
+ }
904
+
905
+ return [
906
+ snapshot.readOnly ? "Read-only mode." : "Editing enabled.",
907
+ `${snapshot.comments.totalCount} comment${snapshot.comments.totalCount === 1 ? "" : "s"}.`,
908
+ `${snapshot.trackedChanges.totalCount} tracked change${
909
+ snapshot.trackedChanges.totalCount === 1 ? "" : "s"
910
+ }.`,
911
+ snapshot.compatibility.blockExport ? "Export blocked." : "Export available.",
912
+ ].join(" ");
913
+ }
914
+
915
+ function getDiagnosticsModeMessage(error?: EditorError | null): string | null {
916
+ if (!error) {
917
+ return null;
918
+ }
919
+
920
+ if (error.code === "package_corrupt") {
921
+ return `${error.message} The document opened in read-only diagnostics mode so you can inspect the issue safely, but editing and export stay blocked until you reload a valid .docx package.`;
922
+ }
923
+
924
+ if (error.code === "validation_failed") {
925
+ return `${error.message} The document opened in read-only diagnostics mode because OOXML validation failed, so editing and export stay blocked until the source package is repaired.`;
926
+ }
927
+
928
+ return error.isFatal ? error.message : null;
929
+ }
930
+
931
+ function normalizeEditorError(error: unknown): EditorError {
932
+ if (
933
+ typeof error === "object" &&
934
+ error !== null &&
935
+ "errorId" in error &&
936
+ "code" in error &&
937
+ "message" in error
938
+ ) {
939
+ return error as EditorError;
940
+ }
941
+
942
+ return {
943
+ errorId: "word-review-editor-load",
944
+ code: "internal_invariant",
945
+ message: error instanceof Error ? error.message : "Unknown editor load failure.",
946
+ isFatal: true,
947
+ source: "runtime",
948
+ };
949
+ }
950
+
951
+ function guessSourceLabel(
952
+ initialSourceLabel?: string,
953
+ initialSnapshot?: PersistedEditorSnapshot,
954
+ externalDocSource?: WordReviewEditorProps["externalDocSource"],
955
+ ): string | undefined {
956
+ return (
957
+ externalDocSource?.sourceLabel ??
958
+ initialSourceLabel ??
959
+ initialSnapshot?.editorBuild ??
960
+ undefined
961
+ );
962
+ }
963
+
964
+ function createLoadingSnapshot(
965
+ documentId: string,
966
+ readOnly: boolean,
967
+ sourceLabel?: string,
968
+ ): RuntimeRenderSnapshot {
969
+ return {
970
+ documentId,
971
+ sessionId: `${documentId}-loading`,
972
+ sourceLabel,
973
+ revisionToken: `${documentId}:loading`,
974
+ isReady: false,
975
+ isDirty: false,
976
+ readOnly,
977
+ selection: collapsedSelection(),
978
+ documentStats: {
979
+ storyLength: 0,
980
+ commentCount: 0,
981
+ revisionCount: 0,
982
+ opaqueFragmentCount: 0,
983
+ },
984
+ comments: {
985
+ openCommentIds: [],
986
+ resolvedCommentIds: [],
987
+ detachedCommentIds: [],
988
+ totalCount: 0,
989
+ threads: [],
990
+ },
991
+ trackedChanges: {
992
+ pendingChangeIds: [],
993
+ acceptedChangeIds: [],
994
+ rejectedChangeIds: [],
995
+ detachedChangeIds: [],
996
+ actionableChangeIds: [],
997
+ preserveOnlyChangeIds: [],
998
+ totalCount: 0,
999
+ revisions: [],
1000
+ },
1001
+ compatibility: {
1002
+ blockExport: false,
1003
+ blockExportReasons: [],
1004
+ warningCount: 0,
1005
+ errorCount: 0,
1006
+ featureEntries: [],
1007
+ },
1008
+ warnings: [],
1009
+ commandState: {
1010
+ canUndo: false,
1011
+ canRedo: false,
1012
+ readOnly,
1013
+ },
1014
+ };
1015
+ }
1016
+
1017
+ function createErrorSnapshot(documentId: string, error: EditorError): RuntimeRenderSnapshot {
1018
+ return {
1019
+ ...createLoadingSnapshot(documentId, true),
1020
+ isReady: true,
1021
+ sessionId: `${documentId}-error`,
1022
+ revisionToken: `${documentId}:error`,
1023
+ compatibility: {
1024
+ blockExport: true,
1025
+ blockExportReasons: [error.message],
1026
+ warningCount: 0,
1027
+ errorCount: 1,
1028
+ featureEntries: [],
1029
+ },
1030
+ fatalError: error,
1031
+ };
1032
+ }
1033
+
1034
+ async function persistAndExport(input: {
1035
+ datastore?: EditorDatastoreAdapter;
1036
+ documentId: string;
1037
+ runtime: WordReviewEditorRuntime;
1038
+ onError?: (error: EditorError) => void;
1039
+ onEvent?: (event: WordReviewEditorEvent) => void;
1040
+ options?: ExportDocxOptions;
1041
+ lastSavedRevisionTokenRef: React.MutableRefObject<string | null>;
1042
+ autosaveTimerRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
1043
+ }): Promise<ExportResult> {
1044
+ if (input.autosaveTimerRef.current) {
1045
+ clearTimeout(input.autosaveTimerRef.current);
1046
+ input.autosaveTimerRef.current = null;
1047
+ }
1048
+
1049
+ await persistSnapshot({
1050
+ datastore: input.datastore,
1051
+ documentId: input.documentId,
1052
+ runtime: input.runtime,
1053
+ isAutosave: false,
1054
+ onError: input.onError,
1055
+ onEvent: input.onEvent,
1056
+ lastSavedRevisionTokenRef: input.lastSavedRevisionTokenRef,
1057
+ });
1058
+
1059
+ const result = await input.runtime.exportDocx(input.options);
1060
+
1061
+ if (!input.datastore) {
1062
+ return result;
1063
+ }
1064
+
1065
+ try {
1066
+ await input.datastore.saveExport({
1067
+ documentId: input.documentId,
1068
+ result,
1069
+ });
1070
+ } catch (error) {
1071
+ const normalized = normalizeDatastoreError(error, {
1072
+ message: "Export persisted bytes could not be stored.",
1073
+ details: {
1074
+ operation: "saveExport",
1075
+ },
1076
+ });
1077
+ input.onError?.(normalized);
1078
+ emitEditorEvent({
1079
+ datastore: input.datastore,
1080
+ onEvent: input.onEvent,
1081
+ event: {
1082
+ type: "error",
1083
+ documentId: input.documentId,
1084
+ error: normalized,
1085
+ },
1086
+ });
1087
+ }
1088
+
1089
+ return result;
1090
+ }
1091
+
1092
+ function rejectExportWhileLoading(input: {
1093
+ documentId: string;
1094
+ datastore?: EditorDatastoreAdapter;
1095
+ onError?: (error: EditorError) => void;
1096
+ onEvent?: (event: WordReviewEditorEvent) => void;
1097
+ }): Promise<never> {
1098
+ const error: EditorError = {
1099
+ errorId: "word-review-editor-loading-export",
1100
+ code: "internal_invariant",
1101
+ message: "WordReviewEditor is still loading and cannot export yet.",
1102
+ isFatal: false,
1103
+ source: "runtime",
1104
+ };
1105
+ input.onError?.(error);
1106
+ emitEditorEvent({
1107
+ datastore: input.datastore,
1108
+ onEvent: input.onEvent,
1109
+ event: {
1110
+ type: "error",
1111
+ documentId: input.documentId,
1112
+ error,
1113
+ },
1114
+ });
1115
+ return Promise.reject(error);
1116
+ }
1117
+
1118
+ async function persistSnapshot(input: {
1119
+ datastore?: EditorDatastoreAdapter;
1120
+ documentId: string;
1121
+ runtime: WordReviewEditorRuntime;
1122
+ isAutosave: boolean;
1123
+ onError?: (error: EditorError) => void;
1124
+ onEvent?: (event: WordReviewEditorEvent) => void;
1125
+ lastSavedRevisionTokenRef: React.MutableRefObject<string | null>;
1126
+ }): Promise<void> {
1127
+ if (!input.datastore) {
1128
+ return;
1129
+ }
1130
+
1131
+ const snapshot = input.runtime.getPersistedSnapshot();
1132
+ const revisionToken = input.runtime.getRenderSnapshot().revisionToken;
1133
+
1134
+ if (input.isAutosave) {
1135
+ emitEditorEvent({
1136
+ datastore: input.datastore,
1137
+ onEvent: input.onEvent,
1138
+ event: {
1139
+ type: "autosave_state",
1140
+ documentId: input.documentId,
1141
+ state: {
1142
+ status: "saving",
1143
+ } satisfies AutosaveState,
1144
+ },
1145
+ });
1146
+ }
1147
+
1148
+ try {
1149
+ const result = await input.datastore.saveSnapshot({
1150
+ documentId: input.documentId,
1151
+ snapshot,
1152
+ isAutosave: input.isAutosave,
1153
+ });
1154
+ const savedSnapshot: PersistedEditorSnapshot = {
1155
+ ...snapshot,
1156
+ savedAt: result.savedAt,
1157
+ };
1158
+ input.lastSavedRevisionTokenRef.current = revisionToken;
1159
+ emitEditorEvent({
1160
+ datastore: input.datastore,
1161
+ onEvent: input.onEvent,
1162
+ event: {
1163
+ type: "snapshot_saved",
1164
+ documentId: input.documentId,
1165
+ snapshot: savedSnapshot,
1166
+ isAutosave: input.isAutosave,
1167
+ },
1168
+ });
1169
+ if (input.isAutosave) {
1170
+ emitEditorEvent({
1171
+ datastore: input.datastore,
1172
+ onEvent: input.onEvent,
1173
+ event: {
1174
+ type: "autosave_state",
1175
+ documentId: input.documentId,
1176
+ state: {
1177
+ status: "saved",
1178
+ savedAt: result.savedAt,
1179
+ } satisfies AutosaveState,
1180
+ },
1181
+ });
1182
+ }
1183
+ } catch (error) {
1184
+ const normalized = normalizeDatastoreError(error, {
1185
+ message: input.isAutosave
1186
+ ? "Autosave failed while storing the editor snapshot."
1187
+ : "Snapshot save failed while preparing the export checkpoint.",
1188
+ details: {
1189
+ operation: "saveSnapshot",
1190
+ isAutosave: input.isAutosave,
1191
+ },
1192
+ });
1193
+ input.onError?.(normalized);
1194
+ emitEditorEvent({
1195
+ datastore: input.datastore,
1196
+ onEvent: input.onEvent,
1197
+ event: {
1198
+ type: "error",
1199
+ documentId: input.documentId,
1200
+ error: normalized,
1201
+ },
1202
+ });
1203
+ if (input.isAutosave) {
1204
+ emitEditorEvent({
1205
+ datastore: input.datastore,
1206
+ onEvent: input.onEvent,
1207
+ event: {
1208
+ type: "autosave_state",
1209
+ documentId: input.documentId,
1210
+ state: {
1211
+ status: "error",
1212
+ error: normalized,
1213
+ } satisfies AutosaveState,
1214
+ },
1215
+ });
1216
+ }
1217
+ if (!input.isAutosave) {
1218
+ throw normalized;
1219
+ }
1220
+ }
1221
+ }
1222
+
1223
+ function emitEditorEvent(input: {
1224
+ datastore?: EditorDatastoreAdapter;
1225
+ onEvent?: (event: WordReviewEditorEvent) => void;
1226
+ event: WordReviewEditorEvent;
1227
+ }): void {
1228
+ input.onEvent?.(input.event);
1229
+ input.datastore?.logEvent?.({
1230
+ type: input.event.type,
1231
+ documentId: input.event.documentId,
1232
+ detail: summarizeEventDetail(input.event),
1233
+ });
1234
+ }
1235
+
1236
+ function summarizeEventDetail(
1237
+ event: WordReviewEditorEvent,
1238
+ ): Record<string, unknown> | undefined {
1239
+ switch (event.type) {
1240
+ case "dirty_changed":
1241
+ return { isDirty: event.isDirty };
1242
+ case "comment_added":
1243
+ return { commentId: event.commentId };
1244
+ case "comment_resolved":
1245
+ return { commentId: event.commentId };
1246
+ case "change_accepted":
1247
+ case "change_rejected":
1248
+ return { changeId: event.changeId };
1249
+ case "warning_added":
1250
+ return { warningId: event.warning.warningId, code: event.warning.code };
1251
+ case "warning_cleared":
1252
+ return { warningId: event.warningId, code: event.code };
1253
+ case "error":
1254
+ return { errorId: event.error.errorId, code: event.error.code };
1255
+ case "autosave_state":
1256
+ return { status: event.state.status };
1257
+ case "snapshot_saved":
1258
+ return { isAutosave: event.isAutosave, savedAt: event.snapshot.savedAt };
1259
+ case "export_completed":
1260
+ return { fileName: event.result.fileName };
1261
+ case "selection_changed":
1262
+ return {
1263
+ anchor: event.selection.anchor,
1264
+ head: event.selection.head,
1265
+ };
1266
+ case "ready":
1267
+ return {
1268
+ source: event.source,
1269
+ blockExport: event.compatibility.blockExport,
1270
+ };
1271
+ }
1272
+ }
1273
+
1274
+ function createReadyEvent(
1275
+ runtime: Pick<WordReviewEditorRuntime, "getCompatibilityReport" | "getRenderSnapshot">,
1276
+ source: "docx" | "snapshot" | "datastore" | "canonical",
1277
+ ): Extract<WordReviewEditorEvent, { type: "ready" }> {
1278
+ const snapshot = runtime.getRenderSnapshot();
1279
+ return {
1280
+ type: "ready",
1281
+ documentId: snapshot.documentId,
1282
+ sessionId: snapshot.sessionId,
1283
+ source,
1284
+ stats: snapshot.documentStats,
1285
+ compatibility: runtime.getCompatibilityReport(),
1286
+ };
1287
+ }
1288
+
1289
+ function normalizeDatastoreError(
1290
+ error: unknown,
1291
+ fallback: {
1292
+ message: string;
1293
+ details?: Record<string, unknown>;
1294
+ },
1295
+ ): EditorError {
1296
+ if (
1297
+ typeof error === "object" &&
1298
+ error !== null &&
1299
+ "errorId" in error &&
1300
+ "code" in error &&
1301
+ "message" in error
1302
+ ) {
1303
+ return error as EditorError;
1304
+ }
1305
+
1306
+ return {
1307
+ errorId: "word-review-editor-datastore",
1308
+ code: "datastore_failed",
1309
+ message: error instanceof Error ? error.message : fallback.message,
1310
+ isFatal: false,
1311
+ source: "datastore",
1312
+ details: fallback.details,
1313
+ };
1314
+ }
1315
+
1316
+ function createFallbackSnapshot(args: CreateRuntimeArgs): RuntimeRenderSnapshot {
1317
+ const warnings = args.source.initialSnapshot?.warningLog ?? [];
1318
+ const compatibility = args.source.initialSnapshot?.compatibility ?? emptyCompatibilityReport();
1319
+
1320
+ return {
1321
+ ...createLoadingSnapshot(args.documentId, args.readOnly, args.source.sourceLabel),
1322
+ sessionId: `${args.documentId}-session`,
1323
+ revisionToken: `${args.documentId}:0`,
1324
+ isReady: true,
1325
+ documentStats: {
1326
+ storyLength: estimateStoryLength(args.source.initialSnapshot),
1327
+ commentCount: 0,
1328
+ revisionCount: 0,
1329
+ opaqueFragmentCount: 0,
1330
+ },
1331
+ compatibility: {
1332
+ blockExport: compatibility.blockExport,
1333
+ blockExportReasons: [],
1334
+ warningCount: compatibility.warnings.length,
1335
+ errorCount: compatibility.errors.length,
1336
+ featureEntries: compatibility.featureEntries,
1337
+ },
1338
+ warnings,
1339
+ };
1340
+ }
1341
+
1342
+ function createFallbackPersistedSnapshot(
1343
+ documentId: string,
1344
+ label = "Generated shell snapshot",
1345
+ ): PersistedEditorSnapshot {
1346
+ const docId = createCanonicalDocumentId(documentId);
1347
+ return {
1348
+ snapshotVersion: "persisted-editor-snapshot/1",
1349
+ schemaVersion: "cds/1.0.0",
1350
+ documentId,
1351
+ docId,
1352
+ createdAt: "1970-01-01T00:00:00.000Z",
1353
+ updatedAt: "1970-01-01T00:00:00.000Z",
1354
+ savedAt: "1970-01-01T00:00:00.000Z",
1355
+ editorBuild: label,
1356
+ canonicalDocument: {
1357
+ schemaVersion: "cds/1.0.0",
1358
+ docId,
1359
+ createdAt: "1970-01-01T00:00:00.000Z",
1360
+ updatedAt: "1970-01-01T00:00:00.000Z",
1361
+ metadata: {
1362
+ customProperties: {},
1363
+ },
1364
+ styles: {
1365
+ paragraphs: {},
1366
+ characters: {},
1367
+ tables: {},
1368
+ },
1369
+ numbering: {
1370
+ abstractDefinitions: {},
1371
+ instances: {},
1372
+ },
1373
+ media: {
1374
+ items: {},
1375
+ },
1376
+ content: {
1377
+ type: "doc",
1378
+ children: [{ type: "paragraph", children: [] }],
1379
+ },
1380
+ review: {
1381
+ comments: {},
1382
+ revisions: {},
1383
+ },
1384
+ preservation: {
1385
+ opaqueFragments: {},
1386
+ packageParts: {},
1387
+ },
1388
+ diagnostics: {
1389
+ warnings: [],
1390
+ errors: [],
1391
+ },
1392
+ },
1393
+ compatibility: emptyCompatibilityReport(),
1394
+ warningLog: [],
1395
+ };
1396
+ }
1397
+
1398
+ function emptyCompatibilityReport(): CompatibilityReport {
1399
+ return {
1400
+ reportVersion: "compatibility-report/1",
1401
+ generatedAt: "1970-01-01T00:00:00.000Z",
1402
+ blockExport: false,
1403
+ featureEntries: [],
1404
+ warnings: [],
1405
+ errors: [],
1406
+ };
1407
+ }
1408
+
1409
+ function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
1410
+ return {
1411
+ anchor: selection.anchor,
1412
+ head: selection.head,
1413
+ isCollapsed: selection.isCollapsed,
1414
+ activeRange:
1415
+ selection.activeRange.kind === "range"
1416
+ ? createRangeAnchor(
1417
+ selection.activeRange.from,
1418
+ selection.activeRange.to,
1419
+ selection.activeRange.assoc,
1420
+ )
1421
+ : selection.activeRange.kind === "node"
1422
+ ? createNodeAnchor(selection.activeRange.at, selection.activeRange.assoc)
1423
+ : createDetachedAnchor(
1424
+ selection.activeRange.lastKnownRange,
1425
+ selection.activeRange.reason,
1426
+ ),
1427
+ };
1428
+ }
1429
+
1430
+ function createSelectionFromAnchor(
1431
+ anchor: PublicSelectionSnapshot["activeRange"],
1432
+ ): PublicSelectionSnapshot {
1433
+ switch (anchor.kind) {
1434
+ case "range":
1435
+ return {
1436
+ anchor: anchor.from,
1437
+ head: anchor.to,
1438
+ isCollapsed: anchor.from === anchor.to,
1439
+ activeRange: anchor,
1440
+ };
1441
+ case "node":
1442
+ return {
1443
+ anchor: anchor.at,
1444
+ head: anchor.at,
1445
+ isCollapsed: true,
1446
+ activeRange: anchor,
1447
+ };
1448
+ case "detached":
1449
+ return {
1450
+ anchor: anchor.lastKnownRange.from,
1451
+ head: anchor.lastKnownRange.to,
1452
+ isCollapsed: anchor.lastKnownRange.from === anchor.lastKnownRange.to,
1453
+ activeRange: anchor,
1454
+ };
1455
+ }
1456
+ }
1457
+
1458
+ function estimateStoryLength(snapshot?: PersistedEditorSnapshot): number {
1459
+ const content = snapshot?.canonicalDocument.content;
1460
+ return Array.isArray(content) ? content.length : 0;
1461
+ }
1462
+
1463
+ function collapsedSelection(): RuntimeRenderSnapshot["selection"] {
1464
+ return {
1465
+ anchor: 0,
1466
+ head: 0,
1467
+ isCollapsed: true,
1468
+ activeRange: {
1469
+ kind: "range",
1470
+ from: 0,
1471
+ to: 0,
1472
+ assoc: {
1473
+ start: -1,
1474
+ end: 1,
1475
+ },
1476
+ },
1477
+ };
1478
+ }
1479
+
1480
+ function toUint8Array(bytes: Uint8Array | ArrayBuffer): Uint8Array {
1481
+ return bytes instanceof Uint8Array ? new Uint8Array(bytes) : new Uint8Array(bytes);
1482
+ }
1483
+
1484
+ function summarizeSelectionPreview(snapshot: RuntimeRenderSnapshot): string | null {
1485
+ if (!snapshot.surface || snapshot.selection.isCollapsed) {
1486
+ return null;
1487
+ }
1488
+
1489
+ const range = snapshot.selection.activeRange;
1490
+ if (range.kind !== "range") {
1491
+ return "Selected range";
1492
+ }
1493
+
1494
+ const preview = snapshot.surface.plainText
1495
+ .slice(range.from, range.to)
1496
+ .replace(/\s+/g, " ")
1497
+ .trim();
1498
+
1499
+ if (!preview) {
1500
+ return "Selected range";
1501
+ }
1502
+
1503
+ return preview.length > 48 ? `${preview.slice(0, 45)}...` : preview;
1504
+ }