@beyondwork/docx-react-component 1.0.18 → 1.0.20

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 (105) hide show
  1. package/README.md +8 -2
  2. package/package.json +24 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +710 -4
  5. package/src/api/session-state.ts +60 -0
  6. package/src/core/commands/formatting-commands.ts +2 -1
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +19 -3
  9. package/src/core/commands/list-commands.ts +231 -36
  10. package/src/core/commands/paragraph-layout-commands.ts +339 -0
  11. package/src/core/commands/section-layout-commands.ts +680 -0
  12. package/src/core/commands/style-commands.ts +262 -0
  13. package/src/core/search/search-text.ts +357 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +4 -1
  16. package/src/index.ts +51 -0
  17. package/src/io/docx-session.ts +623 -56
  18. package/src/io/export/serialize-comments.ts +104 -34
  19. package/src/io/export/serialize-footnotes.ts +198 -1
  20. package/src/io/export/serialize-headers-footers.ts +203 -10
  21. package/src/io/export/serialize-main-document.ts +285 -8
  22. package/src/io/export/serialize-numbering.ts +28 -7
  23. package/src/io/export/split-review-boundaries.ts +181 -19
  24. package/src/io/normalize/normalize-text.ts +144 -32
  25. package/src/io/ooxml/highlight-colors.ts +39 -0
  26. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  27. package/src/io/ooxml/parse-comments.ts +85 -19
  28. package/src/io/ooxml/parse-fields.ts +396 -0
  29. package/src/io/ooxml/parse-footnotes.ts +452 -22
  30. package/src/io/ooxml/parse-headers-footers.ts +657 -29
  31. package/src/io/ooxml/parse-inline-media.ts +30 -0
  32. package/src/io/ooxml/parse-main-document.ts +807 -20
  33. package/src/io/ooxml/parse-numbering.ts +7 -0
  34. package/src/io/ooxml/parse-revisions.ts +317 -38
  35. package/src/io/ooxml/parse-settings.ts +184 -0
  36. package/src/io/ooxml/parse-shapes.ts +25 -0
  37. package/src/io/ooxml/parse-styles.ts +463 -0
  38. package/src/io/ooxml/parse-theme.ts +32 -0
  39. package/src/legal/bookmarks.ts +44 -0
  40. package/src/legal/cross-references.ts +59 -1
  41. package/src/model/canonical-document.ts +250 -4
  42. package/src/model/cds-1.0.0.ts +13 -0
  43. package/src/model/snapshot.ts +87 -2
  44. package/src/review/store/revision-store.ts +6 -0
  45. package/src/review/store/revision-types.ts +1 -0
  46. package/src/runtime/document-layout.ts +332 -0
  47. package/src/runtime/document-navigation.ts +603 -0
  48. package/src/runtime/document-runtime.ts +1754 -78
  49. package/src/runtime/document-search.ts +145 -0
  50. package/src/runtime/numbering-prefix.ts +47 -26
  51. package/src/runtime/page-layout-estimation.ts +212 -0
  52. package/src/runtime/read-only-diagnostics-runtime.ts +9 -0
  53. package/src/runtime/session-capabilities.ts +35 -3
  54. package/src/runtime/story-context.ts +164 -0
  55. package/src/runtime/story-targeting.ts +162 -0
  56. package/src/runtime/surface-projection.ts +324 -36
  57. package/src/runtime/table-schema.ts +89 -7
  58. package/src/runtime/view-state.ts +477 -0
  59. package/src/runtime/workflow-markup.ts +349 -0
  60. package/src/ui/WordReviewEditor.tsx +2469 -1344
  61. package/src/ui/browser-export.ts +52 -0
  62. package/src/ui/editor-command-bag.ts +120 -0
  63. package/src/ui/editor-runtime-boundary.ts +1422 -0
  64. package/src/ui/editor-shell-view.tsx +134 -0
  65. package/src/ui/editor-surface-controller.tsx +51 -0
  66. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  67. package/src/ui/headless/revision-decoration-model.ts +4 -4
  68. package/src/ui/headless/selection-helpers.ts +20 -0
  69. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  70. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  71. package/src/ui/runtime-snapshot-selectors.ts +197 -0
  72. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +18 -2
  73. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +129 -0
  74. package/src/ui-tailwind/chrome/tw-layout-panel.tsx +114 -0
  75. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +34 -0
  76. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  77. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +150 -14
  78. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +128 -0
  79. package/src/ui-tailwind/editor-surface/perf-probe.ts +179 -0
  80. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +46 -7
  81. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  82. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -0
  83. package/src/ui-tailwind/editor-surface/pm-position-map.ts +3 -3
  84. package/src/ui-tailwind/editor-surface/pm-schema.ts +186 -13
  85. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +191 -68
  86. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  87. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +51 -0
  88. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  89. package/src/ui-tailwind/editor-surface/tw-opaque-block.tsx +7 -1
  90. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +528 -85
  91. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  92. package/src/ui-tailwind/index.ts +2 -1
  93. package/src/ui-tailwind/page-chrome-model.ts +27 -0
  94. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  95. package/src/ui-tailwind/review/tw-health-panel.tsx +31 -2
  96. package/src/ui-tailwind/review/tw-review-rail.tsx +8 -8
  97. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +15 -15
  98. package/src/ui-tailwind/theme/editor-theme.css +127 -0
  99. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  100. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +829 -12
  101. package/src/ui-tailwind/tw-review-workspace.tsx +1238 -42
  102. package/src/validation/compatibility-engine.ts +119 -24
  103. package/src/validation/compatibility-report.ts +1 -0
  104. package/src/validation/diagnostics.ts +1 -0
  105. package/src/validation/docx-comment-proof.ts +707 -0
@@ -1,112 +1,174 @@
1
1
  import React, {
2
2
  forwardRef,
3
+ type FocusEvent,
4
+ useCallback,
3
5
  useEffect,
4
6
  useImperativeHandle,
5
7
  useMemo,
6
8
  useRef,
7
9
  useState,
8
- useSyncExternalStore,
9
10
  } from "react";
10
11
 
11
12
  import type {
12
13
  AutosaveState,
13
- EditorDatastoreAdapter,
14
14
  CompatibilityReport,
15
+ DocumentNavigationSnapshot,
16
+ EditorAnchorProjection,
17
+ EditorDatastoreAdapter,
18
+ EditorHostAdapter,
19
+ EditorSessionState,
20
+ ExportResult,
15
21
  EditorError,
22
+ EditorStoryTarget,
23
+ EditorViewStateSnapshot,
16
24
  EditorWarning,
17
- FormattingAlignment,
18
25
  ExportDocxOptions,
26
+ FieldSnapshot,
27
+ FormattingAlignment,
28
+ FormattingStateSnapshot,
29
+ HeaderFooterLinkPatch,
30
+ InteractionGuardSnapshot,
19
31
  InsertImageOptions,
20
32
  InsertTableOptions,
33
+ PageLayoutSnapshot,
21
34
  PersistedEditorSnapshot,
22
35
  RuntimeRenderSnapshot,
36
+ SectionBreakType,
37
+ SectionLayoutPatch,
38
+ SectionPageNumberingPatch,
23
39
  SearchOptions,
24
40
  SearchResultSnapshot,
25
41
  SelectionSnapshot as PublicSelectionSnapshot,
26
- ExportResult,
42
+ StyleCatalogSnapshot,
43
+ SurfaceBlockSnapshot,
44
+ TocRefreshResult,
45
+ UpdateFieldsResult,
46
+ ViewMode as EditorViewMode,
47
+ WorkflowBlockedCommandReason,
48
+ WorkflowScopeSnapshot,
27
49
  WordReviewEditorEvent,
28
50
  WordReviewEditorProps,
29
51
  WordReviewEditorRef,
52
+ WorkspaceMode,
53
+ ZoomLevel,
30
54
  } from "../api/public-types";
55
+ import {
56
+ editorSessionStateFromPersistedSnapshot,
57
+ persistedSnapshotFromEditorSessionState,
58
+ } from "../api/session-state.ts";
31
59
  import {
32
60
  createDetachedAnchor,
33
61
  createNodeAnchor,
34
62
  createRangeAnchor,
63
+ storyTargetsEqual,
35
64
  type TransactionMapping,
36
65
  } from "../core/selection/mapping.ts";
37
66
  import {
38
67
  applyFormattingOperationToDocument,
39
68
  getFormattingStateFromRenderSnapshot,
40
69
  } from "../core/commands/formatting-commands.ts";
41
- import { insertImage as insertImageInDocument } from "../core/commands/image-commands.ts";
70
+ import {
71
+ applyParagraphStyleToDocument,
72
+ applyTableStyleToDocument,
73
+ } from "../core/commands/style-commands.ts";
74
+ import {
75
+ continueNumbering as continueListNumbering,
76
+ backspaceAtListStart,
77
+ indentListItems,
78
+ outdentListItems,
79
+ restartNumbering as restartListNumbering,
80
+ splitListParagraph,
81
+ } from "../core/commands/list-commands.ts";
82
+ import {
83
+ resolveActiveParagraphIndex,
84
+ setActiveParagraphIndentation,
85
+ setActiveParagraphTabStops,
86
+ } from "../core/commands/paragraph-layout-commands.ts";
87
+ import {
88
+ deleteSectionBreakAtSectionIndex,
89
+ insertSectionBreakAfterSectionIndex,
90
+ setHeaderFooterLinkAtSectionIndex,
91
+ setSectionPageNumberingAtSectionIndex,
92
+ updateSectionLayoutAtSectionIndex,
93
+ } from "../core/commands/section-layout-commands.ts";
94
+ import {
95
+ insertImage as insertImageInDocument,
96
+ resizeImage as resizeImageInCatalog,
97
+ repositionFloatingImage as repositionFloatingImageInDocument,
98
+ } from "../core/commands/image-commands.ts";
42
99
  import {
43
100
  applyTableStructureOperation,
44
101
  } from "../core/commands/table-structure-commands.ts";
45
102
  import {
103
+ deleteSelectionOrBackward,
104
+ deleteSelectionOrForward,
105
+ insertHardBreak as insertHardBreakInDocument,
46
106
  insertPageBreak as insertPageBreakInDocument,
107
+ insertTab as insertTabInDocument,
108
+ insertText as insertTextInDocument,
47
109
  insertTable as insertTableInDocument,
110
+ splitParagraph as splitParagraphInDocument,
48
111
  } from "../core/commands/text-commands.ts";
112
+ import { type SelectionSnapshot as InternalSelectionSnapshot } from "../core/state/editor-state.ts";
49
113
  import {
50
- createCanonicalDocumentId,
51
- type SelectionSnapshot as InternalSelectionSnapshot,
52
- } from "../core/state/editor-state.ts";
53
- import {
54
- createDocumentRuntime,
55
- type DocumentRuntime,
56
- } from "../runtime/document-runtime.ts";
57
- import { loadDocxEditorSession } from "../io/docx-session.ts";
114
+ getStoryBlocks,
115
+ replaceStoryBlocks,
116
+ } from "../runtime/story-targeting.ts";
58
117
  import {
59
118
  decodePersistedSourcePackageBytes,
60
119
  hasValidPersistedSourcePackageDigest,
61
120
  } from "../io/source-package-provenance.ts";
121
+ import { readOpcPackage } from "../io/opc/package-reader.ts";
62
122
  import { deriveCapabilities } from "../runtime/session-capabilities";
123
+ import { searchDocument } from "../runtime/document-search.ts";
63
124
  import {
64
- createSearchExcerpt,
65
- findSearchMatches,
66
- } from "../ui-tailwind/editor-surface/search-plugin";
125
+ createEditorViewStateSnapshot,
126
+ createViewState,
127
+ } from "../runtime/view-state.ts";
67
128
  import {
68
- TwProseMirrorSurface,
69
129
  type TwProseMirrorSurfaceRef,
70
130
  } from "../ui-tailwind/editor-surface/tw-prosemirror-surface";
71
- import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace";
131
+ import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-state-from-snapshot";
132
+ import {
133
+ incrementInvalidationCounter,
134
+ recordPerfSample,
135
+ } from "../ui-tailwind/editor-surface/perf-probe.ts";
72
136
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail";
73
- import type { ViewMode } from "../ui-tailwind/toolbar/tw-toolbar";
137
+ import {
138
+ selectMetaSlice,
139
+ selectReviewSlice,
140
+ selectStatusSlice,
141
+ selectSurfaceSlice,
142
+ selectToolbarSlice,
143
+ selectViewSlice,
144
+ shallowEqualRecord,
145
+ useRuntimeSnapshotSlice,
146
+ useRuntimeValue,
147
+ } from "./runtime-snapshot-selectors.ts";
74
148
  import type { MarkupDisplay } from "./headless/comment-decoration-model";
149
+ import type {
150
+ SelectionToolbarAnchor,
151
+ SelectionToolbarModel,
152
+ } from "./headless/selection-toolbar-model";
153
+ import { type EditorCommandBag, useCommandBag } from "./editor-command-bag.ts";
154
+ import {
155
+ type WordReviewEditorRuntime,
156
+ persistAndExport as persistAndExportFromBoundary,
157
+ persistSession as persistSessionFromBoundary,
158
+ rejectExportWhileLoading as rejectExportWhileLoadingFromBoundary,
159
+ useEditorRuntimeBoundary,
160
+ } from "./editor-runtime-boundary.ts";
161
+ import {
162
+ downloadExportResult,
163
+ withExportDelivery,
164
+ } from "./browser-export";
165
+ import { EditorShellView } from "./editor-shell-view.tsx";
166
+ import { EditorSurfaceController } from "./editor-surface-controller.tsx";
75
167
 
76
- interface ResolvedSource {
77
- source: "docx" | "snapshot" | "datastore";
78
- sourceLabel?: string;
79
- initialDocx?: Uint8Array | ArrayBuffer;
80
- initialSnapshot?: PersistedEditorSnapshot;
81
- }
82
-
83
- interface CreateRuntimeArgs {
84
- documentId: string;
85
- readOnly: boolean;
86
- source: ResolvedSource;
87
- datastore?: EditorDatastoreAdapter;
88
- currentUserId?: string;
89
- }
90
-
91
- interface RuntimeLifecycleHandlers {
92
- onEvent?: (event: WordReviewEditorEvent) => void;
93
- onWarning?: (warning: EditorWarning) => void;
94
- onError?: (error: EditorError) => void;
95
- }
96
-
97
- interface WordReviewEditorRuntime extends DocumentRuntime {
98
- getFatalError?(): EditorError | undefined;
99
- dispose?(): void;
100
- }
101
-
102
- type PackageBackedDocxSession = ReturnType<typeof loadDocxEditorSession>;
103
-
104
- interface SnapshotExportBarrier {
105
- reason:
106
- | "missing_source_package_provenance"
107
- | "invalid_source_package_provenance";
108
- message: string;
109
- }
168
+ export {
169
+ __createFallbackRuntime,
170
+ __resolveWordReviewEditorSource,
171
+ } from "./editor-runtime-boundary.ts";
110
172
 
111
173
  const VISUALLY_HIDDEN_STYLES: React.CSSProperties = {
112
174
  position: "absolute",
@@ -120,6 +182,15 @@ const VISUALLY_HIDDEN_STYLES: React.CSSProperties = {
120
182
  border: 0,
121
183
  };
122
184
 
185
+ const BROWSER_SAFE_PREVIEW_TYPES = new Set([
186
+ "image/png",
187
+ "image/jpeg",
188
+ "image/jpg",
189
+ "image/gif",
190
+ "image/webp",
191
+ "image/bmp",
192
+ ]);
193
+
123
194
  const ACCESSIBLE_REGION_ORDER = [
124
195
  "toolbar",
125
196
  "document",
@@ -129,6 +200,12 @@ const ACCESSIBLE_REGION_ORDER = [
129
200
 
130
201
  type AccessibleRegionId = (typeof ACCESSIBLE_REGION_ORDER)[number];
131
202
 
203
+ type SelectionToolbarDismissReason =
204
+ | "blur"
205
+ | "chrome-action"
206
+ | "comment-action"
207
+ | "escape";
208
+
132
209
  export function __createWordReviewEditorRefBridge(
133
210
  runtime: WordReviewEditorRuntime,
134
211
  mountedSurface?: TwProseMirrorSurfaceRef | null,
@@ -138,6 +215,7 @@ export function __createWordReviewEditorRefBridge(
138
215
  blur: () => runtime.blur(),
139
216
  undo: () => runtime.undo(),
140
217
  redo: () => runtime.redo(),
218
+ replaceText: (text, target) => runtime.replaceText(text, target),
141
219
  addComment: (params) => runtime.addComment(params),
142
220
  openComment: (commentId) => runtime.openComment(commentId),
143
221
  resolveComment: (commentId) => runtime.resolveComment(commentId),
@@ -152,6 +230,7 @@ export function __createWordReviewEditorRefBridge(
152
230
  acceptAllChanges: () => runtime.acceptAllChanges(),
153
231
  rejectAllChanges: () => runtime.rejectAllChanges(),
154
232
  exportDocx: (options) => runtime.exportDocx(options),
233
+ getSessionState: () => runtime.getSessionState(),
155
234
  getSnapshot: () => runtime.getPersistedSnapshot(),
156
235
  getRenderSnapshot: () => clonePublicValue(runtime.getRenderSnapshot()),
157
236
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
@@ -165,7 +244,7 @@ export function __createWordReviewEditorRefBridge(
165
244
  clonePublicValue(runtime.getRenderSnapshot().trackedChanges),
166
245
  isDirty: () => runtime.getRenderSnapshot().isDirty,
167
246
  getFormattingState: () => getFormattingStateFromRenderSnapshot(runtime.getRenderSnapshot()),
168
- replaceText: (text, target) => runtime.replaceText(text, target),
247
+ getStyleCatalog: () => getRuntimeStyleCatalog(runtime),
169
248
  toggleBold: () => {
170
249
  applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "bold" });
171
250
  },
@@ -214,6 +293,12 @@ export function __createWordReviewEditorRefBridge(
214
293
  alignment,
215
294
  });
216
295
  },
296
+ setParagraphStyle: (styleId) => {
297
+ applyRuntimeParagraphStyle(runtime, styleId);
298
+ },
299
+ setTableStyle: (styleId) => {
300
+ applyRuntimeTableStyle(runtime, styleId);
301
+ },
217
302
  indent: () => {
218
303
  applyRuntimeFormattingOperation(runtime, { type: "indent" });
219
304
  },
@@ -281,190 +366,127 @@ export function __createWordReviewEditorRefBridge(
281
366
  });
282
367
  },
283
368
  search: (query, options) =>
284
- mountedSurface?.search(query, options) ??
285
- searchSnapshotSurface(runtime.getRenderSnapshot(), query, options),
369
+ searchRuntimeDocument(runtime, mountedSurface ?? null, query, options),
286
370
  clearSearch: () => {
287
371
  mountedSurface?.clearSearch();
288
372
  },
289
373
  setSelection: (selection) => {
290
- runtime.dispatch({
291
- type: "selection.set",
292
- selection: toRuntimeSelectionSnapshot(
293
- normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
294
- ),
295
- });
374
+ applyRuntimeSelection(
375
+ runtime,
376
+ normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
377
+ );
296
378
  },
297
379
  scrollToRevision: (revisionId: string) => {
298
380
  const revision = runtime.getRenderSnapshot().trackedChanges.revisions.find(
299
381
  (r) => r.revisionId === revisionId,
300
382
  );
301
383
  if (!revision || revision.anchor.kind === "detached") return;
302
- runtime.dispatch({
303
- type: "selection.set",
304
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(revision.anchor)),
305
- });
384
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(revision.anchor));
306
385
  },
307
386
  scrollToComment: (commentId: string) => {
308
387
  const comment = runtime.getRenderSnapshot().comments.threads.find(
309
388
  (t) => t.commentId === commentId,
310
389
  );
311
390
  if (!comment || comment.anchor.kind === "detached") return;
312
- runtime.dispatch({
313
- type: "selection.set",
314
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(comment.anchor)),
315
- });
391
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(comment.anchor));
316
392
  },
317
- };
318
- }
319
-
320
- export async function __resolveWordReviewEditorSource(
321
- props: Pick<
322
- WordReviewEditorProps,
323
- | "documentId"
324
- | "datastore"
325
- | "externalDocSource"
326
- | "initialDocx"
327
- | "initialSnapshot"
328
- | "initialSourceLabel"
329
- >,
330
- ): Promise<ResolvedSource> {
331
- const explicitInitialCount =
332
- Number(Boolean(props.initialDocx)) + Number(Boolean(props.initialSnapshot));
333
- if (explicitInitialCount > 1) {
334
- throw new Error("Provide exactly one of initialDocx or initialSnapshot.");
335
- }
336
-
337
- if (props.externalDocSource) {
338
- return props.externalDocSource.kind === "docx"
339
- ? {
340
- source: "docx",
341
- initialDocx: props.externalDocSource.bytes,
342
- sourceLabel: props.externalDocSource.sourceLabel,
343
- }
344
- : {
345
- source: "snapshot",
346
- initialSnapshot: props.externalDocSource.snapshot,
347
- sourceLabel: props.externalDocSource.sourceLabel,
348
- };
349
- }
350
-
351
- if (props.initialSnapshot) {
352
- return {
353
- source: "snapshot",
354
- initialSnapshot: props.initialSnapshot,
355
- sourceLabel: props.initialSourceLabel,
356
- };
357
- }
358
-
359
- if (props.initialDocx) {
360
- return {
361
- source: "docx",
362
- initialDocx: props.initialDocx,
363
- sourceLabel: props.initialSourceLabel,
364
- };
365
- }
366
-
367
- if (!props.datastore) {
368
- throw new Error(
369
- `WordReviewEditor ${props.documentId} needs initialDocx, initialSnapshot, or datastore.load().`,
370
- );
371
- }
372
-
373
- const loadResult = await props.datastore.load({
374
- documentId: props.documentId,
375
- });
376
-
377
- if (!loadResult.source) {
378
- throw new Error(`Datastore did not return a loadable source for ${props.documentId}.`);
379
- }
380
-
381
- return loadResult.source.kind === "docx"
382
- ? {
383
- source: "datastore",
384
- initialDocx: loadResult.source.bytes,
385
- sourceLabel: loadResult.source.sourceLabel,
386
- }
387
- : {
388
- source: "datastore",
389
- initialSnapshot: loadResult.source.snapshot,
390
- sourceLabel: loadResult.source.sourceLabel,
391
- };
392
- }
393
-
394
- export function __createFallbackRuntime(args: CreateRuntimeArgs): WordReviewEditorRuntime {
395
- return createRuntime(args);
396
- }
397
-
398
- function createRuntime(
399
- args: CreateRuntimeArgs,
400
- handlers: RuntimeLifecycleHandlers = {},
401
- ): WordReviewEditorRuntime {
402
- const docxSession = args.source.initialDocx
403
- ? loadDocxEditorSession({
404
- documentId: args.documentId,
405
- sourceLabel: args.source.sourceLabel,
406
- bytes: args.source.initialDocx,
407
- editorBuild: "dev",
408
- })
409
- : undefined;
410
- const snapshotExportResolution = !args.source.initialDocx
411
- ? resolveSnapshotExportSession(args)
412
- : undefined;
413
- const initialSnapshot =
414
- args.source.initialSnapshot ??
415
- docxSession?.initialSnapshot ??
416
- createFallbackPersistedSnapshot(
417
- args.documentId,
418
- args.source.sourceLabel ?? "Generated shell snapshot",
419
- );
420
- const runtimeSnapshot = snapshotExportResolution?.barrier
421
- ? applySnapshotExportBarrier(initialSnapshot, snapshotExportResolution.barrier)
422
- : initialSnapshot;
423
-
424
- return createDocumentRuntime({
425
- documentId: args.documentId,
426
- initialSnapshot: runtimeSnapshot,
427
- sourceKind: args.source.source,
428
- sourceLabel: args.source.sourceLabel,
429
- readOnly: args.readOnly || docxSession?.readOnly,
430
- editorBuild: runtimeSnapshot.editorBuild,
431
- fatalError: docxSession?.fatalError,
432
- exportDocx: async (snapshot, options) => {
433
- if (docxSession) {
434
- return docxSession.exportDocx(snapshot, options);
435
- }
436
-
437
- if (snapshotExportResolution?.session) {
438
- return snapshotExportResolution.session.exportDocx(snapshot, options);
439
- }
440
-
441
- throw createSnapshotExportBlockedError(
442
- args.documentId,
443
- snapshotExportResolution?.barrier ?? {
444
- reason: "missing_source_package_provenance",
445
- message:
446
- "DOCX export is blocked because this snapshot does not carry embedded source package provenance.",
447
- },
448
- );
393
+ openStory: (target: EditorStoryTarget) => {
394
+ runtime.openStory(target);
449
395
  },
450
- onWarning: handlers.onWarning,
451
- onError: handlers.onError,
452
- defaultAuthorId: args.currentUserId,
453
- });
396
+ closeStory: () => {
397
+ runtime.closeStory();
398
+ },
399
+ getPageLayoutSnapshot: () => {
400
+ return clonePublicValue(runtime.getPageLayoutSnapshot());
401
+ },
402
+ getDocumentNavigationSnapshot: () => {
403
+ return clonePublicValue(runtime.getDocumentNavigationSnapshot());
404
+ },
405
+ getFieldSnapshot: () => {
406
+ return clonePublicValue(runtime.getFieldSnapshot());
407
+ },
408
+ updateFields: (options) => {
409
+ return runtime.updateFields(options);
410
+ },
411
+ updateTableOfContents: (options) => {
412
+ return runtime.updateTableOfContents(options);
413
+ },
414
+ getViewState: () => {
415
+ return clonePublicValue(runtime.getViewState());
416
+ },
417
+ setDocumentMode: (mode) => {
418
+ runtime.setDocumentMode(mode);
419
+ },
420
+ getProtectionSnapshot: () => {
421
+ return clonePublicValue(runtime.getProtectionSnapshot());
422
+ },
423
+ setWorkspaceMode: (mode) => {
424
+ runtime.setWorkspaceMode(mode);
425
+ },
426
+ setZoom: (level) => {
427
+ runtime.setZoom(level);
428
+ },
429
+ insertSectionBreak: (type, options) => {
430
+ applyRuntimeInsertSectionBreak(runtime, type, options);
431
+ },
432
+ deleteSectionBreak: (sectionIndex) => {
433
+ applyRuntimeDeleteSectionBreak(runtime, sectionIndex);
434
+ },
435
+ updateSectionLayout: (sectionIndex, patch) => {
436
+ applyRuntimeUpdateSectionLayout(runtime, sectionIndex, patch);
437
+ },
438
+ setSectionPageNumbering: (sectionIndex, patch) => {
439
+ applyRuntimeSetSectionPageNumbering(runtime, sectionIndex, patch);
440
+ },
441
+ setHeaderFooterLink: (sectionIndex, params) => {
442
+ applyRuntimeSetHeaderFooterLink(runtime, sectionIndex, params);
443
+ },
444
+ setImageLayout: (mediaId, dimensions) => {
445
+ applyRuntimeImageResize(runtime, mediaId, dimensions);
446
+ },
447
+ setImageFrame: (mediaId, offsets) => {
448
+ applyRuntimeImageReposition(runtime, mediaId, offsets);
449
+ },
450
+ setWorkflowOverlay: (overlay) => {
451
+ runtime.setWorkflowOverlay(clonePublicValue(overlay));
452
+ },
453
+ clearWorkflowOverlay: () => {
454
+ runtime.clearWorkflowOverlay();
455
+ },
456
+ getWorkflowScopeSnapshot: () => {
457
+ return clonePublicValue(runtime.getWorkflowScopeSnapshot());
458
+ },
459
+ getInteractionGuardSnapshot: () => {
460
+ return clonePublicValue(runtime.getInteractionGuardSnapshot());
461
+ },
462
+ getWorkflowMarkupSnapshot: () => {
463
+ return clonePublicValue(runtime.getWorkflowMarkupSnapshot());
464
+ },
465
+ getWorkflowCandidateRanges: (options) => {
466
+ return clonePublicValue(runtime.getWorkflowCandidateRanges(options));
467
+ },
468
+ replaceWorkflowMarkupText: (markupId, text) => {
469
+ runtime.replaceWorkflowMarkupText(markupId, text);
470
+ },
471
+ };
454
472
  }
455
473
 
456
474
  export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditorProps>(
457
475
  function WordReviewEditor(props, ref) {
458
476
  const {
459
477
  currentUser,
478
+ hostAdapter,
460
479
  datastore,
461
480
  documentId,
462
481
  externalDocSource,
463
482
  externalDocumentRevision,
464
483
  initialDocx,
484
+ loadRevision,
485
+ loadSourcePolicy = "prefer-saved-state",
486
+ initialSessionState,
465
487
  initialSnapshot,
466
488
  initialSourceLabel,
467
- markupDisplay = "simple",
489
+ markupDisplay,
468
490
  onError,
469
491
  onEvent,
470
492
  onWarning,
@@ -473,203 +495,178 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
473
495
  showReviewPanel = true,
474
496
  } = props;
475
497
 
476
- const [runtime, setRuntime] = useState<WordReviewEditorRuntime | null>(null);
477
- const [loadError, setLoadError] = useState<EditorError | null>(null);
478
- const [viewMode, setViewMode] = useState<ViewMode>("canvas");
479
- const liveMarkupDisplay: MarkupDisplay = viewMode === "document" ? "all" : "clean";
480
498
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
481
499
  const [showTrackedChanges, setShowTrackedChanges] = useState(false);
482
500
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
483
- const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
501
+ const [selectionToolbarAnchor, setSelectionToolbarAnchor] = useState<SelectionToolbarAnchor | null>(null);
502
+ const [selectionToolbarDismissedKey, setSelectionToolbarDismissedKey] = useState<string | null>(null);
503
+ const [selectionToolbarFocusWithin, setSelectionToolbarFocusWithin] = useState(false);
484
504
  const surfaceRef = useRef<TwProseMirrorSurfaceRef | null>(null);
505
+ const selectionToolbarElementRef = useRef<HTMLDivElement | null>(null);
485
506
  const shellRef = useRef<HTMLDivElement | null>(null);
507
+ const lastSelectionToolbarKeyRef = useRef<string | null>(null);
486
508
  const lastAnnouncedErrorIdRef = useRef<string | null>(null);
487
- const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
488
- const lastSavedRevisionTokenRef = useRef<string | null>(null);
489
- const datastoreRef = useRef(datastore);
490
- const onEventRef = useRef(onEvent);
491
- const onWarningRef = useRef(onWarning);
492
- const onErrorRef = useRef(onError);
493
- const initialSourceRef = useRef<{
494
- documentId: string;
495
- initialDocx?: Uint8Array | ArrayBuffer;
496
- initialSnapshot?: PersistedEditorSnapshot;
497
- initialSourceLabel?: string;
498
- } | null>(null);
499
-
500
- if (!initialSourceRef.current || initialSourceRef.current.documentId !== documentId) {
501
- initialSourceRef.current = {
502
- documentId,
503
- initialDocx,
504
- initialSnapshot,
505
- initialSourceLabel,
506
- };
507
- }
508
-
509
- const stableInitialSource = initialSourceRef.current;
510
- const sourceReloadKey = externalDocSource
511
- ? `external:${externalDocSource.kind}:${externalDocumentRevision ?? "static"}`
512
- : stableInitialSource?.initialSnapshot
513
- ? "initial-snapshot"
514
- : stableInitialSource?.initialDocx
515
- ? "initial-docx"
516
- : "datastore";
517
-
518
- useEffect(() => {
519
- datastoreRef.current = datastore;
520
- onEventRef.current = onEvent;
521
- onWarningRef.current = onWarning;
522
- onErrorRef.current = onError;
523
- }, [datastore, onError, onEvent, onWarning]);
524
-
525
- useEffect(() => {
526
- let cancelled = false;
527
-
528
- async function loadRuntime(): Promise<void> {
529
- setLoadError(null);
530
-
531
- try {
532
- const source = await __resolveWordReviewEditorSource({
533
- documentId,
534
- datastore: datastoreRef.current,
535
- externalDocSource,
536
- initialDocx: stableInitialSource?.initialDocx,
537
- initialSnapshot: stableInitialSource?.initialSnapshot,
538
- initialSourceLabel: stableInitialSource?.initialSourceLabel,
539
- });
540
-
541
- if (cancelled) {
542
- return;
509
+ const {
510
+ runtime,
511
+ loadError,
512
+ activeRuntime,
513
+ fallbackSnapshot,
514
+ loadingSessionState,
515
+ loadingViewState,
516
+ loadingNavigation,
517
+ hostAdapterRef,
518
+ datastoreRef,
519
+ onEventRef,
520
+ onWarningRef,
521
+ onErrorRef,
522
+ autosaveTimerRef,
523
+ lastSavedRevisionTokenRef,
524
+ runtimeViewStateSeedRef,
525
+ } = useEditorRuntimeBoundary(props);
526
+ const metaSlice = useRuntimeSnapshotSlice(
527
+ runtime,
528
+ fallbackSnapshot,
529
+ selectMetaSlice,
530
+ shallowEqualRecord,
531
+ );
532
+ const toolbarSlice = useRuntimeSnapshotSlice(
533
+ runtime,
534
+ fallbackSnapshot,
535
+ selectToolbarSlice,
536
+ shallowEqualRecord,
537
+ );
538
+ const surfaceSlice = useRuntimeSnapshotSlice(
539
+ runtime,
540
+ fallbackSnapshot,
541
+ selectSurfaceSlice,
542
+ shallowEqualRecord,
543
+ );
544
+ const reviewSlice = useRuntimeSnapshotSlice(
545
+ runtime,
546
+ fallbackSnapshot,
547
+ selectReviewSlice,
548
+ shallowEqualRecord,
549
+ );
550
+ const viewSlice = useRuntimeSnapshotSlice(
551
+ runtime,
552
+ fallbackSnapshot,
553
+ selectViewSlice,
554
+ shallowEqualRecord,
555
+ );
556
+ const statusSlice = useRuntimeSnapshotSlice(
557
+ runtime,
558
+ fallbackSnapshot,
559
+ selectStatusSlice,
560
+ shallowEqualRecord,
561
+ );
562
+ const snapshot = useMemo(
563
+ () => ({
564
+ documentId: metaSlice.documentId,
565
+ sessionId: metaSlice.sessionId,
566
+ sourceLabel: metaSlice.sourceLabel,
567
+ revisionToken: surfaceSlice.revisionToken,
568
+ isReady: toolbarSlice.isReady,
569
+ isDirty: statusSlice.isDirty,
570
+ readOnly: toolbarSlice.readOnly,
571
+ documentMode: viewSlice.documentMode,
572
+ selection: surfaceSlice.selection,
573
+ activeStory: viewSlice.activeStory,
574
+ pageLayout: viewSlice.pageLayout,
575
+ documentStats: statusSlice.documentStats,
576
+ comments: reviewSlice.comments,
577
+ trackedChanges: reviewSlice.trackedChanges,
578
+ compatibility: reviewSlice.compatibility,
579
+ warnings: statusSlice.warnings,
580
+ fatalError: statusSlice.fatalError,
581
+ commandState: toolbarSlice.commandState,
582
+ surface: surfaceSlice.surface,
583
+ protectionSnapshot: statusSlice.protectionSnapshot,
584
+ }),
585
+ [
586
+ metaSlice.documentId,
587
+ metaSlice.sessionId,
588
+ metaSlice.sourceLabel,
589
+ surfaceSlice.revisionToken,
590
+ surfaceSlice.selection,
591
+ surfaceSlice.surface,
592
+ toolbarSlice.isReady,
593
+ toolbarSlice.readOnly,
594
+ toolbarSlice.commandState,
595
+ statusSlice.isDirty,
596
+ statusSlice.documentStats,
597
+ statusSlice.warnings,
598
+ statusSlice.fatalError,
599
+ statusSlice.protectionSnapshot,
600
+ reviewSlice.comments,
601
+ reviewSlice.trackedChanges,
602
+ reviewSlice.compatibility,
603
+ viewSlice.documentMode,
604
+ viewSlice.activeStory,
605
+ viewSlice.pageLayout,
606
+ ],
607
+ );
608
+ const viewState = useRuntimeValue(
609
+ runtime
610
+ ? {
611
+ subscribe: (listener) => runtime.subscribe(listener),
612
+ getValue: () => runtime.getViewState(),
543
613
  }
544
-
545
- runtimeRef.current?.dispose?.();
546
- const nextRuntime = createRuntime(
547
- {
548
- documentId,
549
- readOnly,
550
- source,
551
- datastore: datastoreRef.current,
552
- currentUserId: currentUser.userId,
553
- },
554
- {
555
- onWarning: onWarningRef.current,
556
- onError: onErrorRef.current,
557
- },
558
- );
559
- emitEditorEvent({
560
- datastore: datastoreRef.current,
561
- onEvent: onEventRef.current,
562
- event: createReadyEvent(nextRuntime, source.source),
563
- });
564
- runtimeRef.current = nextRuntime;
565
- setRuntime(nextRuntime);
566
- } catch (error) {
567
- if (cancelled) {
568
- return;
614
+ : null,
615
+ loadingViewState,
616
+ );
617
+ const isPageWorkspace = viewState.workspaceMode === "page";
618
+ const liveMarkupDisplay = __resolveLiveMarkupDisplay(markupDisplay, isPageWorkspace);
619
+ const documentNavigation = useRuntimeValue(
620
+ runtime
621
+ ? {
622
+ subscribe: (listener) => runtime.subscribe(listener),
623
+ getValue: () => runtime.getDocumentNavigationSnapshot(),
569
624
  }
570
-
571
- const normalized = normalizeEditorError(error);
572
- setLoadError(normalized);
573
- onErrorRef.current?.(normalized);
574
- emitEditorEvent({
575
- datastore: datastoreRef.current,
576
- onEvent: onEventRef.current,
577
- event: {
578
- type: "error",
579
- documentId,
580
- error: normalized,
581
- },
582
- });
583
- }
584
- }
585
-
586
- void loadRuntime();
587
-
588
- return () => {
589
- cancelled = true;
590
- };
591
- }, [
592
- documentId,
593
- readOnly,
594
- sourceReloadKey,
595
- ]);
625
+ : null,
626
+ loadingNavigation,
627
+ );
628
+ const workflowScopeSnapshot = useRuntimeValue(
629
+ runtime
630
+ ? {
631
+ subscribe: (listener) => runtime.subscribe(listener),
632
+ getValue: () => runtime.getWorkflowScopeSnapshot(),
633
+ }
634
+ : null,
635
+ null,
636
+ workflowScopeSnapshotsEqual,
637
+ );
638
+ const interactionGuardSnapshot = useRuntimeValue(
639
+ runtime
640
+ ? {
641
+ subscribe: (listener) => runtime.subscribe(listener),
642
+ getValue: () => runtime.getInteractionGuardSnapshot(),
643
+ }
644
+ : null,
645
+ { blockedReasons: [] } satisfies InteractionGuardSnapshot,
646
+ interactionGuardSnapshotsEqual,
647
+ );
648
+ const sessionState = useMemo(
649
+ () => (runtime ? runtime.getSessionState() : loadingSessionState),
650
+ [loadingSessionState, runtime, snapshot.revisionToken],
651
+ );
652
+ const canonicalDocument = sessionState.canonicalDocument;
653
+ const effectiveViewMode = deriveEditorViewMode(snapshot.readOnly, reviewMode);
596
654
 
597
655
  useEffect(() => {
598
- if (!runtime?.subscribeToEvents) {
599
- return;
600
- }
601
-
602
- return runtime.subscribeToEvents((event) => {
603
- emitEditorEvent({
604
- datastore: datastoreRef.current,
605
- onEvent: onEventRef.current,
606
- event,
607
- });
608
- });
609
- }, [runtime]);
656
+ activeRuntime.setViewMode(effectiveViewMode);
657
+ }, [activeRuntime, effectiveViewMode]);
610
658
 
611
659
  useEffect(() => {
612
- return () => {
613
- if (autosaveTimerRef.current) {
614
- clearTimeout(autosaveTimerRef.current);
615
- autosaveTimerRef.current = null;
616
- }
617
- runtimeRef.current?.dispose?.();
618
- runtimeRef.current = null;
660
+ runtimeViewStateSeedRef.current = {
661
+ workspaceMode: viewState.workspaceMode,
662
+ zoomLevel: viewState.zoomLevel,
619
663
  };
620
- }, []);
621
-
622
- const optimisticRuntime = useMemo(
623
- () =>
624
- __createFallbackRuntime({
625
- documentId,
626
- readOnly,
627
- currentUserId: currentUser.userId,
628
- source: {
629
- source: "snapshot",
630
- initialSnapshot:
631
- initialSnapshot ?? createFallbackPersistedSnapshot(documentId, initialSourceLabel),
632
- sourceLabel: guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
633
- },
634
- datastore: datastoreRef.current,
635
- }),
636
- [
637
- currentUser.userId,
638
- documentId,
639
- initialSnapshot,
640
- initialSourceLabel,
641
- readOnly,
642
- externalDocSource?.kind,
643
- externalDocSource?.sourceLabel,
644
- ],
645
- );
646
-
647
- const fallbackSnapshot = useMemo(
648
- () =>
649
- loadError
650
- ? createErrorSnapshot(documentId, loadError)
651
- : createLoadingSnapshot(
652
- documentId,
653
- readOnly,
654
- guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
655
- ),
656
- [
657
- documentId,
658
- externalDocSource,
659
- initialSnapshot,
660
- initialSourceLabel,
661
- loadError,
662
- readOnly,
663
- ],
664
- );
665
-
666
- const snapshot = useSyncExternalStore(
667
- (listener) => runtime?.subscribe(listener) ?? (() => undefined),
668
- () => runtime?.getRenderSnapshot() ?? fallbackSnapshot,
669
- () => runtime?.getRenderSnapshot() ?? fallbackSnapshot,
670
- );
664
+ }, [viewState.workspaceMode, viewState.zoomLevel]);
671
665
 
672
- const activeRuntime = runtime ?? optimisticRuntime;
666
+ useEffect(() => {
667
+ recordPerfSample("shell.render");
668
+ incrementInvalidationCounter("shell.rerenders");
669
+ }, [snapshot.revisionToken, snapshot.selection, viewState, documentNavigation]);
673
670
 
674
671
  useImperativeHandle(
675
672
  ref,
@@ -678,6 +675,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
678
675
  blur: () => activeRuntime.blur(),
679
676
  undo: () => activeRuntime.undo(),
680
677
  redo: () => activeRuntime.redo(),
678
+ replaceText: (text, target) => activeRuntime.replaceText(text, target),
681
679
  addComment: (params) =>
682
680
  activeRuntime.addComment({
683
681
  ...params,
@@ -699,7 +697,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
699
697
  rejectAllChanges: () => activeRuntime.rejectAllChanges(),
700
698
  exportDocx: (options) =>
701
699
  runtime
702
- ? persistAndExport({
700
+ ? persistAndExportFromBoundary({
701
+ hostAdapter: hostAdapterRef.current,
703
702
  datastore: datastoreRef.current,
704
703
  documentId,
705
704
  runtime,
@@ -709,12 +708,14 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
709
708
  lastSavedRevisionTokenRef,
710
709
  autosaveTimerRef,
711
710
  })
712
- : rejectExportWhileLoading({
711
+ : rejectExportWhileLoadingFromBoundary({
713
712
  documentId,
713
+ hostAdapter: hostAdapterRef.current,
714
714
  datastore: datastoreRef.current,
715
715
  onError: onErrorRef.current,
716
716
  onEvent: onEventRef.current,
717
717
  }),
718
+ getSessionState: () => activeRuntime.getSessionState(),
718
719
  getSnapshot: () => activeRuntime.getPersistedSnapshot(),
719
720
  getRenderSnapshot: () => clonePublicValue(activeRuntime.getRenderSnapshot()),
720
721
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
@@ -730,7 +731,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
730
731
  isDirty: () => activeRuntime.getRenderSnapshot().isDirty,
731
732
  getFormattingState: () =>
732
733
  getFormattingStateFromRenderSnapshot(activeRuntime.getRenderSnapshot()),
733
- replaceText: (text, target) => activeRuntime.replaceText(text, target),
734
+ getStyleCatalog: () => getRuntimeStyleCatalog(activeRuntime),
734
735
  toggleBold: () => {
735
736
  applyRuntimeFormattingOperation(activeRuntime, {
736
737
  type: "toggle",
@@ -797,6 +798,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
797
798
  alignment,
798
799
  });
799
800
  },
801
+ setParagraphStyle: (styleId) => {
802
+ applyRuntimeParagraphStyle(activeRuntime, styleId);
803
+ },
804
+ setTableStyle: (styleId) => {
805
+ applyRuntimeTableStyle(activeRuntime, styleId);
806
+ },
800
807
  indent: () => {
801
808
  applyRuntimeFormattingOperation(activeRuntime, { type: "indent" });
802
809
  },
@@ -864,45 +871,121 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
864
871
  });
865
872
  },
866
873
  search: (query, options) =>
867
- surfaceRef.current?.search(query, options) ??
868
- searchSnapshotSurface(activeRuntime.getRenderSnapshot(), query, options),
874
+ searchRuntimeDocument(
875
+ activeRuntime,
876
+ surfaceRef.current,
877
+ query,
878
+ options,
879
+ ),
869
880
  clearSearch: () => {
870
881
  surfaceRef.current?.clearSearch();
871
882
  },
872
883
  setSelection: (selection) => {
873
- activeRuntime.dispatch({
874
- type: "selection.set",
875
- selection: toRuntimeSelectionSnapshot(
876
- normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
877
- ),
878
- });
884
+ applyRuntimeSelection(
885
+ activeRuntime,
886
+ normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
887
+ );
879
888
  },
880
889
  scrollToRevision: (revisionId: string) => {
881
890
  const revision = activeRuntime.getRenderSnapshot().trackedChanges.revisions.find(
882
891
  (r) => r.revisionId === revisionId,
883
892
  );
884
893
  if (!revision || revision.anchor.kind === "detached") return;
885
- activeRuntime.dispatch({
886
- type: "selection.set",
887
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(revision.anchor)),
888
- });
894
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(revision.anchor));
889
895
  },
890
896
  scrollToComment: (commentId: string) => {
891
897
  const comment = activeRuntime.getRenderSnapshot().comments.threads.find(
892
898
  (t) => t.commentId === commentId,
893
899
  );
894
900
  if (!comment || comment.anchor.kind === "detached") return;
895
- activeRuntime.dispatch({
896
- type: "selection.set",
897
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(comment.anchor)),
898
- });
901
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(comment.anchor));
902
+ },
903
+ openStory: (target: EditorStoryTarget) => {
904
+ activeRuntime.openStory(target);
905
+ },
906
+ closeStory: () => {
907
+ activeRuntime.closeStory();
908
+ },
909
+ getPageLayoutSnapshot: () => {
910
+ return clonePublicValue(activeRuntime.getPageLayoutSnapshot());
911
+ },
912
+ getDocumentNavigationSnapshot: () => {
913
+ return clonePublicValue(activeRuntime.getDocumentNavigationSnapshot());
914
+ },
915
+ getFieldSnapshot: () => {
916
+ return clonePublicValue(activeRuntime.getFieldSnapshot());
917
+ },
918
+ updateFields: (options) => {
919
+ return activeRuntime.updateFields(options);
920
+ },
921
+ updateTableOfContents: (options) => {
922
+ return activeRuntime.updateTableOfContents(options);
923
+ },
924
+ getViewState: () => {
925
+ return clonePublicValue(activeRuntime.getViewState());
926
+ },
927
+ setDocumentMode: (mode) => {
928
+ activeRuntime.setDocumentMode(mode);
929
+ },
930
+ getProtectionSnapshot: () => {
931
+ return clonePublicValue(activeRuntime.getProtectionSnapshot());
932
+ },
933
+ setWorkspaceMode: (mode) => {
934
+ activeRuntime.setWorkspaceMode(mode);
935
+ },
936
+ setZoom: (level) => {
937
+ activeRuntime.setZoom(level);
938
+ },
939
+ insertSectionBreak: (type, options) => {
940
+ applyRuntimeInsertSectionBreak(activeRuntime, type, options);
941
+ },
942
+ deleteSectionBreak: (sectionIndex) => {
943
+ applyRuntimeDeleteSectionBreak(activeRuntime, sectionIndex);
944
+ },
945
+ updateSectionLayout: (sectionIndex, patch) => {
946
+ applyRuntimeUpdateSectionLayout(activeRuntime, sectionIndex, patch);
947
+ },
948
+ setSectionPageNumbering: (sectionIndex, patch) => {
949
+ applyRuntimeSetSectionPageNumbering(activeRuntime, sectionIndex, patch);
950
+ },
951
+ setHeaderFooterLink: (sectionIndex, params) => {
952
+ applyRuntimeSetHeaderFooterLink(activeRuntime, sectionIndex, params);
953
+ },
954
+ setImageLayout: (mediaId, dimensions) => {
955
+ applyRuntimeImageResize(activeRuntime, mediaId, dimensions);
956
+ },
957
+ setImageFrame: (mediaId, offsets) => {
958
+ applyRuntimeImageReposition(activeRuntime, mediaId, offsets);
959
+ },
960
+ setWorkflowOverlay: (overlay) => {
961
+ activeRuntime.setWorkflowOverlay(clonePublicValue(overlay));
962
+ },
963
+ clearWorkflowOverlay: () => {
964
+ activeRuntime.clearWorkflowOverlay();
965
+ },
966
+ getWorkflowScopeSnapshot: () => {
967
+ return clonePublicValue(activeRuntime.getWorkflowScopeSnapshot());
968
+ },
969
+ getInteractionGuardSnapshot: () => {
970
+ return clonePublicValue(activeRuntime.getInteractionGuardSnapshot());
971
+ },
972
+ getWorkflowMarkupSnapshot: () => {
973
+ return clonePublicValue(activeRuntime.getWorkflowMarkupSnapshot());
974
+ },
975
+ getWorkflowCandidateRanges: (options) => {
976
+ return clonePublicValue(activeRuntime.getWorkflowCandidateRanges(options));
977
+ },
978
+ replaceWorkflowMarkupText: (markupId, text) => {
979
+ activeRuntime.replaceWorkflowMarkupText(markupId, text);
899
980
  },
900
981
  }),
901
982
  [activeRuntime, currentUser.userId, documentId, runtime],
902
983
  );
903
984
 
904
985
  useEffect(() => {
905
- if (!datastoreRef.current || props.autosave?.enabled === false || !runtime || readOnly) {
986
+ const canPersist =
987
+ Boolean(hostAdapterRef.current?.saveSession) || Boolean(datastoreRef.current?.saveSnapshot);
988
+ if (!canPersist || props.autosave?.enabled === false || !runtime || readOnly) {
906
989
  if (autosaveTimerRef.current) {
907
990
  clearTimeout(autosaveTimerRef.current);
908
991
  autosaveTimerRef.current = null;
@@ -924,7 +1007,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
924
1007
 
925
1008
  const debounceMs = props.autosave?.debounceMs ?? 800;
926
1009
  if (debounceMs <= 0) {
927
- void persistSnapshot({
1010
+ void persistSessionFromBoundary({
1011
+ hostAdapter: hostAdapterRef.current,
928
1012
  datastore: datastoreRef.current,
929
1013
  documentId,
930
1014
  runtime,
@@ -937,7 +1021,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
937
1021
  }
938
1022
 
939
1023
  autosaveTimerRef.current = setTimeout(() => {
940
- void persistSnapshot({
1024
+ void persistSessionFromBoundary({
1025
+ hostAdapter: hostAdapterRef.current,
941
1026
  datastore: datastoreRef.current,
942
1027
  documentId,
943
1028
  runtime,
@@ -989,29 +1074,29 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
989
1074
  if (anchor.kind === "detached") {
990
1075
  return;
991
1076
  }
992
-
993
- activeRuntime.dispatch({
994
- type: "selection.set",
995
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(anchor)),
996
- });
1077
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor));
997
1078
  }
998
1079
 
999
- function addReviewComment(): void {
1080
+ function addReviewComment(): string | null {
1000
1081
  try {
1001
- activeRuntime.addComment({
1082
+ const commentId = activeRuntime.addComment({
1002
1083
  anchor: snapshot.selection.activeRange,
1003
- body: "New review comment",
1084
+ body: "",
1004
1085
  authorId: currentUser.userId,
1005
1086
  });
1087
+ activeRuntime.openComment(commentId);
1006
1088
  setActiveRailTab("comments");
1089
+ return commentId;
1007
1090
  } catch {
1008
1091
  // Runtime already emitted a concrete export-safety error for invalid anchors.
1092
+ return null;
1009
1093
  }
1010
1094
  }
1011
1095
 
1012
1096
  function exportCurrentDocument(): void {
1013
1097
  void (runtime
1014
- ? persistAndExport({
1098
+ ? persistAndExportFromBoundary({
1099
+ hostAdapter: hostAdapterRef.current,
1015
1100
  datastore: datastoreRef.current,
1016
1101
  documentId,
1017
1102
  runtime,
@@ -1020,41 +1105,260 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1020
1105
  lastSavedRevisionTokenRef,
1021
1106
  autosaveTimerRef,
1022
1107
  })
1023
- : rejectExportWhileLoading({
1108
+ : rejectExportWhileLoadingFromBoundary({
1024
1109
  documentId,
1110
+ hostAdapter: hostAdapterRef.current,
1025
1111
  datastore: datastoreRef.current,
1026
1112
  onError: onErrorRef.current,
1027
1113
  onEvent: onEventRef.current,
1028
1114
  }));
1029
1115
  }
1030
1116
 
1031
- const selectionPreview = summarizeSelectionPreview(snapshot);
1032
- const derivedCapabilities = deriveCapabilities(snapshot, reviewMode);
1117
+ const derivedCapabilities = deriveCapabilities(
1118
+ snapshot,
1119
+ reviewMode,
1120
+ workflowScopeSnapshot,
1121
+ );
1033
1122
  const capabilities = showReviewPanel
1034
1123
  ? derivedCapabilities
1035
1124
  : { ...derivedCapabilities, reviewRailVisible: false };
1125
+ const formattingState = getFormattingStateFromRenderSnapshot(snapshot);
1126
+ const styleCatalog = useMemo(
1127
+ () => getRuntimeStyleCatalog(canonicalDocument.styles),
1128
+ [canonicalDocument.styles],
1129
+ );
1036
1130
  const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
1037
1131
  const addCommentDisabledReason =
1038
1132
  !capabilities.canAddComment && !snapshot.selection.isCollapsed
1039
1133
  ? "Select text within one paragraph to add a DOCX comment."
1040
1134
  : undefined;
1135
+ const activeImageContext = useMemo(
1136
+ () =>
1137
+ buildActiveImageContext({
1138
+ canonicalDocument,
1139
+ selection: snapshot.selection,
1140
+ storyTarget: viewState.activeStory,
1141
+ surface: snapshot.surface,
1142
+ }),
1143
+ [canonicalDocument, snapshot.selection, snapshot.surface, viewState.activeStory],
1144
+ );
1145
+ const sourcePackage = sessionState.sourcePackage;
1146
+ const mediaPreviewCatalogKey = Object.values(canonicalDocument.media.items)
1147
+ .map((item) =>
1148
+ [
1149
+ item.mediaId,
1150
+ item.packagePartName,
1151
+ item.contentType ?? "",
1152
+ item.widthEmu ?? "",
1153
+ item.heightEmu ?? "",
1154
+ ].join(":"),
1155
+ )
1156
+ .sort()
1157
+ .join("|");
1158
+ const mediaPreviews = useMemo(() => {
1159
+ if (!sourcePackage) {
1160
+ return {} as Record<string, MediaPreviewDescriptor>;
1161
+ }
1162
+ try {
1163
+ const bytes = decodePersistedSourcePackageBytes(sourcePackage);
1164
+ if (!hasValidPersistedSourcePackageDigest(sourcePackage, bytes)) {
1165
+ return {} as Record<string, MediaPreviewDescriptor>;
1166
+ }
1167
+ const opc = readOpcPackage(bytes);
1168
+ const previews: Record<string, MediaPreviewDescriptor> = {};
1169
+ for (const item of Object.values(canonicalDocument.media.items)) {
1170
+ const contentType = item.contentType?.toLowerCase();
1171
+ const part = opc.parts.get(item.packagePartName);
1172
+ if (
1173
+ !part?.bytes ||
1174
+ !contentType ||
1175
+ !BROWSER_SAFE_PREVIEW_TYPES.has(contentType)
1176
+ ) {
1177
+ continue;
1178
+ }
1179
+ previews[item.mediaId] = {
1180
+ src: createImageDataUrl(contentType, part.bytes),
1181
+ ...(item.widthEmu !== undefined ? { widthEmu: item.widthEmu } : {}),
1182
+ ...(item.heightEmu !== undefined ? { heightEmu: item.heightEmu } : {}),
1183
+ };
1184
+ }
1185
+ return previews;
1186
+ } catch {
1187
+ return {} as Record<string, MediaPreviewDescriptor>;
1188
+ }
1189
+ }, [mediaPreviewCatalogKey, sourcePackage?.sha256Hex]);
1190
+ const activeObjectContext = useMemo(
1191
+ () =>
1192
+ viewState.activeObjectFrame && viewState.activeObjectFrame.kind !== "image"
1193
+ ? {
1194
+ kind: viewState.activeObjectFrame.kind,
1195
+ display: viewState.activeObjectFrame.display,
1196
+ }
1197
+ : null,
1198
+ [viewState.activeObjectFrame],
1199
+ );
1200
+ const selectionToolbar = buildSelectionToolbarModel({
1201
+ snapshot,
1202
+ viewState,
1203
+ capabilities,
1204
+ documentNavigation,
1205
+ styleCatalog,
1206
+ formattingState,
1207
+ addCommentDisabledReason,
1208
+ });
1209
+ const selectionToolbarSelectionKey = useMemo(
1210
+ () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory),
1211
+ [snapshot.selection, viewState.activeStory],
1212
+ );
1213
+ const shouldRenderSelectionToolbar = Boolean(
1214
+ selectionToolbar &&
1215
+ selectionToolbarSelectionKey &&
1216
+ selectionToolbarDismissedKey !== selectionToolbarSelectionKey &&
1217
+ (viewState.isFocused || selectionToolbarFocusWithin),
1218
+ );
1041
1219
  const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
1042
1220
  const accessibilityStatusId = `${documentId}-accessibility-status`;
1043
1221
  const accessibilityAlertId = `${documentId}-accessibility-alert`;
1044
1222
 
1045
1223
  const dispatchSelection = (selection: PublicSelectionSnapshot) =>
1046
- activeRuntime.dispatch({
1047
- type: "selection.set",
1048
- selection: toRuntimeSelectionSnapshot(selection),
1049
- });
1224
+ applyRuntimeSelection(activeRuntime, selection);
1225
+
1226
+ const dismissSelectionToolbar = useCallback(
1227
+ (_reason: SelectionToolbarDismissReason) => {
1228
+ if (selectionToolbarSelectionKey) {
1229
+ setSelectionToolbarDismissedKey(selectionToolbarSelectionKey);
1230
+ } else {
1231
+ setSelectionToolbarDismissedKey(null);
1232
+ }
1233
+ setSelectionToolbarAnchor(null);
1234
+ setSelectionToolbarFocusWithin(false);
1235
+ },
1236
+ [selectionToolbarSelectionKey],
1237
+ );
1050
1238
 
1051
- useEffect(() => {
1052
- const shell = shellRef.current;
1053
- if (!shell) {
1054
- return;
1055
- }
1239
+ const getDocumentSurfaceElement = useCallback((): HTMLElement | null => {
1240
+ return shellRef.current?.querySelector<HTMLElement>("[data-wre-document-surface='true']") ?? null;
1241
+ }, []);
1056
1242
 
1057
- applyRegionAttributes(shell);
1243
+ const isTargetWithinSelectionToolbar = useCallback((target: EventTarget | null): boolean => {
1244
+ return target instanceof Node && Boolean(selectionToolbarElementRef.current?.contains(target));
1245
+ }, []);
1246
+
1247
+ const isTargetWithinDocumentSurface = useCallback(
1248
+ (target: EventTarget | null): boolean => {
1249
+ const surfaceElement = getDocumentSurfaceElement();
1250
+ return target instanceof Node && Boolean(surfaceElement?.contains(target));
1251
+ },
1252
+ [getDocumentSurfaceElement],
1253
+ );
1254
+
1255
+ const focusDocumentSurface = useCallback((): void => {
1256
+ const surfaceElement = getDocumentSurfaceElement();
1257
+ surfaceElement?.focus();
1258
+ activeRuntime.focus();
1259
+ }, [activeRuntime, getDocumentSurfaceElement]);
1260
+
1261
+ const handleSurfaceFocus = useCallback(
1262
+ (_event: FocusEvent<HTMLDivElement>) => {
1263
+ setSelectionToolbarFocusWithin(false);
1264
+ activeRuntime.focus();
1265
+ },
1266
+ [activeRuntime],
1267
+ );
1268
+
1269
+ const handleSurfaceBlur = useCallback(
1270
+ (event: FocusEvent<HTMLDivElement>) => {
1271
+ if (isTargetWithinSelectionToolbar(event.relatedTarget)) {
1272
+ setSelectionToolbarFocusWithin(true);
1273
+ activeRuntime.focus();
1274
+ return;
1275
+ }
1276
+
1277
+ setSelectionToolbarFocusWithin(false);
1278
+ activeRuntime.blur();
1279
+ dismissSelectionToolbar("blur");
1280
+ },
1281
+ [activeRuntime, dismissSelectionToolbar, isTargetWithinSelectionToolbar],
1282
+ );
1283
+
1284
+ const handleSelectionToolbarFocusCapture = useCallback(() => {
1285
+ setSelectionToolbarFocusWithin(true);
1286
+ activeRuntime.focus();
1287
+ }, [activeRuntime]);
1288
+
1289
+ const handleSelectionToolbarBlurCapture = useCallback(
1290
+ (event: FocusEvent<HTMLDivElement>) => {
1291
+ if (isTargetWithinSelectionToolbar(event.relatedTarget)) {
1292
+ return;
1293
+ }
1294
+
1295
+ if (isTargetWithinDocumentSurface(event.relatedTarget)) {
1296
+ setSelectionToolbarFocusWithin(false);
1297
+ activeRuntime.focus();
1298
+ return;
1299
+ }
1300
+
1301
+ setSelectionToolbarFocusWithin(false);
1302
+ activeRuntime.blur();
1303
+ dismissSelectionToolbar("blur");
1304
+ },
1305
+ [
1306
+ activeRuntime,
1307
+ dismissSelectionToolbar,
1308
+ isTargetWithinDocumentSurface,
1309
+ isTargetWithinSelectionToolbar,
1310
+ ],
1311
+ );
1312
+
1313
+ const addSelectionToolbarComment = useCallback(() => {
1314
+ const commentId = addReviewComment();
1315
+ if (!commentId) {
1316
+ return;
1317
+ }
1318
+ dismissSelectionToolbar("comment-action");
1319
+ queueMicrotask(() => {
1320
+ focusDocumentSurface();
1321
+ });
1322
+ }, [dismissSelectionToolbar, focusDocumentSurface]);
1323
+
1324
+ const handleSelectionToolbarAnchorChange = useCallback(
1325
+ (nextAnchor: SelectionToolbarAnchor | null) => {
1326
+ setSelectionToolbarAnchor((current) =>
1327
+ selectionToolbarAnchorsEqual(current, nextAnchor) ? current : nextAnchor);
1328
+ },
1329
+ [],
1330
+ );
1331
+
1332
+ useEffect(() => {
1333
+ if (!selectionToolbarSelectionKey) {
1334
+ setSelectionToolbarDismissedKey(null);
1335
+ setSelectionToolbarFocusWithin(false);
1336
+ setSelectionToolbarAnchor(null);
1337
+ lastSelectionToolbarKeyRef.current = null;
1338
+ return;
1339
+ }
1340
+
1341
+ if (lastSelectionToolbarKeyRef.current !== selectionToolbarSelectionKey) {
1342
+ lastSelectionToolbarKeyRef.current = selectionToolbarSelectionKey;
1343
+ setSelectionToolbarDismissedKey(null);
1344
+ setSelectionToolbarFocusWithin(false);
1345
+ }
1346
+ }, [selectionToolbarSelectionKey]);
1347
+
1348
+ useEffect(() => {
1349
+ if (!selectionToolbar) {
1350
+ setSelectionToolbarAnchor(null);
1351
+ setSelectionToolbarFocusWithin(false);
1352
+ }
1353
+ }, [selectionToolbar]);
1354
+
1355
+ useEffect(() => {
1356
+ const shell = shellRef.current;
1357
+ if (!shell) {
1358
+ return;
1359
+ }
1360
+
1361
+ applyRegionAttributes(shell);
1058
1362
  }, [runtime, snapshot.fatalError, snapshot.isReady]);
1059
1363
 
1060
1364
  useEffect(() => {
@@ -1075,6 +1379,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1075
1379
  }, [loadError, snapshot.fatalError]);
1076
1380
 
1077
1381
  function handleShellKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>): void {
1382
+ if (
1383
+ event.key === "Escape" &&
1384
+ shouldRenderSelectionToolbar &&
1385
+ (isTargetWithinDocumentSurface(event.target) || isTargetWithinSelectionToolbar(event.target))
1386
+ ) {
1387
+ event.preventDefault();
1388
+ event.stopPropagation();
1389
+ const restoreSurfaceFocus = isTargetWithinSelectionToolbar(event.target);
1390
+ dismissSelectionToolbar("escape");
1391
+ if (restoreSurfaceFocus) {
1392
+ queueMicrotask(() => {
1393
+ focusDocumentSurface();
1394
+ });
1395
+ }
1396
+ return;
1397
+ }
1398
+
1078
1399
  if (event.key !== "F6") {
1079
1400
  return;
1080
1401
  }
@@ -1089,15 +1410,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1089
1410
  }
1090
1411
 
1091
1412
  const editorCallbacks = {
1092
- onFocus: () => activeRuntime.focus(),
1093
- onBlur: () => activeRuntime.blur(),
1413
+ onFocus: handleSurfaceFocus,
1414
+ onBlur: handleSurfaceBlur,
1094
1415
  onSelectionChange: dispatchSelection,
1095
- onInsertText: (text: string) => activeRuntime.dispatch({ type: "text.insert", text }),
1096
- onDeleteBackward: () => activeRuntime.dispatch({ type: "text.delete-backward" }),
1097
- onDeleteForward: () => activeRuntime.dispatch({ type: "text.delete-forward" }),
1098
- onInsertTab: () => activeRuntime.dispatch({ type: "text.insert-tab" }),
1099
- onInsertHardBreak: () => activeRuntime.dispatch({ type: "text.insert-hard-break" }),
1100
- onSplitParagraph: () => activeRuntime.dispatch({ type: "paragraph.split" }),
1416
+ onInsertText: (text: string) => applyRuntimeTextCommand(activeRuntime, { type: "insert-text", text }),
1417
+ onDeleteBackward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-backward" }),
1418
+ onDeleteForward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-forward" }),
1419
+ onInsertTab: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-tab" }),
1420
+ onOutdentTab: () => applyRuntimeTextCommand(activeRuntime, { type: "outdent-tab" }),
1421
+ onInsertHardBreak: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-hard-break" }),
1422
+ onSplitParagraph: () => applyRuntimeTextCommand(activeRuntime, { type: "split-paragraph" }),
1101
1423
  };
1102
1424
 
1103
1425
  const reviewCallbacks = {
@@ -1145,82 +1467,207 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1145
1467
  activeRuntime.rejectAllChanges();
1146
1468
  setActiveRailTab("changes");
1147
1469
  },
1470
+ onCloseStory: () => {
1471
+ activeRuntime.closeStory();
1472
+ },
1148
1473
  };
1149
1474
 
1475
+ const commands = useCommandBag<EditorCommandBag>({
1476
+ ...reviewCallbacks,
1477
+ onWorkspaceModeChange: (mode) => activeRuntime.setWorkspaceMode(mode),
1478
+ onZoomChange: (level) => activeRuntime.setZoom(level),
1479
+ onActiveRailTabChange: setActiveRailTab,
1480
+ onShowTrackedChangesChange: setShowTrackedChanges,
1481
+ onToggleBold: () =>
1482
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "bold" }),
1483
+ onToggleItalic: () =>
1484
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "italic" }),
1485
+ onToggleUnderline: () =>
1486
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "underline" }),
1487
+ onSetSelectionTextColor: (color) =>
1488
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-text-color", color }),
1489
+ onSetSelectionHighlightColor: (color) =>
1490
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-highlight-color", color }),
1491
+ onToggleStrikethrough: () =>
1492
+ applyRuntimeFormattingOperation(activeRuntime, {
1493
+ type: "toggle",
1494
+ mark: "strikethrough",
1495
+ }),
1496
+ onToggleSuperscript: () =>
1497
+ applyRuntimeFormattingOperation(activeRuntime, {
1498
+ type: "toggle",
1499
+ mark: "superscript",
1500
+ }),
1501
+ onToggleSubscript: () =>
1502
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "subscript" }),
1503
+ onSetFontFamily: (fontFamily) =>
1504
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-font-family", fontFamily }),
1505
+ onSetFontSize: (fontSize) =>
1506
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-font-size", size: fontSize }),
1507
+ onSetTextColor: (color) =>
1508
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-text-color", color }),
1509
+ onSetHighlightColor: (color) =>
1510
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-highlight-color", color }),
1511
+ onSetAlignment: (alignment) =>
1512
+ applyRuntimeFormattingOperation(activeRuntime, { type: "set-alignment", alignment }),
1513
+ onSetParagraphStyle: (styleId) => applyRuntimeParagraphStyle(activeRuntime, styleId),
1514
+ onOutdent: () => applyRuntimeFormattingOperation(activeRuntime, { type: "outdent" }),
1515
+ onIndent: () => applyRuntimeFormattingOperation(activeRuntime, { type: "indent" }),
1516
+ onInsertPageBreak: () => applyRuntimeInsertPageBreak(activeRuntime),
1517
+ onInsertTable: () => applyRuntimeInsertTable(activeRuntime, { rows: 3, columns: 3 }),
1518
+ onInsertSectionBreak: (type) => applyRuntimeInsertSectionBreak(activeRuntime, type),
1519
+ onInsertImage: (options) => applyRuntimeInsertImage(activeRuntime, options),
1520
+ onSetTableStyle: (styleId) => applyRuntimeTableStyle(activeRuntime, styleId),
1521
+ onAddRowBefore: () =>
1522
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1523
+ type: "add-row-before",
1524
+ }),
1525
+ onAddRowAfter: () =>
1526
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1527
+ type: "add-row-after",
1528
+ }),
1529
+ onAddColumnBefore: () =>
1530
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1531
+ type: "add-column-before",
1532
+ }),
1533
+ onAddColumnAfter: () =>
1534
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1535
+ type: "add-column-after",
1536
+ }),
1537
+ onDeleteRow: () =>
1538
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1539
+ type: "delete-row",
1540
+ }),
1541
+ onDeleteColumn: () =>
1542
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1543
+ type: "delete-column",
1544
+ }),
1545
+ onDeleteTable: () =>
1546
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1547
+ type: "delete-table",
1548
+ }),
1549
+ onMergeCells: () =>
1550
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1551
+ type: "merge-cells",
1552
+ }),
1553
+ onSplitCell: () =>
1554
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1555
+ type: "split-cell",
1556
+ }),
1557
+ onSetCellBackground: (color) =>
1558
+ applyRuntimeTableStructureOperation(activeRuntime, surfaceRef.current ?? null, {
1559
+ type: "set-cell-background",
1560
+ color,
1561
+ }),
1562
+ onSetImageLayout: (mediaId, dimensions) =>
1563
+ applyRuntimeImageResize(activeRuntime, mediaId, dimensions),
1564
+ onSetImageFrame: (mediaId, offsets) =>
1565
+ applyRuntimeImageReposition(activeRuntime, mediaId, offsets),
1566
+ onOpenHeaderStory: () =>
1567
+ openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "header"),
1568
+ onOpenFooterStory: () =>
1569
+ openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "footer"),
1570
+ onDeleteSectionBreak: (sectionIndex) =>
1571
+ applyRuntimeDeleteSectionBreak(activeRuntime, sectionIndex),
1572
+ onUpdateSectionLayout: (sectionIndex, patch) =>
1573
+ applyRuntimeUpdateSectionLayout(activeRuntime, sectionIndex, patch),
1574
+ onSetSectionPageNumbering: (sectionIndex, patch) =>
1575
+ applyRuntimeSetSectionPageNumbering(activeRuntime, sectionIndex, patch),
1576
+ onSetHeaderFooterLink: (sectionIndex, patch) =>
1577
+ applyRuntimeSetHeaderFooterLink(activeRuntime, sectionIndex, patch),
1578
+ onSetParagraphIndentation: (indentation) =>
1579
+ applyRuntimeParagraphIndentation(activeRuntime, indentation),
1580
+ onSetParagraphTabStops: (tabStops) =>
1581
+ applyRuntimeParagraphTabStops(activeRuntime, tabStops),
1582
+ onRestartNumbering: () =>
1583
+ applyRuntimeNumberingFlow(activeRuntime, { type: "restart" }),
1584
+ onContinueNumbering: () =>
1585
+ applyRuntimeNumberingFlow(activeRuntime, { type: "continue" }),
1586
+ onNavigateHeading: (headingId) => {
1587
+ const heading = documentNavigation.headings.find(
1588
+ (entry) => entry.headingId === headingId,
1589
+ );
1590
+ if (!heading) {
1591
+ return;
1592
+ }
1593
+ applyRuntimeSelection(
1594
+ activeRuntime,
1595
+ createCollapsedPublicSelection(heading.offset),
1596
+ );
1597
+ },
1598
+ });
1599
+
1600
+ const documentElement = (
1601
+ <EditorSurfaceController
1602
+ ref={surfaceRef}
1603
+ currentUser={currentUser}
1604
+ snapshot={snapshot}
1605
+ canonicalDocument={canonicalDocument}
1606
+ documentNavigation={documentNavigation}
1607
+ reviewMode={reviewMode}
1608
+ markupDisplay={liveMarkupDisplay}
1609
+ activeRevisionId={activeRevisionId}
1610
+ showTrackedChanges={showTrackedChanges}
1611
+ mediaPreviews={mediaPreviews}
1612
+ isPageWorkspace={isPageWorkspace}
1613
+ workflowScopes={workflowScopeSnapshot?.scopes}
1614
+ onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
1615
+ {...editorCallbacks}
1616
+ onCommentActivated={(commentId) => {
1617
+ activeRuntime.openComment(commentId);
1618
+ setActiveRailTab("comments");
1619
+ }}
1620
+ onRevisionActivated={(revisionId) => {
1621
+ setActiveRevisionId(revisionId);
1622
+ setActiveRailTab("changes");
1623
+ }}
1624
+ />
1625
+ );
1626
+
1150
1627
  return (
1151
- <div
1152
- ref={shellRef}
1153
- role="region"
1154
- aria-label={`Word review editor for ${snapshot.sourceLabel ?? documentId}`}
1155
- aria-describedby={`${accessibilityInstructionsId} ${accessibilityStatusId}${
1156
- diagnosticsModeMessage ? ` ${accessibilityAlertId}` : ""
1157
- }`}
1158
- className="relative h-full"
1159
- onKeyDownCapture={handleShellKeyDownCapture}
1160
- >
1161
- <p id={accessibilityInstructionsId} style={VISUALLY_HIDDEN_STYLES}>
1162
- Press F6 to move focus between the toolbar, document surface, review rail, and status bar.
1163
- </p>
1164
- <div
1165
- id={accessibilityStatusId}
1166
- role="status"
1167
- aria-live="polite"
1168
- aria-atomic="true"
1169
- style={VISUALLY_HIDDEN_STYLES}
1170
- >
1171
- {buildAccessibilityStatusMessage(snapshot, loadError ?? undefined)}
1172
- </div>
1173
- {diagnosticsModeMessage ? (
1174
- <div
1175
- id={accessibilityAlertId}
1176
- data-wre-alert="true"
1177
- role="alert"
1178
- aria-live="assertive"
1179
- aria-atomic="true"
1180
- tabIndex={-1}
1181
- className="border-b border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger"
1182
- >
1183
- {diagnosticsModeMessage}
1184
- </div>
1185
- ) : null}
1186
- <TwReviewWorkspace
1187
- snapshot={snapshot}
1188
- currentUserId={currentUser.userId}
1189
- capabilities={capabilities}
1190
- reviewMode={reviewMode}
1191
- viewMode={viewMode}
1192
- activeRailTab={activeRailTab}
1193
- activeCommentId={snapshot.comments.activeCommentId}
1194
- activeRevisionId={activeRevisionId}
1195
- showTrackedChanges={showTrackedChanges}
1196
- selectionPreview={selectionPreview}
1197
- addCommentDisabledReason={addCommentDisabledReason}
1198
- onViewModeChange={setViewMode}
1199
- onActiveRailTabChange={setActiveRailTab}
1200
- onShowTrackedChangesChange={setShowTrackedChanges}
1201
- {...reviewCallbacks}
1202
- document={
1203
- <TwProseMirrorSurface
1204
- ref={surfaceRef}
1205
- currentUser={currentUser}
1206
- snapshot={snapshot}
1207
- reviewMode={reviewMode}
1208
- markupDisplay={liveMarkupDisplay}
1209
- activeRevisionId={activeRevisionId}
1210
- showTrackedChanges={showTrackedChanges}
1211
- {...editorCallbacks}
1212
- onCommentActivated={(commentId) => {
1213
- activeRuntime.openComment(commentId);
1214
- setActiveRailTab("comments");
1215
- }}
1216
- onRevisionActivated={(revisionId) => {
1217
- setActiveRevisionId(revisionId);
1218
- setActiveRailTab("changes");
1219
- }}
1220
- />
1221
- }
1222
- />
1223
- </div>
1628
+ <EditorShellView
1629
+ shellRef={shellRef}
1630
+ documentId={documentId}
1631
+ snapshot={snapshot}
1632
+ loadError={loadError}
1633
+ diagnosticsModeMessage={diagnosticsModeMessage}
1634
+ accessibilityInstructionsId={accessibilityInstructionsId}
1635
+ accessibilityStatusId={accessibilityStatusId}
1636
+ accessibilityAlertId={accessibilityAlertId}
1637
+ accessibilityStatusMessage={buildAccessibilityStatusMessage(
1638
+ snapshot,
1639
+ loadError ?? undefined,
1640
+ )}
1641
+ visuallyHiddenStyles={VISUALLY_HIDDEN_STYLES}
1642
+ onShellKeyDownCapture={handleShellKeyDownCapture}
1643
+ viewState={viewState}
1644
+ markupDisplay={liveMarkupDisplay}
1645
+ currentUserId={currentUser.userId}
1646
+ capabilities={capabilities}
1647
+ documentNavigation={documentNavigation}
1648
+ reviewMode={reviewMode}
1649
+ workspaceMode={viewState.workspaceMode}
1650
+ zoomLevel={viewState.zoomLevel}
1651
+ formattingState={formattingState}
1652
+ styleCatalog={styleCatalog}
1653
+ activeRailTab={activeRailTab}
1654
+ activeCommentId={snapshot.comments.activeCommentId}
1655
+ activeRevisionId={activeRevisionId}
1656
+ showTrackedChanges={showTrackedChanges}
1657
+ workflowScopeSnapshot={workflowScopeSnapshot}
1658
+ interactionGuardSnapshot={interactionGuardSnapshot}
1659
+ selectionToolbar={shouldRenderSelectionToolbar ? selectionToolbar : null}
1660
+ selectionToolbarAnchor={shouldRenderSelectionToolbar ? selectionToolbarAnchor : null}
1661
+ onAddCommentFromSelection={addSelectionToolbarComment}
1662
+ onDismissSelectionToolbar={() => dismissSelectionToolbar("chrome-action")}
1663
+ onSelectionToolbarFocusCapture={handleSelectionToolbarFocusCapture}
1664
+ onSelectionToolbarBlurCapture={handleSelectionToolbarBlurCapture}
1665
+ selectionToolbarRef={selectionToolbarElementRef}
1666
+ activeImageContext={activeImageContext}
1667
+ activeObjectContext={activeObjectContext}
1668
+ commands={commands}
1669
+ document={documentElement}
1670
+ />
1224
1671
  );
1225
1672
  },
1226
1673
  );
@@ -1237,206 +1684,1034 @@ function applyRuntimeFormattingOperation(
1237
1684
  | { type: "indent" }
1238
1685
  | { type: "outdent" },
1239
1686
  ): void {
1240
- const snapshot = runtime.getRenderSnapshot();
1241
- if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
1687
+ const context = getStoryMutationContext(runtime);
1688
+ if (!context) {
1242
1689
  return;
1243
1690
  }
1244
1691
 
1245
1692
  const result = applyFormattingOperationToDocument(
1246
- runtime.getPersistedSnapshot().canonicalDocument,
1247
- snapshot,
1693
+ context.localDocument,
1694
+ context.localSnapshot,
1248
1695
  operation,
1249
1696
  );
1250
- if (!result.changed) {
1697
+ dispatchStoryMutationResult(
1698
+ runtime,
1699
+ context,
1700
+ {
1701
+ ...result,
1702
+ selection: toRuntimeSelectionSnapshot(result.selection),
1703
+ },
1704
+ context.timestamp,
1705
+ );
1706
+ }
1707
+
1708
+ function getRuntimeStyleCatalog(
1709
+ input:
1710
+ | WordReviewEditorRuntime
1711
+ | EditorSessionState["canonicalDocument"]["styles"],
1712
+ ): StyleCatalogSnapshot {
1713
+ const styles =
1714
+ "getSessionState" in input
1715
+ ? input.getSessionState().canonicalDocument.styles
1716
+ : input;
1717
+ const mapRecord = <
1718
+ T extends {
1719
+ styleId: string;
1720
+ displayName: string;
1721
+ kind: "paragraph" | "character" | "table";
1722
+ isDefault: boolean;
1723
+ basedOn?: string;
1724
+ nextStyle?: string;
1725
+ },
1726
+ >(
1727
+ record: Record<string, T>,
1728
+ ) =>
1729
+ Object.values(record)
1730
+ .map((entry) => ({
1731
+ styleId: entry.styleId,
1732
+ displayName: entry.displayName,
1733
+ kind: entry.kind,
1734
+ isDefault: entry.isDefault,
1735
+ ...(entry.basedOn ? { basedOn: entry.basedOn } : {}),
1736
+ ...(entry.nextStyle ? { nextStyle: entry.nextStyle } : {}),
1737
+ }))
1738
+ .sort((left, right) =>
1739
+ left.displayName.localeCompare(right.displayName) ||
1740
+ left.styleId.localeCompare(right.styleId),
1741
+ );
1742
+
1743
+ return {
1744
+ paragraphs: mapRecord(styles.paragraphs),
1745
+ characters: mapRecord(styles.characters),
1746
+ tables: mapRecord(styles.tables),
1747
+ fromPackage: styles.fromPackage === true,
1748
+ };
1749
+ }
1750
+
1751
+ function applyRuntimeParagraphStyle(
1752
+ runtime: WordReviewEditorRuntime,
1753
+ styleId: string | null,
1754
+ ): void {
1755
+ const context = getStoryMutationContext(runtime);
1756
+ if (!context) {
1251
1757
  return;
1252
1758
  }
1253
1759
 
1254
- runtime.dispatch({
1255
- type: "document.replace",
1256
- document: result.document,
1257
- selection: toRuntimeSelectionSnapshot(result.selection),
1258
- origin: {
1259
- source: "api",
1260
- timestamp: new Date().toISOString(),
1760
+ const result = applyParagraphStyleToDocument(
1761
+ context.localDocument,
1762
+ context.localSnapshot,
1763
+ styleId,
1764
+ );
1765
+ dispatchStoryMutationResult(
1766
+ runtime,
1767
+ context,
1768
+ {
1769
+ ...result,
1770
+ selection: toRuntimeSelectionSnapshot(result.selection),
1261
1771
  },
1262
- });
1772
+ context.timestamp,
1773
+ );
1263
1774
  }
1264
1775
 
1265
- function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
1266
- const snapshot = runtime.getRenderSnapshot();
1267
- if (!canApplyRuntimeMutation(snapshot)) {
1776
+ function applyRuntimeTableStyle(
1777
+ runtime: WordReviewEditorRuntime,
1778
+ styleId: string | null,
1779
+ ): void {
1780
+ const context = getStoryMutationContext(runtime);
1781
+ if (!context) {
1268
1782
  return;
1269
1783
  }
1270
1784
 
1271
- const timestamp = new Date().toISOString();
1272
- const result = insertPageBreakInDocument(
1273
- runtime.getPersistedSnapshot().canonicalDocument,
1274
- toRuntimeSelectionSnapshot(snapshot.selection),
1275
- { timestamp },
1785
+ const result = applyTableStyleToDocument(
1786
+ context.localDocument,
1787
+ context.localSnapshot,
1788
+ styleId,
1789
+ );
1790
+ dispatchStoryMutationResult(
1791
+ runtime,
1792
+ context,
1793
+ {
1794
+ ...result,
1795
+ selection: toRuntimeSelectionSnapshot(result.selection),
1796
+ },
1797
+ context.timestamp,
1276
1798
  );
1277
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1278
1799
  }
1279
1800
 
1280
- function applyRuntimeInsertTable(
1801
+ function applyRuntimeParagraphIndentation(
1281
1802
  runtime: WordReviewEditorRuntime,
1282
- options: InsertTableOptions,
1803
+ indentation: {
1804
+ left?: number;
1805
+ right?: number;
1806
+ firstLine?: number;
1807
+ hanging?: number;
1808
+ },
1283
1809
  ): void {
1284
- const snapshot = runtime.getRenderSnapshot();
1285
- if (!canApplyRuntimeMutation(snapshot)) {
1810
+ const context = getStoryMutationContext(runtime);
1811
+ if (!context) {
1286
1812
  return;
1287
1813
  }
1288
1814
 
1289
- const timestamp = new Date().toISOString();
1290
- const result = insertTableInDocument(
1291
- runtime.getPersistedSnapshot().canonicalDocument,
1292
- toRuntimeSelectionSnapshot(snapshot.selection),
1293
- options,
1294
- { timestamp },
1815
+ const result = setActiveParagraphIndentation(
1816
+ context.localDocument,
1817
+ context.localSnapshot,
1818
+ indentation,
1819
+ { timestamp: context.timestamp },
1820
+ );
1821
+ dispatchStoryMutationResult(
1822
+ runtime,
1823
+ context,
1824
+ {
1825
+ ...result,
1826
+ selection: toRuntimeSelectionSnapshot(result.selection),
1827
+ },
1828
+ context.timestamp,
1295
1829
  );
1296
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1297
1830
  }
1298
1831
 
1299
- function applyRuntimeInsertImage(
1832
+ function applyRuntimeParagraphTabStops(
1300
1833
  runtime: WordReviewEditorRuntime,
1301
- options: InsertImageOptions,
1834
+ tabStops: Array<{ pos: number; val?: string; leader?: string }>,
1302
1835
  ): void {
1303
- const snapshot = runtime.getRenderSnapshot();
1304
- if (!canApplyRuntimeMutation(snapshot)) {
1836
+ const context = getStoryMutationContext(runtime);
1837
+ if (!context) {
1305
1838
  return;
1306
1839
  }
1307
1840
 
1308
- const timestamp = new Date().toISOString();
1309
- try {
1310
- const result = insertImageInDocument(
1311
- runtime.getPersistedSnapshot().canonicalDocument,
1312
- toRuntimeSelectionSnapshot(snapshot.selection),
1313
- options.data,
1314
- options.mimeType,
1315
- options.width,
1316
- options.height,
1317
- {
1318
- timestamp,
1319
- altText: options.altText,
1320
- },
1321
- );
1322
- dispatchRuntimeDocumentMutation(runtime, {
1323
- changed: true,
1324
- document: result.document,
1325
- selection: result.selection,
1326
- mapping: result.mapping,
1327
- }, timestamp);
1328
- } catch {
1841
+ const result = setActiveParagraphTabStops(
1842
+ context.localDocument,
1843
+ context.localSnapshot,
1844
+ tabStops,
1845
+ { timestamp: context.timestamp },
1846
+ );
1847
+ dispatchStoryMutationResult(
1848
+ runtime,
1849
+ context,
1850
+ {
1851
+ ...result,
1852
+ selection: toRuntimeSelectionSnapshot(result.selection),
1853
+ },
1854
+ context.timestamp,
1855
+ );
1856
+ }
1857
+
1858
+ function applyRuntimeNumberingFlow(
1859
+ runtime: WordReviewEditorRuntime,
1860
+ operation: { type: "restart"; startAt?: number } | { type: "continue" },
1861
+ ): void {
1862
+ const context = getStoryMutationContext(runtime);
1863
+ if (!context) {
1329
1864
  return;
1330
1865
  }
1866
+
1867
+ const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
1868
+ if (!paragraphContext?.paragraph.numbering) {
1869
+ return;
1870
+ }
1871
+
1872
+ const result =
1873
+ operation.type === "restart"
1874
+ ? restartListNumbering(
1875
+ context.localDocument,
1876
+ paragraphContext.paragraphIndex,
1877
+ { timestamp: context.timestamp },
1878
+ operation.startAt,
1879
+ )
1880
+ : continueListNumbering(
1881
+ context.localDocument,
1882
+ paragraphContext.paragraphIndex,
1883
+ { timestamp: context.timestamp },
1884
+ );
1885
+
1886
+ dispatchStoryMutationResult(
1887
+ runtime,
1888
+ context,
1889
+ {
1890
+ changed: result.affectedParagraphIndexes.length > 0,
1891
+ document: result.document,
1892
+ selection: toRuntimeSelectionSnapshot(context.localSnapshot.selection),
1893
+ },
1894
+ context.timestamp,
1895
+ );
1331
1896
  }
1332
1897
 
1333
- function applyRuntimeTableStructureOperation(
1898
+ function applyRuntimeInsertSectionBreak(
1334
1899
  runtime: WordReviewEditorRuntime,
1335
- mountedSurface: TwProseMirrorSurfaceRef | null | undefined,
1336
- operation:
1337
- | { type: "add-row-before" }
1338
- | { type: "add-row-after" }
1339
- | { type: "add-column-before" }
1340
- | { type: "add-column-after" }
1341
- | { type: "delete-row" }
1342
- | { type: "delete-column" }
1343
- | { type: "delete-table" }
1344
- | { type: "merge-cells" }
1345
- | { type: "split-cell" }
1346
- | { type: "set-cell-background"; color: string },
1900
+ breakType: SectionBreakType,
1901
+ options?: { afterSectionIndex?: number },
1347
1902
  ): void {
1348
1903
  const snapshot = runtime.getRenderSnapshot();
1349
- if (!canApplyRuntimeMutation(snapshot)) {
1904
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1350
1905
  return;
1351
1906
  }
1352
1907
 
1908
+ const sessionState = runtime.getSessionState();
1353
1909
  const timestamp = new Date().toISOString();
1354
- const result = applyTableStructureOperation(
1355
- runtime.getPersistedSnapshot().canonicalDocument,
1356
- snapshot,
1357
- mountedSurface?.getTableSelection() ?? null,
1358
- operation,
1910
+ const result =
1911
+ typeof options?.afterSectionIndex === "number"
1912
+ ? insertSectionBreakAfterSectionIndex(
1913
+ sessionState.canonicalDocument,
1914
+ options.afterSectionIndex,
1915
+ breakType,
1916
+ { timestamp },
1917
+ )
1918
+ : insertSectionBreakAfterSectionIndex(
1919
+ sessionState.canonicalDocument,
1920
+ runtime.getDocumentNavigationSnapshot().activeSectionIndex,
1921
+ breakType,
1922
+ { timestamp },
1923
+ );
1924
+
1925
+ dispatchRuntimeDocumentMutation(
1926
+ runtime,
1927
+ {
1928
+ changed: result.changed,
1929
+ document: result.document,
1930
+ selection: toRuntimeSelectionSnapshot(result.selection),
1931
+ },
1932
+ timestamp,
1359
1933
  );
1360
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1361
1934
  }
1362
1935
 
1363
- function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
1364
- return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
1936
+ function applyRuntimeDeleteSectionBreak(
1937
+ runtime: WordReviewEditorRuntime,
1938
+ sectionIndex: number,
1939
+ ): void {
1940
+ const snapshot = runtime.getRenderSnapshot();
1941
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1942
+ return;
1943
+ }
1944
+
1945
+ const sessionState = runtime.getSessionState();
1946
+ const timestamp = new Date().toISOString();
1947
+ const result = deleteSectionBreakAtSectionIndex(
1948
+ sessionState.canonicalDocument,
1949
+ sectionIndex,
1950
+ { timestamp },
1951
+ );
1952
+
1953
+ dispatchRuntimeDocumentMutation(
1954
+ runtime,
1955
+ {
1956
+ changed: result.changed,
1957
+ document: result.document,
1958
+ selection: toRuntimeSelectionSnapshot(result.selection),
1959
+ },
1960
+ timestamp,
1961
+ );
1365
1962
  }
1366
1963
 
1367
- function dispatchRuntimeDocumentMutation(
1964
+ function applyRuntimeUpdateSectionLayout(
1368
1965
  runtime: WordReviewEditorRuntime,
1369
- result: {
1370
- changed: boolean;
1371
- document: PersistedEditorSnapshot["canonicalDocument"];
1372
- selection: InternalSelectionSnapshot;
1373
- mapping?: TransactionMapping;
1374
- },
1375
- timestamp: string,
1966
+ sectionIndex: number,
1967
+ patch: SectionLayoutPatch,
1376
1968
  ): void {
1377
- if (!result.changed) {
1969
+ const snapshot = runtime.getRenderSnapshot();
1970
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1378
1971
  return;
1379
1972
  }
1380
1973
 
1381
- runtime.dispatch({
1382
- type: "document.replace",
1383
- document: {
1384
- ...result.document,
1385
- updatedAt: timestamp,
1974
+ const sessionState = runtime.getSessionState();
1975
+ const timestamp = new Date().toISOString();
1976
+ const result = updateSectionLayoutAtSectionIndex(
1977
+ sessionState.canonicalDocument,
1978
+ sectionIndex,
1979
+ {
1980
+ ...(patch.pageSize ? { pageSize: patch.pageSize } : {}),
1981
+ ...(patch.pageMargins ? { pageMargins: patch.pageMargins } : {}),
1982
+ ...(patch.columns ? { columns: patch.columns } : {}),
1983
+ ...(patch.titlePage !== undefined ? { titlePage: patch.titlePage } : {}),
1984
+ ...(patch.sectionType ? { sectionType: patch.sectionType } : {}),
1386
1985
  },
1387
- mapping: result.mapping,
1388
- selection: result.selection,
1389
- origin: {
1390
- source: "api",
1391
- timestamp,
1986
+ { timestamp },
1987
+ );
1988
+
1989
+ dispatchRuntimeDocumentMutation(
1990
+ runtime,
1991
+ {
1992
+ changed: result.changed,
1993
+ document: result.document,
1994
+ selection: toRuntimeSelectionSnapshot(result.selection),
1392
1995
  },
1393
- });
1996
+ timestamp,
1997
+ );
1394
1998
  }
1395
1999
 
1396
- function applyRuntimeDeleteComment(
2000
+ function applyRuntimeSetSectionPageNumbering(
1397
2001
  runtime: WordReviewEditorRuntime,
1398
- commentId: string,
2002
+ sectionIndex: number,
2003
+ patch: SectionPageNumberingPatch | null,
1399
2004
  ): void {
1400
2005
  const snapshot = runtime.getRenderSnapshot();
1401
- if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
2006
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1402
2007
  return;
1403
2008
  }
1404
2009
 
1405
- const persistedSnapshot = runtime.getPersistedSnapshot();
1406
- if (!persistedSnapshot.canonicalDocument.review.comments[commentId]) {
2010
+ const sessionState = runtime.getSessionState();
2011
+ const timestamp = new Date().toISOString();
2012
+ const normalizedPatch =
2013
+ patch === null
2014
+ ? null
2015
+ : {
2016
+ ...(patch.format !== undefined
2017
+ ? { format: patch.format ?? undefined }
2018
+ : {}),
2019
+ ...(patch.start !== undefined
2020
+ ? { start: patch.start ?? undefined }
2021
+ : {}),
2022
+ ...(patch.chapterStyle !== undefined
2023
+ ? { chapStyle: patch.chapterStyle ?? undefined }
2024
+ : {}),
2025
+ ...(patch.chapterSeparator !== undefined
2026
+ ? { chapSep: patch.chapterSeparator ?? undefined }
2027
+ : {}),
2028
+ };
2029
+ const result = setSectionPageNumberingAtSectionIndex(
2030
+ sessionState.canonicalDocument,
2031
+ sectionIndex,
2032
+ normalizedPatch,
2033
+ { timestamp },
2034
+ );
2035
+
2036
+ dispatchRuntimeDocumentMutation(
2037
+ runtime,
2038
+ {
2039
+ changed: result.changed,
2040
+ document: result.document,
2041
+ selection: toRuntimeSelectionSnapshot(result.selection),
2042
+ },
2043
+ timestamp,
2044
+ );
2045
+ }
2046
+
2047
+ function applyRuntimeSetHeaderFooterLink(
2048
+ runtime: WordReviewEditorRuntime,
2049
+ sectionIndex: number,
2050
+ patch: HeaderFooterLinkPatch,
2051
+ ): void {
2052
+ const snapshot = runtime.getRenderSnapshot();
2053
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1407
2054
  return;
1408
2055
  }
1409
2056
 
1410
- const nextComments = {
1411
- ...persistedSnapshot.canonicalDocument.review.comments,
1412
- };
1413
- delete nextComments[commentId];
2057
+ const sessionState = runtime.getSessionState();
2058
+ const timestamp = new Date().toISOString();
2059
+ const result = setHeaderFooterLinkAtSectionIndex(
2060
+ sessionState.canonicalDocument,
2061
+ sectionIndex,
2062
+ patch,
2063
+ { timestamp },
2064
+ );
1414
2065
 
1415
- runtime.dispatch({
1416
- type: "document.replace",
1417
- document: {
1418
- ...persistedSnapshot.canonicalDocument,
1419
- review: {
1420
- ...persistedSnapshot.canonicalDocument.review,
1421
- comments: nextComments,
1422
- },
1423
- },
1424
- selection: toRuntimeSelectionSnapshot(snapshot.selection),
1425
- origin: {
1426
- source: "api",
1427
- timestamp: new Date().toISOString(),
2066
+ dispatchRuntimeDocumentMutation(
2067
+ runtime,
2068
+ {
2069
+ changed: result.changed,
2070
+ document: result.document,
2071
+ selection: toRuntimeSelectionSnapshot(result.selection),
1428
2072
  },
1429
- });
2073
+ timestamp,
2074
+ );
1430
2075
  }
1431
2076
 
1432
- function normalizeRequestedSelection(
1433
- snapshot: RuntimeRenderSnapshot,
1434
- selection: PublicSelectionSnapshot | null,
2077
+ function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
2078
+ const context = getStoryMutationContext(runtime);
2079
+ if (!context) {
2080
+ return;
2081
+ }
2082
+
2083
+ const result = insertPageBreakInDocument(
2084
+ context.localDocument,
2085
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2086
+ { timestamp: context.timestamp },
2087
+ );
2088
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2089
+ }
2090
+
2091
+ function applyRuntimeInsertTable(
2092
+ runtime: WordReviewEditorRuntime,
2093
+ options: InsertTableOptions,
2094
+ ): void {
2095
+ const context = getStoryMutationContext(runtime);
2096
+ if (!context) {
2097
+ return;
2098
+ }
2099
+
2100
+ const result = insertTableInDocument(
2101
+ context.localDocument,
2102
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2103
+ options,
2104
+ { timestamp: context.timestamp },
2105
+ );
2106
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2107
+ }
2108
+
2109
+ function applyRuntimeInsertImage(
2110
+ runtime: WordReviewEditorRuntime,
2111
+ options: InsertImageOptions,
2112
+ ): void {
2113
+ const context = getStoryMutationContext(runtime);
2114
+ if (!context) {
2115
+ return;
2116
+ }
2117
+
2118
+ try {
2119
+ const result = insertImageInDocument(
2120
+ context.localDocument,
2121
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2122
+ options.data,
2123
+ options.mimeType,
2124
+ options.width,
2125
+ options.height,
2126
+ {
2127
+ timestamp: context.timestamp,
2128
+ altText: options.altText,
2129
+ },
2130
+ );
2131
+ dispatchStoryMutationResult(runtime, context, {
2132
+ changed: true,
2133
+ document: result.document,
2134
+ selection: result.selection,
2135
+ mapping: result.mapping,
2136
+ }, context.timestamp);
2137
+ } catch {
2138
+ return;
2139
+ }
2140
+ }
2141
+
2142
+ function applyRuntimeImageResize(
2143
+ runtime: WordReviewEditorRuntime,
2144
+ mediaId: string,
2145
+ dimensions: { widthEmu: number; heightEmu: number },
2146
+ ): void {
2147
+ const snapshot = runtime.getRenderSnapshot();
2148
+ if (!canApplyRuntimeMutation(snapshot)) {
2149
+ return;
2150
+ }
2151
+
2152
+ try {
2153
+ const sessionState = runtime.getSessionState();
2154
+ const result = resizeImageInCatalog(
2155
+ sessionState.canonicalDocument,
2156
+ mediaId,
2157
+ dimensions,
2158
+ );
2159
+ runtime.dispatch({
2160
+ type: "document.replace",
2161
+ document: result.document,
2162
+ selection: toRuntimeSelectionSnapshot(snapshot.selection),
2163
+ origin: { source: "api", timestamp: new Date().toISOString() },
2164
+ });
2165
+ } catch {
2166
+ return;
2167
+ }
2168
+ }
2169
+
2170
+ function applyRuntimeImageReposition(
2171
+ runtime: WordReviewEditorRuntime,
2172
+ mediaId: string,
2173
+ offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
2174
+ ): void {
2175
+ const context = getStoryMutationContext(runtime);
2176
+ if (!context) {
2177
+ return;
2178
+ }
2179
+
2180
+ try {
2181
+ const result = repositionFloatingImageInDocument(
2182
+ context.localDocument,
2183
+ mediaId,
2184
+ offsets,
2185
+ context.timestamp,
2186
+ );
2187
+ dispatchStoryMutationResult(
2188
+ runtime,
2189
+ context,
2190
+ {
2191
+ changed: true,
2192
+ document: result.document,
2193
+ selection: toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2194
+ },
2195
+ context.timestamp,
2196
+ );
2197
+ } catch {
2198
+ return;
2199
+ }
2200
+ }
2201
+
2202
+ // deriveViewState removed — the runtime's getViewState() is now the single
2203
+ // source of truth for EditorViewStateSnapshot, backed by view-state.ts.
2204
+
2205
+ function applyRuntimeTableStructureOperation(
2206
+ runtime: WordReviewEditorRuntime,
2207
+ mountedSurface: TwProseMirrorSurfaceRef | null | undefined,
2208
+ operation:
2209
+ | { type: "add-row-before" }
2210
+ | { type: "add-row-after" }
2211
+ | { type: "add-column-before" }
2212
+ | { type: "add-column-after" }
2213
+ | { type: "delete-row" }
2214
+ | { type: "delete-column" }
2215
+ | { type: "delete-table" }
2216
+ | { type: "merge-cells" }
2217
+ | { type: "split-cell" }
2218
+ | { type: "set-cell-background"; color: string },
2219
+ ): void {
2220
+ const context = getStoryMutationContext(runtime);
2221
+ if (!context) {
2222
+ return;
2223
+ }
2224
+
2225
+ const result = applyTableStructureOperation(
2226
+ context.localDocument,
2227
+ context.localSnapshot,
2228
+ mountedSurface?.getTableSelection() ?? null,
2229
+ operation,
2230
+ );
2231
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2232
+ }
2233
+
2234
+ function applyRuntimeTextCommand(
2235
+ runtime: WordReviewEditorRuntime,
2236
+ command:
2237
+ | { type: "insert-text"; text: string }
2238
+ | { type: "delete-backward" }
2239
+ | { type: "delete-forward" }
2240
+ | { type: "insert-tab" }
2241
+ | { type: "outdent-tab" }
2242
+ | { type: "insert-hard-break" }
2243
+ | { type: "split-paragraph" },
2244
+ ): void {
2245
+ const context = getStoryMutationContext(runtime);
2246
+ if (!context) {
2247
+ return;
2248
+ }
2249
+
2250
+ const listAwareResult = applyListAwareTextCommand(context, command);
2251
+ if (listAwareResult) {
2252
+ dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
2253
+ return;
2254
+ }
2255
+
2256
+ if (context.activeStory.kind === "main") {
2257
+ switch (command.type) {
2258
+ case "insert-text":
2259
+ runtime.dispatch({ type: "text.insert", text: command.text });
2260
+ return;
2261
+ case "delete-backward":
2262
+ runtime.dispatch({ type: "text.delete-backward" });
2263
+ return;
2264
+ case "delete-forward":
2265
+ runtime.dispatch({ type: "text.delete-forward" });
2266
+ return;
2267
+ case "insert-tab":
2268
+ runtime.dispatch({ type: "text.insert-tab" });
2269
+ return;
2270
+ case "outdent-tab":
2271
+ return;
2272
+ case "insert-hard-break":
2273
+ runtime.dispatch({ type: "text.insert-hard-break" });
2274
+ return;
2275
+ case "split-paragraph":
2276
+ runtime.dispatch({ type: "paragraph.split" });
2277
+ return;
2278
+ }
2279
+ }
2280
+
2281
+ const selection = toRuntimeSelectionSnapshot(context.localSnapshot.selection);
2282
+ const localResult = (() => {
2283
+ switch (command.type) {
2284
+ case "insert-text":
2285
+ return insertTextInDocument(
2286
+ context.localDocument,
2287
+ selection,
2288
+ command.text,
2289
+ { timestamp: context.timestamp },
2290
+ );
2291
+ case "delete-backward":
2292
+ return deleteSelectionOrBackward(
2293
+ context.localDocument,
2294
+ selection,
2295
+ { timestamp: context.timestamp },
2296
+ );
2297
+ case "delete-forward":
2298
+ return deleteSelectionOrForward(
2299
+ context.localDocument,
2300
+ selection,
2301
+ { timestamp: context.timestamp },
2302
+ );
2303
+ case "insert-tab":
2304
+ return insertTabInDocument(
2305
+ context.localDocument,
2306
+ selection,
2307
+ { timestamp: context.timestamp },
2308
+ );
2309
+ case "outdent-tab":
2310
+ return {
2311
+ changed: false,
2312
+ document: context.localDocument,
2313
+ selection,
2314
+ };
2315
+ case "insert-hard-break":
2316
+ return insertHardBreakInDocument(
2317
+ context.localDocument,
2318
+ selection,
2319
+ { timestamp: context.timestamp },
2320
+ );
2321
+ case "split-paragraph":
2322
+ return splitParagraphInDocument(
2323
+ context.localDocument,
2324
+ selection,
2325
+ { timestamp: context.timestamp },
2326
+ );
2327
+ }
2328
+ })();
2329
+
2330
+ dispatchStoryMutationResult(
2331
+ runtime,
2332
+ context,
2333
+ {
2334
+ changed: "changed" in localResult ? localResult.changed : true,
2335
+ document: localResult.document,
2336
+ selection: localResult.selection,
2337
+ mapping: "mapping" in localResult ? localResult.mapping : undefined,
2338
+ },
2339
+ context.timestamp,
2340
+ );
2341
+ }
2342
+
2343
+ function applyListAwareTextCommand(
2344
+ context: NonNullable<ReturnType<typeof getStoryMutationContext>>,
2345
+ command:
2346
+ | { type: "insert-text"; text: string }
2347
+ | { type: "delete-backward" }
2348
+ | { type: "delete-forward" }
2349
+ | { type: "insert-tab" }
2350
+ | { type: "outdent-tab" }
2351
+ | { type: "insert-hard-break" }
2352
+ | { type: "split-paragraph" },
2353
+ ): {
2354
+ changed: boolean;
2355
+ document: EditorSessionState["canonicalDocument"];
2356
+ selection: InternalSelectionSnapshot;
2357
+ } | null {
2358
+ const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
2359
+ if (!paragraphContext?.paragraph.numbering) {
2360
+ return null;
2361
+ }
2362
+
2363
+ switch (command.type) {
2364
+ case "insert-tab": {
2365
+ const result = indentListItems(
2366
+ context.localDocument,
2367
+ [paragraphContext.paragraphIndex],
2368
+ { timestamp: context.timestamp },
2369
+ );
2370
+ return createListMutationResult(result, context.localSnapshot.selection);
2371
+ }
2372
+ case "outdent-tab": {
2373
+ const result = outdentListItems(
2374
+ context.localDocument,
2375
+ [paragraphContext.paragraphIndex],
2376
+ { timestamp: context.timestamp },
2377
+ );
2378
+ return createListMutationResult(result, context.localSnapshot.selection);
2379
+ }
2380
+ case "delete-backward": {
2381
+ if (!paragraphContext.atParagraphStart || !context.localSnapshot.selection.isCollapsed) {
2382
+ return null;
2383
+ }
2384
+ const result = backspaceAtListStart(
2385
+ context.localDocument,
2386
+ paragraphContext.paragraphIndex,
2387
+ { timestamp: context.timestamp },
2388
+ );
2389
+ return result.handled
2390
+ ? createListMutationResult(result, context.localSnapshot.selection)
2391
+ : null;
2392
+ }
2393
+ case "split-paragraph": {
2394
+ if (!context.localSnapshot.selection.isCollapsed || !paragraphContext.isEmpty) {
2395
+ return null;
2396
+ }
2397
+ const result = splitListParagraph(
2398
+ context.localDocument,
2399
+ paragraphContext.paragraphIndex,
2400
+ true,
2401
+ { timestamp: context.timestamp },
2402
+ );
2403
+ return result.action === "split"
2404
+ ? null
2405
+ : createListMutationResult(result, context.localSnapshot.selection);
2406
+ }
2407
+ default:
2408
+ return null;
2409
+ }
2410
+ }
2411
+
2412
+ function createListMutationResult(
2413
+ result: {
2414
+ document: EditorSessionState["canonicalDocument"];
2415
+ affectedParagraphIndexes: number[];
2416
+ },
2417
+ selection: RuntimeRenderSnapshot["selection"],
2418
+ ): {
2419
+ changed: boolean;
2420
+ document: EditorSessionState["canonicalDocument"];
2421
+ selection: InternalSelectionSnapshot;
2422
+ } {
2423
+ return {
2424
+ changed: result.affectedParagraphIndexes.length > 0,
2425
+ document: result.document,
2426
+ selection: toRuntimeSelectionSnapshot(selection),
2427
+ };
2428
+ }
2429
+
2430
+ function resolveActiveParagraphContext(
2431
+ snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
2432
+ ): {
2433
+ paragraphIndex: number;
2434
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
2435
+ atParagraphStart: boolean;
2436
+ isEmpty: boolean;
2437
+ } | null {
2438
+ if (!snapshot.surface) {
2439
+ return null;
2440
+ }
2441
+
2442
+ const paragraphIndex = resolveActiveParagraphIndex(
2443
+ snapshot.surface.blocks,
2444
+ snapshot.selection,
2445
+ );
2446
+ if (paragraphIndex === null) {
2447
+ return null;
2448
+ }
2449
+
2450
+ const selectionPosition =
2451
+ snapshot.selection.activeRange.kind === "node"
2452
+ ? snapshot.selection.activeRange.at
2453
+ : snapshot.selection.head;
2454
+ const paragraph = findSurfaceParagraphAtPosition(snapshot.surface.blocks, selectionPosition);
2455
+ if (!paragraph) {
2456
+ return null;
2457
+ }
2458
+
2459
+ return {
2460
+ paragraphIndex,
2461
+ paragraph,
2462
+ atParagraphStart:
2463
+ snapshot.selection.isCollapsed &&
2464
+ snapshot.selection.activeRange.kind !== "node" &&
2465
+ snapshot.selection.anchor === snapshot.selection.head &&
2466
+ snapshot.selection.head === paragraph.from,
2467
+ isEmpty: isSurfaceParagraphEmpty(paragraph),
2468
+ };
2469
+ }
2470
+
2471
+ function findSurfaceParagraphAtPosition(
2472
+ blocks: readonly SurfaceBlockSnapshot[],
2473
+ position: number,
2474
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
2475
+ for (const block of blocks) {
2476
+ if (position < block.from || position > block.to) {
2477
+ continue;
2478
+ }
2479
+ if (block.kind === "paragraph") {
2480
+ return block;
2481
+ }
2482
+ if (block.kind === "table") {
2483
+ for (const row of block.rows) {
2484
+ for (const cell of row.cells) {
2485
+ const paragraph = findSurfaceParagraphAtPosition(cell.content, position);
2486
+ if (paragraph) {
2487
+ return paragraph;
2488
+ }
2489
+ }
2490
+ }
2491
+ continue;
2492
+ }
2493
+ if (block.kind === "sdt_block") {
2494
+ const paragraph = findSurfaceParagraphAtPosition(block.children, position);
2495
+ if (paragraph) {
2496
+ return paragraph;
2497
+ }
2498
+ }
2499
+ }
2500
+ return null;
2501
+ }
2502
+
2503
+ function isSurfaceParagraphEmpty(
2504
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
2505
+ ): boolean {
2506
+ if (paragraph.segments.length === 0) {
2507
+ return true;
2508
+ }
2509
+ return paragraph.segments.every((segment) => segment.kind === "text" && segment.text.length === 0);
2510
+ }
2511
+
2512
+ function applyRuntimeSelection(
2513
+ runtime: WordReviewEditorRuntime,
2514
+ selection: PublicSelectionSnapshot,
2515
+ ): void {
2516
+ const requestedStory = selection.storyTarget ?? { kind: "main" };
2517
+ if (requestedStory.kind === "main") {
2518
+ runtime.closeStory();
2519
+ } else if (!storyTargetsEqual(runtime.getActiveStory(), requestedStory)) {
2520
+ if (!runtime.openStory(requestedStory)) {
2521
+ return;
2522
+ }
2523
+ }
2524
+
2525
+ runtime.dispatch({
2526
+ type: "selection.set",
2527
+ selection: toRuntimeSelectionSnapshot(stripStoryTarget(selection)),
2528
+ });
2529
+ }
2530
+
2531
+ function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
2532
+ return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
2533
+ }
2534
+
2535
+ function getStoryMutationContext(
2536
+ runtime: WordReviewEditorRuntime,
2537
+ ): {
2538
+ timestamp: string;
2539
+ activeStory: EditorStoryTarget;
2540
+ persistedDocument: EditorSessionState["canonicalDocument"];
2541
+ localDocument: EditorSessionState["canonicalDocument"];
2542
+ localSnapshot: RuntimeRenderSnapshot;
2543
+ } | null {
2544
+ const snapshot = runtime.getRenderSnapshot();
2545
+ if (!canApplyRuntimeMutation(snapshot)) {
2546
+ return null;
2547
+ }
2548
+
2549
+ const persistedDocument = runtime.getSessionState().canonicalDocument;
2550
+ const activeStory = snapshot.activeStory;
2551
+ if (activeStory.kind === "main") {
2552
+ return {
2553
+ timestamp: new Date().toISOString(),
2554
+ activeStory,
2555
+ persistedDocument,
2556
+ localDocument: persistedDocument,
2557
+ localSnapshot: snapshot,
2558
+ };
2559
+ }
2560
+
2561
+ return {
2562
+ timestamp: new Date().toISOString(),
2563
+ activeStory,
2564
+ persistedDocument,
2565
+ localDocument: {
2566
+ ...persistedDocument,
2567
+ content: {
2568
+ type: "doc",
2569
+ children: [...getStoryBlocks(persistedDocument, activeStory)],
2570
+ },
2571
+ },
2572
+ localSnapshot: {
2573
+ ...snapshot,
2574
+ activeStory: { kind: "main" },
2575
+ selection: stripStoryTarget(snapshot.selection),
2576
+ },
2577
+ };
2578
+ }
2579
+
2580
+ function dispatchStoryMutationResult(
2581
+ runtime: WordReviewEditorRuntime,
2582
+ context: {
2583
+ activeStory: EditorStoryTarget;
2584
+ persistedDocument: EditorSessionState["canonicalDocument"];
2585
+ },
2586
+ result: {
2587
+ changed: boolean;
2588
+ document: EditorSessionState["canonicalDocument"];
2589
+ selection: InternalSelectionSnapshot;
2590
+ mapping?: TransactionMapping;
2591
+ },
2592
+ timestamp: string,
2593
+ ): void {
2594
+ if (context.activeStory.kind === "main") {
2595
+ dispatchRuntimeDocumentMutation(runtime, result, timestamp);
2596
+ return;
2597
+ }
2598
+
2599
+ if (!result.changed) {
2600
+ return;
2601
+ }
2602
+
2603
+ const nextDocument = replaceStoryBlocks(
2604
+ context.persistedDocument,
2605
+ context.activeStory,
2606
+ result.document.content.children,
2607
+ );
2608
+ dispatchRuntimeDocumentMutation(
2609
+ runtime,
2610
+ {
2611
+ changed: true,
2612
+ document: nextDocument,
2613
+ selection: result.selection,
2614
+ },
2615
+ timestamp,
2616
+ );
2617
+ }
2618
+
2619
+ function dispatchRuntimeDocumentMutation(
2620
+ runtime: WordReviewEditorRuntime,
2621
+ result: {
2622
+ changed: boolean;
2623
+ document: EditorSessionState["canonicalDocument"];
2624
+ selection: InternalSelectionSnapshot;
2625
+ mapping?: TransactionMapping;
2626
+ },
2627
+ timestamp: string,
2628
+ ): void {
2629
+ if (!result.changed) {
2630
+ return;
2631
+ }
2632
+
2633
+ runtime.dispatch({
2634
+ type: "document.replace",
2635
+ document: {
2636
+ ...result.document,
2637
+ updatedAt: timestamp,
2638
+ },
2639
+ mapping: result.mapping,
2640
+ selection: result.selection,
2641
+ origin: {
2642
+ source: "api",
2643
+ timestamp,
2644
+ },
2645
+ });
2646
+ }
2647
+
2648
+ function stripStoryTarget(
2649
+ selection: PublicSelectionSnapshot,
2650
+ ): PublicSelectionSnapshot {
2651
+ const { storyTarget: _storyTarget, ...rest } = selection;
2652
+ return rest;
2653
+ }
2654
+
2655
+ function applyRuntimeDeleteComment(
2656
+ runtime: WordReviewEditorRuntime,
2657
+ commentId: string,
2658
+ ): void {
2659
+ const snapshot = runtime.getRenderSnapshot();
2660
+ if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
2661
+ return;
2662
+ }
2663
+
2664
+ const sessionState = runtime.getSessionState();
2665
+ if (!sessionState.canonicalDocument.review.comments[commentId]) {
2666
+ return;
2667
+ }
2668
+
2669
+ const nextComments = {
2670
+ ...sessionState.canonicalDocument.review.comments,
2671
+ };
2672
+ delete nextComments[commentId];
2673
+
2674
+ runtime.dispatch({
2675
+ type: "document.replace",
2676
+ document: {
2677
+ ...sessionState.canonicalDocument,
2678
+ review: {
2679
+ ...sessionState.canonicalDocument.review,
2680
+ comments: nextComments,
2681
+ },
2682
+ },
2683
+ selection: toRuntimeSelectionSnapshot(snapshot.selection),
2684
+ origin: {
2685
+ source: "api",
2686
+ timestamp: new Date().toISOString(),
2687
+ },
2688
+ });
2689
+ }
2690
+
2691
+ function normalizeRequestedSelection(
2692
+ snapshot: RuntimeRenderSnapshot,
2693
+ selection: PublicSelectionSnapshot | null,
1435
2694
  ): PublicSelectionSnapshot {
1436
- return selection ?? createCollapsedPublicSelection(snapshot.selection.head);
2695
+ return (
2696
+ selection ??
2697
+ createCollapsedPublicSelection(
2698
+ snapshot.selection.head,
2699
+ snapshot.activeStory.kind === "main" ? undefined : snapshot.activeStory,
2700
+ )
2701
+ );
1437
2702
  }
1438
2703
 
1439
- function createCollapsedPublicSelection(position: number): PublicSelectionSnapshot {
2704
+ export function __resolveLiveMarkupDisplay(
2705
+ requested: MarkupDisplay | undefined,
2706
+ isPageWorkspace: boolean,
2707
+ ): MarkupDisplay {
2708
+ return requested ?? (isPageWorkspace ? "all" : "clean");
2709
+ }
2710
+
2711
+ function createCollapsedPublicSelection(
2712
+ position: number,
2713
+ storyTarget?: EditorStoryTarget,
2714
+ ): PublicSelectionSnapshot {
1440
2715
  return {
1441
2716
  anchor: position,
1442
2717
  head: position,
@@ -1450,6 +2725,7 @@ function createCollapsedPublicSelection(position: number): PublicSelectionSnapsh
1450
2725
  end: 1,
1451
2726
  },
1452
2727
  },
2728
+ ...(storyTarget ? { storyTarget } : {}),
1453
2729
  };
1454
2730
  }
1455
2731
 
@@ -1457,61 +2733,66 @@ function clonePublicValue<T>(value: T): T {
1457
2733
  return structuredClone(value);
1458
2734
  }
1459
2735
 
1460
- function searchSnapshotSurface(
1461
- snapshot: RuntimeRenderSnapshot,
1462
- query: string,
1463
- options: SearchOptions = {},
1464
- ): SearchResultSnapshot[] {
1465
- const normalizedQuery = query.trim();
1466
- if (!normalizedQuery || !snapshot.surface) {
1467
- return [];
2736
+ function openDefaultStoryVariant(
2737
+ runtime: WordReviewEditorRuntime,
2738
+ pageLayout: PageLayoutSnapshot | undefined,
2739
+ navigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]> | undefined,
2740
+ kind: "header" | "footer",
2741
+ ): void {
2742
+ const variants =
2743
+ kind === "header"
2744
+ ? pageLayout?.headerVariants
2745
+ : pageLayout?.footerVariants;
2746
+ const activePage = navigation?.pages[navigation.activePageIndex];
2747
+ const isFirstPageInSection =
2748
+ activePage !== undefined &&
2749
+ activePage.sectionIndex === pageLayout?.sectionIndex &&
2750
+ activePage.pageInSection === 0;
2751
+ const isEvenDocumentPage = activePage !== undefined && (activePage.pageIndex + 1) % 2 === 0;
2752
+
2753
+ let variant =
2754
+ pageLayout?.differentFirstPage && isFirstPageInSection
2755
+ ? variants?.find((entry) => entry.variant === "first")
2756
+ : undefined;
2757
+
2758
+ if (!variant && pageLayout?.differentOddEvenPages && isEvenDocumentPage) {
2759
+ variant = variants?.find((entry) => entry.variant === "even");
1468
2760
  }
1469
2761
 
1470
- const rawResults = findSearchMatches(
1471
- snapshot.surface.plainText,
1472
- normalizedQuery,
1473
- options,
1474
- ).slice(0, options.limit ?? Number.POSITIVE_INFINITY);
1475
- const activeResultIndex = getActiveSearchResultIndex(rawResults, snapshot.selection);
2762
+ if (!variant) {
2763
+ variant = variants?.find((entry) => entry.variant === "default") ?? variants?.[0];
2764
+ }
1476
2765
 
1477
- return rawResults.map((result, index) => ({
1478
- resultId: `search-result-${index}`,
1479
- anchor: {
1480
- kind: "range",
1481
- from: result.from,
1482
- to: result.to,
1483
- assoc: {
1484
- start: -1,
1485
- end: 1,
1486
- },
1487
- },
1488
- excerpt: createSearchExcerpt(
1489
- snapshot.surface?.plainText ?? "",
1490
- result.from,
1491
- result.to,
1492
- ),
1493
- isActive: index === activeResultIndex,
1494
- }));
2766
+ if (!variant) {
2767
+ return;
2768
+ }
2769
+ runtime.openStory({
2770
+ kind,
2771
+ relationshipId: variant.relationshipId,
2772
+ variant: variant.variant,
2773
+ sectionIndex: pageLayout?.sectionIndex,
2774
+ });
1495
2775
  }
1496
2776
 
1497
- function getActiveSearchResultIndex(
1498
- results: Array<{ from: number; to: number }>,
1499
- selection: PublicSelectionSnapshot,
1500
- ): number {
1501
- if (results.length === 0) {
1502
- return -1;
2777
+ function searchRuntimeDocument(
2778
+ runtime: WordReviewEditorRuntime,
2779
+ mountedSurface: TwProseMirrorSurfaceRef | null,
2780
+ query: string,
2781
+ options: SearchOptions = {},
2782
+ ): SearchResultSnapshot[] {
2783
+ if (mountedSurface) {
2784
+ return mountedSurface.search(query, options);
1503
2785
  }
1504
2786
 
1505
- const selectionFrom = Math.min(selection.anchor, selection.head);
1506
- const selectionTo = Math.max(selection.anchor, selection.head);
1507
- const activeIndex = results.findIndex((result) => {
1508
- if (selectionFrom === selectionTo) {
1509
- return selectionFrom >= result.from && selectionFrom <= result.to;
1510
- }
1511
- return selectionFrom < result.to && selectionTo > result.from;
1512
- });
1513
-
1514
- return activeIndex >= 0 ? activeIndex : 0;
2787
+ const snapshot = runtime.getRenderSnapshot();
2788
+ return searchDocument(
2789
+ runtime.getSessionState().canonicalDocument,
2790
+ snapshot.selection,
2791
+ snapshot.activeStory,
2792
+ runtime.getDocumentNavigationSnapshot(),
2793
+ query,
2794
+ options,
2795
+ );
1515
2796
  }
1516
2797
 
1517
2798
  function applyRegionAttributes(shell: HTMLElement): void {
@@ -1582,663 +2863,59 @@ function findRegionFocusTarget(
1582
2863
  }
1583
2864
 
1584
2865
  if (regionId === "document" || regionId === "toolbar" || regionId === "review-rail" || regionId === "status") {
1585
- return region;
1586
- }
1587
-
1588
- return null;
1589
- }
1590
-
1591
- function isAccessibleRegionId(value: string | undefined): value is AccessibleRegionId {
1592
- return value === "toolbar" || value === "document" || value === "review-rail" || value === "status";
1593
- }
1594
-
1595
- function buildAccessibilityStatusMessage(
1596
- snapshot: RuntimeRenderSnapshot,
1597
- loadError?: EditorError,
1598
- ): string {
1599
- if (loadError) {
1600
- return `Editor failed to load. ${loadError.message}`;
1601
- }
1602
-
1603
- if (!snapshot.isReady) {
1604
- return "Editor loading. Document surface is not ready yet.";
1605
- }
1606
-
1607
- if (snapshot.fatalError) {
1608
- return `Editor opened in diagnostics mode. ${snapshot.fatalError.message}`;
1609
- }
1610
-
1611
- return [
1612
- snapshot.readOnly ? "Read-only mode." : "Editing enabled.",
1613
- `${snapshot.comments.totalCount} comment${snapshot.comments.totalCount === 1 ? "" : "s"}.`,
1614
- `${snapshot.trackedChanges.totalCount} tracked change${
1615
- snapshot.trackedChanges.totalCount === 1 ? "" : "s"
1616
- }.`,
1617
- snapshot.compatibility.blockExport ? "Export blocked." : "Export available.",
1618
- ].join(" ");
1619
- }
1620
-
1621
- function getDiagnosticsModeMessage(error?: EditorError | null): string | null {
1622
- if (!error) {
1623
- return null;
1624
- }
1625
-
1626
- if (error.code === "package_corrupt") {
1627
- 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.`;
1628
- }
1629
-
1630
- if (error.code === "validation_failed") {
1631
- 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.`;
1632
- }
1633
-
1634
- return error.isFatal ? error.message : null;
1635
- }
1636
-
1637
- function normalizeEditorError(error: unknown): EditorError {
1638
- if (
1639
- typeof error === "object" &&
1640
- error !== null &&
1641
- "errorId" in error &&
1642
- "code" in error &&
1643
- "message" in error
1644
- ) {
1645
- return error as EditorError;
1646
- }
1647
-
1648
- return {
1649
- errorId: "word-review-editor-load",
1650
- code: "internal_invariant",
1651
- message: error instanceof Error ? error.message : "Unknown editor load failure.",
1652
- isFatal: true,
1653
- source: "runtime",
1654
- };
1655
- }
1656
-
1657
- function guessSourceLabel(
1658
- initialSourceLabel?: string,
1659
- initialSnapshot?: PersistedEditorSnapshot,
1660
- externalDocSource?: WordReviewEditorProps["externalDocSource"],
1661
- ): string | undefined {
1662
- return (
1663
- externalDocSource?.sourceLabel ??
1664
- initialSourceLabel ??
1665
- initialSnapshot?.sourcePackage?.sourceLabel ??
1666
- initialSnapshot?.editorBuild ??
1667
- undefined
1668
- );
1669
- }
1670
-
1671
- function createLoadingSnapshot(
1672
- documentId: string,
1673
- readOnly: boolean,
1674
- sourceLabel?: string,
1675
- ): RuntimeRenderSnapshot {
1676
- return {
1677
- documentId,
1678
- sessionId: `${documentId}-loading`,
1679
- sourceLabel,
1680
- revisionToken: `${documentId}:loading`,
1681
- isReady: false,
1682
- isDirty: false,
1683
- readOnly,
1684
- selection: collapsedSelection(),
1685
- documentStats: {
1686
- storyLength: 0,
1687
- commentCount: 0,
1688
- revisionCount: 0,
1689
- opaqueFragmentCount: 0,
1690
- },
1691
- comments: {
1692
- openCommentIds: [],
1693
- resolvedCommentIds: [],
1694
- detachedCommentIds: [],
1695
- totalCount: 0,
1696
- threads: [],
1697
- },
1698
- trackedChanges: {
1699
- pendingChangeIds: [],
1700
- acceptedChangeIds: [],
1701
- rejectedChangeIds: [],
1702
- detachedChangeIds: [],
1703
- actionableChangeIds: [],
1704
- preserveOnlyChangeIds: [],
1705
- totalCount: 0,
1706
- revisions: [],
1707
- },
1708
- compatibility: {
1709
- blockExport: false,
1710
- blockExportReasons: [],
1711
- warningCount: 0,
1712
- errorCount: 0,
1713
- featureEntries: [],
1714
- },
1715
- warnings: [],
1716
- commandState: {
1717
- canUndo: false,
1718
- canRedo: false,
1719
- readOnly,
1720
- },
1721
- };
1722
- }
1723
-
1724
- function createErrorSnapshot(documentId: string, error: EditorError): RuntimeRenderSnapshot {
1725
- return {
1726
- ...createLoadingSnapshot(documentId, true),
1727
- isReady: true,
1728
- sessionId: `${documentId}-error`,
1729
- revisionToken: `${documentId}:error`,
1730
- compatibility: {
1731
- blockExport: true,
1732
- blockExportReasons: [error.message],
1733
- warningCount: 0,
1734
- errorCount: 1,
1735
- featureEntries: [],
1736
- },
1737
- fatalError: error,
1738
- };
1739
- }
1740
-
1741
- async function persistAndExport(input: {
1742
- datastore?: EditorDatastoreAdapter;
1743
- documentId: string;
1744
- runtime: WordReviewEditorRuntime;
1745
- onError?: (error: EditorError) => void;
1746
- onEvent?: (event: WordReviewEditorEvent) => void;
1747
- options?: ExportDocxOptions;
1748
- lastSavedRevisionTokenRef: React.MutableRefObject<string | null>;
1749
- autosaveTimerRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
1750
- }): Promise<ExportResult> {
1751
- if (input.autosaveTimerRef.current) {
1752
- clearTimeout(input.autosaveTimerRef.current);
1753
- input.autosaveTimerRef.current = null;
1754
- }
1755
-
1756
- await persistSnapshot({
1757
- datastore: input.datastore,
1758
- documentId: input.documentId,
1759
- runtime: input.runtime,
1760
- isAutosave: false,
1761
- onError: input.onError,
1762
- onEvent: input.onEvent,
1763
- lastSavedRevisionTokenRef: input.lastSavedRevisionTokenRef,
1764
- });
1765
-
1766
- let result: ExportResult;
1767
- try {
1768
- result = await input.runtime.exportDocx(input.options);
1769
- } catch (error) {
1770
- const normalized = normalizeExportError(error, input.documentId, input.options);
1771
- input.onError?.(normalized);
1772
- emitEditorEvent({
1773
- datastore: input.datastore,
1774
- onEvent: input.onEvent,
1775
- event: {
1776
- type: "error",
1777
- documentId: input.documentId,
1778
- error: normalized,
1779
- },
1780
- });
1781
- throw normalized;
1782
- }
1783
-
1784
- if (!input.datastore) {
1785
- return result;
1786
- }
1787
-
1788
- try {
1789
- await input.datastore.saveExport({
1790
- documentId: input.documentId,
1791
- result,
1792
- });
1793
- } catch (error) {
1794
- const normalized = normalizeDatastoreError(error, {
1795
- message: "Export persisted bytes could not be stored.",
1796
- details: {
1797
- operation: "saveExport",
1798
- },
1799
- });
1800
- input.onError?.(normalized);
1801
- emitEditorEvent({
1802
- datastore: input.datastore,
1803
- onEvent: input.onEvent,
1804
- event: {
1805
- type: "error",
1806
- documentId: input.documentId,
1807
- error: normalized,
1808
- },
1809
- });
1810
- }
1811
-
1812
- return result;
1813
- }
1814
-
1815
- function rejectExportWhileLoading(input: {
1816
- documentId: string;
1817
- datastore?: EditorDatastoreAdapter;
1818
- onError?: (error: EditorError) => void;
1819
- onEvent?: (event: WordReviewEditorEvent) => void;
1820
- }): Promise<never> {
1821
- const error: EditorError = {
1822
- errorId: "word-review-editor-loading-export",
1823
- code: "internal_invariant",
1824
- message: "WordReviewEditor is still loading and cannot export yet.",
1825
- isFatal: false,
1826
- source: "runtime",
1827
- };
1828
- input.onError?.(error);
1829
- emitEditorEvent({
1830
- datastore: input.datastore,
1831
- onEvent: input.onEvent,
1832
- event: {
1833
- type: "error",
1834
- documentId: input.documentId,
1835
- error,
1836
- },
1837
- });
1838
- return Promise.reject(error);
1839
- }
1840
-
1841
- async function persistSnapshot(input: {
1842
- datastore?: EditorDatastoreAdapter;
1843
- documentId: string;
1844
- runtime: WordReviewEditorRuntime;
1845
- isAutosave: boolean;
1846
- onError?: (error: EditorError) => void;
1847
- onEvent?: (event: WordReviewEditorEvent) => void;
1848
- lastSavedRevisionTokenRef: React.MutableRefObject<string | null>;
1849
- }): Promise<void> {
1850
- if (!input.datastore) {
1851
- return;
1852
- }
1853
-
1854
- const snapshot = input.runtime.getPersistedSnapshot();
1855
- const revisionToken = input.runtime.getRenderSnapshot().revisionToken;
1856
-
1857
- if (input.isAutosave) {
1858
- emitEditorEvent({
1859
- datastore: input.datastore,
1860
- onEvent: input.onEvent,
1861
- event: {
1862
- type: "autosave_state",
1863
- documentId: input.documentId,
1864
- state: {
1865
- status: "saving",
1866
- } satisfies AutosaveState,
1867
- },
1868
- });
1869
- }
1870
-
1871
- try {
1872
- const result = await input.datastore.saveSnapshot({
1873
- documentId: input.documentId,
1874
- snapshot,
1875
- isAutosave: input.isAutosave,
1876
- });
1877
- const savedSnapshot: PersistedEditorSnapshot = {
1878
- ...snapshot,
1879
- savedAt: result.savedAt,
1880
- };
1881
- input.lastSavedRevisionTokenRef.current = revisionToken;
1882
- emitEditorEvent({
1883
- datastore: input.datastore,
1884
- onEvent: input.onEvent,
1885
- event: {
1886
- type: "snapshot_saved",
1887
- documentId: input.documentId,
1888
- snapshot: savedSnapshot,
1889
- isAutosave: input.isAutosave,
1890
- },
1891
- });
1892
- if (input.isAutosave) {
1893
- emitEditorEvent({
1894
- datastore: input.datastore,
1895
- onEvent: input.onEvent,
1896
- event: {
1897
- type: "autosave_state",
1898
- documentId: input.documentId,
1899
- state: {
1900
- status: "saved",
1901
- savedAt: result.savedAt,
1902
- } satisfies AutosaveState,
1903
- },
1904
- });
1905
- }
1906
- } catch (error) {
1907
- const normalized = normalizeDatastoreError(error, {
1908
- message: input.isAutosave
1909
- ? "Autosave failed while storing the editor snapshot."
1910
- : "Snapshot save failed while preparing the export checkpoint.",
1911
- details: {
1912
- operation: "saveSnapshot",
1913
- isAutosave: input.isAutosave,
1914
- },
1915
- });
1916
- input.onError?.(normalized);
1917
- emitEditorEvent({
1918
- datastore: input.datastore,
1919
- onEvent: input.onEvent,
1920
- event: {
1921
- type: "error",
1922
- documentId: input.documentId,
1923
- error: normalized,
1924
- },
1925
- });
1926
- if (input.isAutosave) {
1927
- emitEditorEvent({
1928
- datastore: input.datastore,
1929
- onEvent: input.onEvent,
1930
- event: {
1931
- type: "autosave_state",
1932
- documentId: input.documentId,
1933
- state: {
1934
- status: "error",
1935
- error: normalized,
1936
- } satisfies AutosaveState,
1937
- },
1938
- });
1939
- }
1940
- if (!input.isAutosave) {
1941
- throw normalized;
1942
- }
1943
- }
1944
- }
1945
-
1946
- function emitEditorEvent(input: {
1947
- datastore?: EditorDatastoreAdapter;
1948
- onEvent?: (event: WordReviewEditorEvent) => void;
1949
- event: WordReviewEditorEvent;
1950
- }): void {
1951
- input.onEvent?.(input.event);
1952
- input.datastore?.logEvent?.({
1953
- type: input.event.type,
1954
- documentId: input.event.documentId,
1955
- detail: summarizeEventDetail(input.event),
1956
- });
1957
- }
1958
-
1959
- function summarizeEventDetail(
1960
- event: WordReviewEditorEvent,
1961
- ): Record<string, unknown> | undefined {
1962
- switch (event.type) {
1963
- case "dirty_changed":
1964
- return { isDirty: event.isDirty };
1965
- case "comment_added":
1966
- return { commentId: event.commentId };
1967
- case "comment_resolved":
1968
- return { commentId: event.commentId };
1969
- case "change_accepted":
1970
- case "change_rejected":
1971
- return { changeId: event.changeId };
1972
- case "warning_added":
1973
- return { warningId: event.warning.warningId, code: event.warning.code };
1974
- case "warning_cleared":
1975
- return { warningId: event.warningId, code: event.code };
1976
- case "error":
1977
- return { errorId: event.error.errorId, code: event.error.code };
1978
- case "autosave_state":
1979
- return { status: event.state.status };
1980
- case "snapshot_saved":
1981
- return { isAutosave: event.isAutosave, savedAt: event.snapshot.savedAt };
1982
- case "export_completed":
1983
- return { fileName: event.result.fileName };
1984
- case "selection_changed":
1985
- return {
1986
- anchor: event.selection.anchor,
1987
- head: event.selection.head,
1988
- };
1989
- case "ready":
1990
- return {
1991
- source: event.source,
1992
- blockExport: event.compatibility.blockExport,
1993
- };
1994
- }
1995
- }
1996
-
1997
- function createReadyEvent(
1998
- runtime: Pick<WordReviewEditorRuntime, "getCompatibilityReport" | "getRenderSnapshot">,
1999
- source: "docx" | "snapshot" | "datastore" | "canonical",
2000
- ): Extract<WordReviewEditorEvent, { type: "ready" }> {
2001
- const snapshot = runtime.getRenderSnapshot();
2002
- return {
2003
- type: "ready",
2004
- documentId: snapshot.documentId,
2005
- sessionId: snapshot.sessionId,
2006
- source,
2007
- stats: snapshot.documentStats,
2008
- compatibility: runtime.getCompatibilityReport(),
2009
- comments: snapshot.comments,
2010
- trackedChanges: snapshot.trackedChanges,
2011
- };
2012
- }
2013
-
2014
- function normalizeDatastoreError(
2015
- error: unknown,
2016
- fallback: {
2017
- message: string;
2018
- details?: Record<string, unknown>;
2019
- },
2020
- ): EditorError {
2021
- if (
2022
- typeof error === "object" &&
2023
- error !== null &&
2024
- "errorId" in error &&
2025
- "code" in error &&
2026
- "message" in error
2027
- ) {
2028
- return error as EditorError;
2029
- }
2030
-
2031
- return {
2032
- errorId: "word-review-editor-datastore",
2033
- code: "datastore_failed",
2034
- message: error instanceof Error ? error.message : fallback.message,
2035
- isFatal: false,
2036
- source: "datastore",
2037
- details: fallback.details,
2038
- };
2039
- }
2040
-
2041
- function createFallbackSnapshot(args: CreateRuntimeArgs): RuntimeRenderSnapshot {
2042
- const warnings = args.source.initialSnapshot?.warningLog ?? [];
2043
- const compatibility = args.source.initialSnapshot?.compatibility ?? emptyCompatibilityReport();
2866
+ return region;
2867
+ }
2044
2868
 
2045
- return {
2046
- ...createLoadingSnapshot(args.documentId, args.readOnly, args.source.sourceLabel),
2047
- sessionId: `${args.documentId}-session`,
2048
- revisionToken: `${args.documentId}:0`,
2049
- isReady: true,
2050
- documentStats: {
2051
- storyLength: estimateStoryLength(args.source.initialSnapshot),
2052
- commentCount: 0,
2053
- revisionCount: 0,
2054
- opaqueFragmentCount: 0,
2055
- },
2056
- compatibility: {
2057
- blockExport: compatibility.blockExport,
2058
- blockExportReasons: [],
2059
- warningCount: compatibility.warnings.length,
2060
- errorCount: compatibility.errors.length,
2061
- featureEntries: compatibility.featureEntries,
2062
- },
2063
- warnings,
2064
- };
2869
+ return null;
2065
2870
  }
2066
2871
 
2067
- function createFallbackPersistedSnapshot(
2068
- documentId: string,
2069
- label = "Generated shell snapshot",
2070
- ): PersistedEditorSnapshot {
2071
- const docId = createCanonicalDocumentId(documentId);
2072
- return {
2073
- snapshotVersion: "persisted-editor-snapshot/2",
2074
- schemaVersion: "cds/1.0.0",
2075
- documentId,
2076
- docId,
2077
- createdAt: "1970-01-01T00:00:00.000Z",
2078
- updatedAt: "1970-01-01T00:00:00.000Z",
2079
- savedAt: "1970-01-01T00:00:00.000Z",
2080
- editorBuild: label,
2081
- canonicalDocument: {
2082
- schemaVersion: "cds/1.0.0",
2083
- docId,
2084
- createdAt: "1970-01-01T00:00:00.000Z",
2085
- updatedAt: "1970-01-01T00:00:00.000Z",
2086
- metadata: {
2087
- customProperties: {},
2088
- },
2089
- styles: {
2090
- paragraphs: {},
2091
- characters: {},
2092
- tables: {},
2093
- },
2094
- numbering: {
2095
- abstractDefinitions: {},
2096
- instances: {},
2097
- },
2098
- media: {
2099
- items: {},
2100
- },
2101
- content: {
2102
- type: "doc",
2103
- children: [{ type: "paragraph", children: [] }],
2104
- },
2105
- review: {
2106
- comments: {},
2107
- revisions: {},
2108
- },
2109
- preservation: {
2110
- opaqueFragments: {},
2111
- packageParts: {},
2112
- },
2113
- diagnostics: {
2114
- warnings: [],
2115
- errors: [],
2116
- },
2117
- },
2118
- compatibility: emptyCompatibilityReport(),
2119
- warningLog: [],
2120
- };
2872
+ function isAccessibleRegionId(value: string | undefined): value is AccessibleRegionId {
2873
+ return value === "toolbar" || value === "document" || value === "review-rail" || value === "status";
2121
2874
  }
2122
2875
 
2123
- function emptyCompatibilityReport(): CompatibilityReport {
2124
- return {
2125
- reportVersion: "compatibility-report/1",
2126
- generatedAt: "1970-01-01T00:00:00.000Z",
2127
- blockExport: false,
2128
- featureEntries: [],
2129
- warnings: [],
2130
- errors: [],
2131
- };
2132
- }
2876
+ function buildAccessibilityStatusMessage(
2877
+ snapshot: RuntimeRenderSnapshot,
2878
+ loadError?: EditorError,
2879
+ ): string {
2880
+ if (loadError) {
2881
+ return `Editor failed to load. ${loadError.message}`;
2882
+ }
2133
2883
 
2134
- function resolveSnapshotExportSession(args: CreateRuntimeArgs): {
2135
- session?: PackageBackedDocxSession;
2136
- barrier?: SnapshotExportBarrier;
2137
- } {
2138
- const sourcePackage = args.source.initialSnapshot?.sourcePackage;
2139
- if (!sourcePackage) {
2140
- return {
2141
- barrier: {
2142
- reason: "missing_source_package_provenance",
2143
- message:
2144
- "DOCX export is blocked because this snapshot was loaded without embedded source package provenance.",
2145
- },
2146
- };
2884
+ if (!snapshot.isReady) {
2885
+ return "Editor loading. Document surface is not ready yet.";
2147
2886
  }
2148
2887
 
2149
- try {
2150
- const bytes = decodePersistedSourcePackageBytes(sourcePackage);
2151
- if (!hasValidPersistedSourcePackageDigest(sourcePackage, bytes)) {
2152
- return {
2153
- barrier: {
2154
- reason: "invalid_source_package_provenance",
2155
- message:
2156
- "DOCX export is blocked because the embedded source package provenance failed its integrity check.",
2157
- },
2158
- };
2159
- }
2888
+ if (snapshot.fatalError) {
2889
+ return `Editor opened in diagnostics mode. ${snapshot.fatalError.message}`;
2890
+ }
2160
2891
 
2161
- const session = loadDocxEditorSession({
2162
- documentId: args.documentId,
2163
- sourceLabel: sourcePackage.sourceLabel ?? args.source.sourceLabel,
2164
- bytes,
2165
- editorBuild: args.source.initialSnapshot?.editorBuild ?? "dev",
2166
- });
2167
- if (session.readOnly || session.fatalError) {
2168
- return {
2169
- barrier: {
2170
- reason: "invalid_source_package_provenance",
2171
- message:
2172
- "DOCX export is blocked because the embedded source package provenance is no longer loadable as a valid package-backed session.",
2173
- },
2174
- };
2175
- }
2892
+ return [
2893
+ snapshot.readOnly ? "Read-only mode." : "Editing enabled.",
2894
+ `${snapshot.comments.totalCount} comment${snapshot.comments.totalCount === 1 ? "" : "s"}.`,
2895
+ `${snapshot.trackedChanges.totalCount} tracked change${
2896
+ snapshot.trackedChanges.totalCount === 1 ? "" : "s"
2897
+ }.`,
2898
+ snapshot.compatibility.blockExport ? "Export blocked." : "Export available.",
2899
+ ].join(" ");
2900
+ }
2176
2901
 
2177
- return { session };
2178
- } catch {
2179
- return {
2180
- barrier: {
2181
- reason: "invalid_source_package_provenance",
2182
- message:
2183
- "DOCX export is blocked because the embedded source package provenance could not be decoded into a package-backed session.",
2184
- },
2185
- };
2902
+ function getDiagnosticsModeMessage(error?: EditorError | null): string | null {
2903
+ if (!error) {
2904
+ return null;
2186
2905
  }
2187
- }
2188
2906
 
2189
- function applySnapshotExportBarrier(
2190
- snapshot: PersistedEditorSnapshot,
2191
- barrier: SnapshotExportBarrier,
2192
- ): PersistedEditorSnapshot {
2193
- const featureEntryId = `feature:source-package-provenance:${barrier.reason}`;
2194
- const featureEntries = snapshot.compatibility.featureEntries.some(
2195
- (entry) => entry.featureEntryId === featureEntryId,
2196
- )
2197
- ? snapshot.compatibility.featureEntries
2198
- : [
2199
- ...snapshot.compatibility.featureEntries,
2200
- {
2201
- featureEntryId,
2202
- featureKey: "source-package-provenance",
2203
- featureClass: "unsupported-fatal" as const,
2204
- message: barrier.message,
2205
- details: {
2206
- reason: barrier.reason,
2207
- },
2208
- },
2209
- ];
2907
+ if (error.code === "package_corrupt") {
2908
+ 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.`;
2909
+ }
2210
2910
 
2211
- return {
2212
- ...snapshot,
2213
- compatibility: {
2214
- ...snapshot.compatibility,
2215
- blockExport: true,
2216
- featureEntries,
2217
- },
2218
- };
2219
- }
2911
+ if (error.code === "validation_failed") {
2912
+ 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.`;
2913
+ }
2220
2914
 
2221
- function createSnapshotExportBlockedError(
2222
- documentId: string,
2223
- barrier: SnapshotExportBarrier,
2224
- ): EditorError {
2225
- return {
2226
- errorId: `${documentId}:export:${barrier.reason}`,
2227
- code: "export_failed",
2228
- message: barrier.message,
2229
- isFatal: false,
2230
- source: "export",
2231
- details: {
2232
- reason: barrier.reason,
2233
- },
2234
- };
2915
+ return error.isFatal ? error.message : null;
2235
2916
  }
2236
2917
 
2237
- function normalizeExportError(
2238
- error: unknown,
2239
- documentId: string,
2240
- options?: ExportDocxOptions,
2241
- ): EditorError {
2918
+ function normalizeEditorError(error: unknown): EditorError {
2242
2919
  if (
2243
2920
  typeof error === "object" &&
2244
2921
  error !== null &&
@@ -2250,18 +2927,47 @@ function normalizeExportError(
2250
2927
  }
2251
2928
 
2252
2929
  return {
2253
- errorId: `${documentId}:export:failed`,
2254
- code: "export_failed",
2255
- message:
2256
- error instanceof Error ? error.message : "DOCX export failed for an unknown reason.",
2257
- isFatal: false,
2258
- source: "export",
2259
- details: {
2260
- requestedOptions: options ?? {},
2261
- },
2930
+ errorId: "word-review-editor-load",
2931
+ code: "internal_invariant",
2932
+ message: error instanceof Error ? error.message : "Unknown editor load failure.",
2933
+ isFatal: true,
2934
+ source: "runtime",
2262
2935
  };
2263
2936
  }
2264
2937
 
2938
+ function guessSourceLabel(
2939
+ initialSourceLabel?: string,
2940
+ initialSessionState?: EditorSessionState,
2941
+ initialSnapshot?: PersistedEditorSnapshot,
2942
+ externalDocSource?: WordReviewEditorProps["externalDocSource"],
2943
+ ): string | undefined {
2944
+ return (
2945
+ externalDocSource?.sourceLabel ??
2946
+ (externalDocSource?.kind === "session"
2947
+ ? externalDocSource.sessionState.sourcePackage?.sourceLabel
2948
+ : undefined) ??
2949
+ (externalDocSource?.kind === "snapshot"
2950
+ ? externalDocSource.snapshot.sourcePackage?.sourceLabel
2951
+ : undefined) ??
2952
+ initialSourceLabel ??
2953
+ initialSessionState?.sourcePackage?.sourceLabel ??
2954
+ initialSessionState?.editorBuild ??
2955
+ initialSnapshot?.sourcePackage?.sourceLabel ??
2956
+ initialSnapshot?.editorBuild ??
2957
+ undefined
2958
+ );
2959
+ }
2960
+
2961
+ function deriveEditorViewMode(
2962
+ readOnly: boolean,
2963
+ reviewMode: WordReviewEditorProps["reviewMode"] = "review",
2964
+ ): EditorViewMode {
2965
+ if (readOnly) {
2966
+ return "view";
2967
+ }
2968
+ return reviewMode === "editing" ? "editing" : "review";
2969
+ }
2970
+
2265
2971
  function toRuntimeSelectionSnapshot(selection: PublicSelectionSnapshot) {
2266
2972
  return {
2267
2973
  anchor: selection.anchor,
@@ -2311,9 +3017,17 @@ function createSelectionFromAnchor(
2311
3017
  }
2312
3018
  }
2313
3019
 
2314
- function estimateStoryLength(snapshot?: PersistedEditorSnapshot): number {
2315
- const content = snapshot?.canonicalDocument.content;
2316
- return Array.isArray(content) ? content.length : 0;
3020
+ function estimateStoryLength(
3021
+ sessionStateOrSnapshot?: EditorSessionState | PersistedEditorSnapshot,
3022
+ ): number {
3023
+ if (!sessionStateOrSnapshot) {
3024
+ return 0;
3025
+ }
3026
+
3027
+ const content = "sessionVersion" in sessionStateOrSnapshot
3028
+ ? sessionStateOrSnapshot.canonicalDocument.content
3029
+ : sessionStateOrSnapshot.canonicalDocument.content;
3030
+ return Array.isArray(content?.children) ? content.children.length : 0;
2317
3031
  }
2318
3032
 
2319
3033
  function collapsedSelection(): RuntimeRenderSnapshot["selection"] {
@@ -2358,3 +3072,414 @@ function summarizeSelectionPreview(snapshot: RuntimeRenderSnapshot): string | nu
2358
3072
 
2359
3073
  return preview.length > 48 ? `${preview.slice(0, 45)}...` : preview;
2360
3074
  }
3075
+
3076
+ function selectionToolbarAnchorsEqual(
3077
+ left: SelectionToolbarAnchor | null,
3078
+ right: SelectionToolbarAnchor | null,
3079
+ ): boolean {
3080
+ if (left === right) {
3081
+ return true;
3082
+ }
3083
+ if (!left || !right) {
3084
+ return false;
3085
+ }
3086
+ return (
3087
+ left.left === right.left &&
3088
+ left.right === right.right &&
3089
+ left.top === right.top &&
3090
+ left.bottom === right.bottom
3091
+ );
3092
+ }
3093
+
3094
+ function workflowScopeSnapshotsEqual(
3095
+ left: WorkflowScopeSnapshot | null,
3096
+ right: WorkflowScopeSnapshot | null,
3097
+ ): boolean {
3098
+ if (left === right) {
3099
+ return true;
3100
+ }
3101
+ if (!left || !right) {
3102
+ return false;
3103
+ }
3104
+ return (
3105
+ left.overlayPresent === right.overlayPresent &&
3106
+ left.activeWorkItemId === right.activeWorkItemId &&
3107
+ left.activeWorkItem === right.activeWorkItem &&
3108
+ left.scopes === right.scopes &&
3109
+ left.candidates === right.candidates &&
3110
+ workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons)
3111
+ );
3112
+ }
3113
+
3114
+ function interactionGuardSnapshotsEqual(
3115
+ left: InteractionGuardSnapshot,
3116
+ right: InteractionGuardSnapshot,
3117
+ ): boolean {
3118
+ if (left === right) {
3119
+ return true;
3120
+ }
3121
+ return workflowBlockedReasonsEqual(left.blockedReasons, right.blockedReasons);
3122
+ }
3123
+
3124
+ function workflowBlockedReasonsEqual(
3125
+ left: readonly WorkflowBlockedCommandReason[],
3126
+ right: readonly WorkflowBlockedCommandReason[],
3127
+ ): boolean {
3128
+ if (left === right) {
3129
+ return true;
3130
+ }
3131
+ if (left.length !== right.length) {
3132
+ return false;
3133
+ }
3134
+ for (let index = 0; index < left.length; index += 1) {
3135
+ if (!workflowBlockedReasonEqual(left[index]!, right[index]!)) {
3136
+ return false;
3137
+ }
3138
+ }
3139
+ return true;
3140
+ }
3141
+
3142
+ function workflowBlockedReasonEqual(
3143
+ left: WorkflowBlockedCommandReason,
3144
+ right: WorkflowBlockedCommandReason,
3145
+ ): boolean {
3146
+ return (
3147
+ left.code === right.code &&
3148
+ left.message === right.message &&
3149
+ left.scopeId === right.scopeId &&
3150
+ left.workItemId === right.workItemId &&
3151
+ editorAnchorProjectionEqual(left.anchor, right.anchor) &&
3152
+ storyTargetsEqual(left.storyTarget, right.storyTarget)
3153
+ );
3154
+ }
3155
+
3156
+ function editorAnchorProjectionEqual(
3157
+ left: EditorAnchorProjection | undefined,
3158
+ right: EditorAnchorProjection | undefined,
3159
+ ): boolean {
3160
+ if (left === right) {
3161
+ return true;
3162
+ }
3163
+ if (!left || !right || left.kind !== right.kind) {
3164
+ return false;
3165
+ }
3166
+
3167
+ switch (left.kind) {
3168
+ case "range":
3169
+ return (
3170
+ right.kind === "range" &&
3171
+ left.from === right.from &&
3172
+ left.to === right.to &&
3173
+ left.assoc.start === right.assoc.start &&
3174
+ left.assoc.end === right.assoc.end
3175
+ );
3176
+ case "node":
3177
+ return right.kind === "node" && left.at === right.at && left.assoc === right.assoc;
3178
+ case "detached":
3179
+ return (
3180
+ right.kind === "detached" &&
3181
+ left.lastKnownRange.from === right.lastKnownRange.from &&
3182
+ left.lastKnownRange.to === right.lastKnownRange.to &&
3183
+ left.reason === right.reason
3184
+ );
3185
+ }
3186
+
3187
+ return false;
3188
+ }
3189
+
3190
+ function createSelectionToolbarSelectionKey(
3191
+ selection: RuntimeRenderSnapshot["selection"],
3192
+ activeStory: EditorStoryTarget,
3193
+ ): string | null {
3194
+ if (selection.isCollapsed || selection.activeRange.kind !== "range") {
3195
+ return null;
3196
+ }
3197
+
3198
+ return JSON.stringify({
3199
+ story: activeStory,
3200
+ from: selection.activeRange.from,
3201
+ to: selection.activeRange.to,
3202
+ });
3203
+ }
3204
+
3205
+ function buildSelectionToolbarModel(args: {
3206
+ snapshot: RuntimeRenderSnapshot;
3207
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>;
3208
+ capabilities: ReturnType<typeof deriveCapabilities>;
3209
+ documentNavigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]>;
3210
+ styleCatalog: StyleCatalogSnapshot;
3211
+ formattingState: FormattingStateSnapshot;
3212
+ addCommentDisabledReason?: string;
3213
+ }): SelectionToolbarModel | null {
3214
+ const {
3215
+ snapshot,
3216
+ viewState,
3217
+ capabilities,
3218
+ documentNavigation,
3219
+ styleCatalog,
3220
+ formattingState,
3221
+ addCommentDisabledReason,
3222
+ } = args;
3223
+
3224
+ if (
3225
+ !snapshot.surface ||
3226
+ snapshot.selection.isCollapsed ||
3227
+ snapshot.selection.activeRange.kind !== "range" ||
3228
+ !capabilities.canEdit ||
3229
+ viewState.viewMode === "view"
3230
+ ) {
3231
+ return null;
3232
+ }
3233
+
3234
+ const previewText = summarizeSelectionPreview(snapshot);
3235
+ if (!previewText) {
3236
+ return null;
3237
+ }
3238
+
3239
+ const badges = [
3240
+ createSelectionToolbarStoryBadge(viewState.activeStory),
3241
+ viewState.workspaceMode === "page" && documentNavigation.pageCount > 0
3242
+ ? { label: `Page ${documentNavigation.activePageIndex + 1}` as const }
3243
+ : null,
3244
+ createSelectionToolbarStyleBadge(styleCatalog, formattingState),
3245
+ createSelectionToolbarListBadge(viewState),
3246
+ ].filter((badge): badge is SelectionToolbarModel["badges"][number] => Boolean(badge));
3247
+
3248
+ return {
3249
+ previewText,
3250
+ badges,
3251
+ canToggleFormatting: true,
3252
+ boldActive: formattingState.bold,
3253
+ italicActive: formattingState.italic,
3254
+ underlineActive: formattingState.underline,
3255
+ canAddComment: capabilities.canAddComment,
3256
+ ...(addCommentDisabledReason ? { disabledReason: addCommentDisabledReason } : {}),
3257
+ };
3258
+ }
3259
+
3260
+ function createSelectionToolbarStoryBadge(
3261
+ target: EditorStoryTarget,
3262
+ ): SelectionToolbarModel["badges"][number] | null {
3263
+ if (target.kind === "main") {
3264
+ return null;
3265
+ }
3266
+
3267
+ return {
3268
+ label:
3269
+ target.kind === "header"
3270
+ ? target.variant === "default"
3271
+ ? "Header"
3272
+ : `Header ${target.variant}`
3273
+ : target.kind === "footer"
3274
+ ? target.variant === "default"
3275
+ ? "Footer"
3276
+ : `Footer ${target.variant}`
3277
+ : target.kind === "footnote"
3278
+ ? "Footnote"
3279
+ : "Endnote",
3280
+ tone: "accent",
3281
+ };
3282
+ }
3283
+
3284
+ function createSelectionToolbarStyleBadge(
3285
+ styleCatalog: StyleCatalogSnapshot,
3286
+ formattingState: FormattingStateSnapshot,
3287
+ ): SelectionToolbarModel["badges"][number] | null {
3288
+ if (!formattingState.paragraphStyleId) {
3289
+ return null;
3290
+ }
3291
+
3292
+ const styleEntry = styleCatalog.paragraphs.find(
3293
+ (entry) => entry.styleId === formattingState.paragraphStyleId,
3294
+ );
3295
+ if (!styleEntry || styleEntry.isDefault) {
3296
+ return null;
3297
+ }
3298
+
3299
+ return { label: styleEntry.displayName };
3300
+ }
3301
+
3302
+ function createSelectionToolbarListBadge(
3303
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>,
3304
+ ): SelectionToolbarModel["badges"][number] | null {
3305
+ if (!viewState.activeListContext) {
3306
+ return null;
3307
+ }
3308
+
3309
+ return {
3310
+ label: viewState.activeListContext.isOrdered ? "Numbered list" : "Bulleted list",
3311
+ };
3312
+ }
3313
+
3314
+ function buildActiveImageContext(args: {
3315
+ canonicalDocument: PersistedEditorSnapshot["canonicalDocument"];
3316
+ selection: RuntimeRenderSnapshot["selection"];
3317
+ storyTarget: EditorStoryTarget;
3318
+ surface?: RuntimeRenderSnapshot["surface"];
3319
+ }): {
3320
+ mediaId: string;
3321
+ display: "inline" | "floating";
3322
+ widthEmu?: number;
3323
+ heightEmu?: number;
3324
+ horizontalOffsetEmu?: number;
3325
+ verticalOffsetEmu?: number;
3326
+ } | null {
3327
+ const imageSegment = findSelectedImageSegment(args.surface, args.selection);
3328
+ if (!imageSegment) {
3329
+ return null;
3330
+ }
3331
+
3332
+ const storyBlocks = getStoryBlocks(args.canonicalDocument, args.storyTarget);
3333
+ const imageNode = findImageNodeByMediaId(storyBlocks, imageSegment.mediaId);
3334
+ const mediaItem = args.canonicalDocument.media.items[imageSegment.mediaId];
3335
+
3336
+ return {
3337
+ mediaId: imageSegment.mediaId,
3338
+ display: imageSegment.display === "floating" ? "floating" : "inline",
3339
+ widthEmu: mediaItem?.widthEmu,
3340
+ heightEmu: mediaItem?.heightEmu,
3341
+ horizontalOffsetEmu: imageNode?.floating?.horizontalPosition?.offset,
3342
+ verticalOffsetEmu: imageNode?.floating?.verticalPosition?.offset,
3343
+ };
3344
+ }
3345
+
3346
+ function findSelectedImageSegment(
3347
+ surface: RuntimeRenderSnapshot["surface"] | undefined,
3348
+ selection: RuntimeRenderSnapshot["selection"],
3349
+ ): Extract<NonNullable<RuntimeRenderSnapshot["surface"]>["blocks"][number], { kind: "paragraph" }>["segments"][number] & {
3350
+ kind: "image";
3351
+ } | null {
3352
+ if (!surface) {
3353
+ return null;
3354
+ }
3355
+
3356
+ const position =
3357
+ selection.activeRange.kind === "node" ? selection.activeRange.at : selection.head;
3358
+
3359
+ return findSelectedImageSegmentInBlocks(surface.blocks, position);
3360
+ }
3361
+
3362
+ function findSelectedImageSegmentInBlocks(
3363
+ blocks: readonly SurfaceBlockSnapshot[],
3364
+ position: number,
3365
+ ): Extract<NonNullable<RuntimeRenderSnapshot["surface"]>["blocks"][number], { kind: "paragraph" }>["segments"][number] & {
3366
+ kind: "image";
3367
+ } | null {
3368
+ for (const block of blocks) {
3369
+ if (position < block.from || position > block.to) {
3370
+ continue;
3371
+ }
3372
+
3373
+ if (block.kind === "paragraph") {
3374
+ const imageSegment = block.segments.find(
3375
+ (segment) => segment.kind === "image" && position >= segment.from && position <= segment.to,
3376
+ );
3377
+ if (imageSegment && imageSegment.kind === "image") {
3378
+ return imageSegment;
3379
+ }
3380
+ continue;
3381
+ }
3382
+
3383
+ if (block.kind === "table") {
3384
+ for (const row of block.rows) {
3385
+ for (const cell of row.cells) {
3386
+ const match = findSelectedImageSegmentInBlocks(cell.content, position);
3387
+ if (match) {
3388
+ return match;
3389
+ }
3390
+ }
3391
+ }
3392
+ continue;
3393
+ }
3394
+
3395
+ if (block.kind === "sdt_block") {
3396
+ const match = findSelectedImageSegmentInBlocks(block.children, position);
3397
+ if (match) {
3398
+ return match;
3399
+ }
3400
+ }
3401
+ }
3402
+
3403
+ return null;
3404
+ }
3405
+
3406
+ function findImageNodeByMediaId(blocks: readonly unknown[], mediaId: string): {
3407
+ floating?: {
3408
+ horizontalPosition?: { offset?: number };
3409
+ verticalPosition?: { offset?: number };
3410
+ };
3411
+ } | null {
3412
+ for (const block of blocks) {
3413
+ const match = findImageNodeInValue(block, mediaId);
3414
+ if (match) {
3415
+ return match;
3416
+ }
3417
+ }
3418
+ return null;
3419
+ }
3420
+
3421
+ function findImageNodeInValue(
3422
+ value: unknown,
3423
+ mediaId: string,
3424
+ ): {
3425
+ floating?: {
3426
+ horizontalPosition?: { offset?: number };
3427
+ verticalPosition?: { offset?: number };
3428
+ };
3429
+ } | null {
3430
+ if (!value || typeof value !== "object") {
3431
+ return null;
3432
+ }
3433
+
3434
+ const record = value as {
3435
+ type?: string;
3436
+ mediaId?: string;
3437
+ children?: unknown[];
3438
+ rows?: Array<{ cells?: Array<{ children?: unknown[] }> }>;
3439
+ floating?: {
3440
+ horizontalPosition?: { offset?: number };
3441
+ verticalPosition?: { offset?: number };
3442
+ };
3443
+ };
3444
+
3445
+ if (record.type === "image" && record.mediaId === mediaId) {
3446
+ return record;
3447
+ }
3448
+
3449
+ if (Array.isArray(record.children)) {
3450
+ for (const child of record.children) {
3451
+ const match = findImageNodeInValue(child, mediaId);
3452
+ if (match) {
3453
+ return match;
3454
+ }
3455
+ }
3456
+ }
3457
+
3458
+ if (Array.isArray(record.rows)) {
3459
+ for (const row of record.rows) {
3460
+ for (const cell of row.cells ?? []) {
3461
+ for (const child of cell.children ?? []) {
3462
+ const match = findImageNodeInValue(child, mediaId);
3463
+ if (match) {
3464
+ return match;
3465
+ }
3466
+ }
3467
+ }
3468
+ }
3469
+ }
3470
+
3471
+ return null;
3472
+ }
3473
+
3474
+ function createImageDataUrl(contentType: string, bytes: Uint8Array): string {
3475
+ const base64 = bytesToBase64(bytes);
3476
+ return `data:${contentType};base64,${base64}`;
3477
+ }
3478
+
3479
+ function bytesToBase64(bytes: Uint8Array): string {
3480
+ let binary = "";
3481
+ for (let index = 0; index < bytes.length; index += 1) {
3482
+ binary += String.fromCharCode(bytes[index] ?? 0);
3483
+ }
3484
+ return btoa(binary);
3485
+ }