@beyondwork/docx-react-component 1.0.17 → 1.0.19

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 (74) hide show
  1. package/README.md +8 -2
  2. package/package.json +32 -34
  3. package/src/api/README.md +5 -1
  4. package/src/api/public-types.ts +374 -4
  5. package/src/api/session-state.ts +58 -0
  6. package/src/core/commands/formatting-commands.ts +1 -0
  7. package/src/core/commands/image-commands.ts +147 -0
  8. package/src/core/commands/index.ts +5 -1
  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 +329 -0
  14. package/src/core/selection/mapping.ts +41 -0
  15. package/src/core/state/editor-state.ts +1 -1
  16. package/src/index.ts +30 -0
  17. package/src/io/docx-session.ts +260 -39
  18. package/src/io/export/serialize-main-document.ts +202 -5
  19. package/src/io/export/serialize-numbering.ts +28 -7
  20. package/src/io/normalize/normalize-text.ts +63 -25
  21. package/src/io/ooxml/numbering-sentinels.ts +44 -0
  22. package/src/io/ooxml/parse-footnotes.ts +212 -20
  23. package/src/io/ooxml/parse-headers-footers.ts +229 -25
  24. package/src/io/ooxml/parse-inline-media.ts +16 -0
  25. package/src/io/ooxml/parse-main-document.ts +411 -6
  26. package/src/io/ooxml/parse-numbering.ts +7 -0
  27. package/src/io/ooxml/parse-settings.ts +184 -0
  28. package/src/io/ooxml/parse-shapes.ts +25 -0
  29. package/src/io/ooxml/parse-styles.ts +463 -0
  30. package/src/io/ooxml/parse-theme.ts +32 -0
  31. package/src/model/canonical-document.ts +133 -3
  32. package/src/model/cds-1.0.0.ts +13 -0
  33. package/src/model/snapshot.ts +2 -1
  34. package/src/runtime/document-layout.ts +332 -0
  35. package/src/runtime/document-navigation.ts +564 -0
  36. package/src/runtime/document-runtime.ts +265 -35
  37. package/src/runtime/document-search.ts +145 -0
  38. package/src/runtime/numbering-prefix.ts +47 -26
  39. package/src/runtime/page-layout-estimation.ts +212 -0
  40. package/src/runtime/read-only-diagnostics-runtime.ts +1 -0
  41. package/src/runtime/session-capabilities.ts +2 -0
  42. package/src/runtime/story-context.ts +164 -0
  43. package/src/runtime/story-targeting.ts +162 -0
  44. package/src/runtime/surface-projection.ts +239 -12
  45. package/src/runtime/table-schema.ts +87 -5
  46. package/src/runtime/view-state.ts +459 -0
  47. package/src/ui/WordReviewEditor.tsx +1902 -312
  48. package/src/ui/browser-export.ts +52 -0
  49. package/src/ui/headless/preserve-editor-selection.ts +5 -0
  50. package/src/ui/headless/selection-helpers.ts +20 -0
  51. package/src/ui/headless/selection-toolbar-model.ts +22 -0
  52. package/src/ui/headless/use-editor-keyboard.ts +6 -1
  53. package/src/ui-tailwind/chrome/tw-page-ruler.tsx +386 -0
  54. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +125 -14
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +107 -0
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +45 -6
  57. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +31 -0
  58. package/src/ui-tailwind/editor-surface/pm-position-map.ts +2 -2
  59. package/src/ui-tailwind/editor-surface/pm-schema.ts +47 -5
  60. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +95 -22
  61. package/src/ui-tailwind/editor-surface/search-plugin.ts +19 -68
  62. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +11 -0
  63. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +394 -77
  64. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +0 -1
  65. package/src/ui-tailwind/index.ts +2 -1
  66. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +277 -147
  67. package/src/ui-tailwind/review/tw-review-rail.tsx +6 -6
  68. package/src/ui-tailwind/theme/editor-theme.css +123 -0
  69. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +4 -0
  70. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +291 -12
  71. package/src/ui-tailwind/tw-review-workspace.tsx +926 -27
  72. package/src/validation/compatibility-engine.ts +92 -20
  73. package/src/validation/diagnostics.ts +1 -0
  74. package/src/validation/docx-comment-proof.ts +487 -0
@@ -1,5 +1,7 @@
1
1
  import React, {
2
2
  forwardRef,
3
+ type FocusEvent,
4
+ useCallback,
3
5
  useEffect,
4
6
  useImperativeHandle,
5
7
  useMemo,
@@ -10,41 +12,91 @@ import React, {
10
12
 
11
13
  import type {
12
14
  AutosaveState,
13
- EditorDatastoreAdapter,
14
15
  CompatibilityReport,
16
+ EditorDatastoreAdapter,
17
+ EditorHostAdapter,
18
+ EditorSessionState,
19
+ ExportResult,
15
20
  EditorError,
21
+ EditorStoryTarget,
16
22
  EditorWarning,
17
- FormattingAlignment,
18
23
  ExportDocxOptions,
24
+ FormattingAlignment,
25
+ FormattingStateSnapshot,
26
+ HeaderFooterLinkPatch,
19
27
  InsertImageOptions,
20
28
  InsertTableOptions,
29
+ PageLayoutSnapshot,
21
30
  PersistedEditorSnapshot,
22
31
  RuntimeRenderSnapshot,
32
+ SectionBreakType,
33
+ SectionLayoutPatch,
34
+ SectionPageNumberingPatch,
23
35
  SearchOptions,
24
36
  SearchResultSnapshot,
25
37
  SelectionSnapshot as PublicSelectionSnapshot,
26
- ExportResult,
38
+ StyleCatalogSnapshot,
39
+ SurfaceBlockSnapshot,
40
+ ViewMode as EditorViewMode,
27
41
  WordReviewEditorEvent,
28
42
  WordReviewEditorProps,
29
43
  WordReviewEditorRef,
44
+ WorkspaceMode,
45
+ ZoomLevel,
30
46
  } from "../api/public-types";
47
+ import { editorSessionStateFromPersistedSnapshot } from "../api/session-state.ts";
31
48
  import {
32
49
  createDetachedAnchor,
33
50
  createNodeAnchor,
34
51
  createRangeAnchor,
52
+ storyTargetsEqual,
35
53
  type TransactionMapping,
36
54
  } from "../core/selection/mapping.ts";
37
55
  import {
38
56
  applyFormattingOperationToDocument,
39
57
  getFormattingStateFromRenderSnapshot,
40
58
  } from "../core/commands/formatting-commands.ts";
41
- import { insertImage as insertImageInDocument } from "../core/commands/image-commands.ts";
59
+ import {
60
+ applyParagraphStyleToDocument,
61
+ applyTableStyleToDocument,
62
+ } from "../core/commands/style-commands.ts";
63
+ import {
64
+ continueNumbering as continueListNumbering,
65
+ backspaceAtListStart,
66
+ indentListItems,
67
+ outdentListItems,
68
+ restartNumbering as restartListNumbering,
69
+ splitListParagraph,
70
+ } from "../core/commands/list-commands.ts";
71
+ import {
72
+ resolveActiveParagraphIndex,
73
+ setActiveParagraphIndentation,
74
+ setActiveParagraphTabStops,
75
+ } from "../core/commands/paragraph-layout-commands.ts";
76
+ import {
77
+ deleteSectionBreakAtSectionIndex,
78
+ insertSectionBreakAfterSectionIndex,
79
+ setHeaderFooterLinkAtSectionIndex,
80
+ setSectionPageNumberingAtSectionIndex,
81
+ updateSectionLayoutAtSectionIndex,
82
+ } from "../core/commands/section-layout-commands.ts";
83
+ import {
84
+ insertImage as insertImageInDocument,
85
+ resizeImage as resizeImageInCatalog,
86
+ repositionFloatingImage as repositionFloatingImageInDocument,
87
+ } from "../core/commands/image-commands.ts";
42
88
  import {
43
89
  applyTableStructureOperation,
44
90
  } from "../core/commands/table-structure-commands.ts";
45
91
  import {
92
+ deleteSelectionOrBackward,
93
+ deleteSelectionOrForward,
94
+ insertHardBreak as insertHardBreakInDocument,
46
95
  insertPageBreak as insertPageBreakInDocument,
96
+ insertTab as insertTabInDocument,
97
+ insertText as insertTextInDocument,
47
98
  insertTable as insertTableInDocument,
99
+ splitParagraph as splitParagraphInDocument,
48
100
  } from "../core/commands/text-commands.ts";
49
101
  import {
50
102
  createCanonicalDocumentId,
@@ -54,29 +106,38 @@ import {
54
106
  createDocumentRuntime,
55
107
  type DocumentRuntime,
56
108
  } from "../runtime/document-runtime.ts";
109
+ import {
110
+ getStoryBlocks,
111
+ replaceStoryBlocks,
112
+ } from "../runtime/story-targeting.ts";
57
113
  import { loadDocxEditorSession } from "../io/docx-session.ts";
58
114
  import {
59
115
  decodePersistedSourcePackageBytes,
60
116
  hasValidPersistedSourcePackageDigest,
61
117
  } from "../io/source-package-provenance.ts";
62
118
  import { deriveCapabilities } from "../runtime/session-capabilities";
63
- import {
64
- createSearchExcerpt,
65
- findSearchMatches,
66
- } from "../ui-tailwind/editor-surface/search-plugin";
119
+ import { searchDocument } from "../runtime/document-search.ts";
67
120
  import {
68
121
  TwProseMirrorSurface,
69
122
  type TwProseMirrorSurfaceRef,
70
123
  } from "../ui-tailwind/editor-surface/tw-prosemirror-surface";
71
124
  import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace";
72
125
  import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail";
73
- import type { ViewMode } from "../ui-tailwind/toolbar/tw-toolbar";
74
126
  import type { MarkupDisplay } from "./headless/comment-decoration-model";
127
+ import type {
128
+ SelectionToolbarAnchor,
129
+ SelectionToolbarModel,
130
+ } from "./headless/selection-toolbar-model";
131
+ import {
132
+ downloadExportResult,
133
+ withExportDelivery,
134
+ } from "./browser-export";
75
135
 
76
136
  interface ResolvedSource {
77
- source: "docx" | "snapshot" | "datastore";
137
+ source: "docx" | "session" | "snapshot";
78
138
  sourceLabel?: string;
79
139
  initialDocx?: Uint8Array | ArrayBuffer;
140
+ initialSessionState?: EditorSessionState;
80
141
  initialSnapshot?: PersistedEditorSnapshot;
81
142
  }
82
143
 
@@ -84,6 +145,11 @@ interface CreateRuntimeArgs {
84
145
  documentId: string;
85
146
  readOnly: boolean;
86
147
  source: ResolvedSource;
148
+ initialViewState?: {
149
+ workspaceMode?: WorkspaceMode;
150
+ zoomLevel?: ZoomLevel;
151
+ };
152
+ hostAdapter?: EditorHostAdapter;
87
153
  datastore?: EditorDatastoreAdapter;
88
154
  currentUserId?: string;
89
155
  }
@@ -129,6 +195,12 @@ const ACCESSIBLE_REGION_ORDER = [
129
195
 
130
196
  type AccessibleRegionId = (typeof ACCESSIBLE_REGION_ORDER)[number];
131
197
 
198
+ type SelectionToolbarDismissReason =
199
+ | "blur"
200
+ | "chrome-action"
201
+ | "comment-action"
202
+ | "escape";
203
+
132
204
  export function __createWordReviewEditorRefBridge(
133
205
  runtime: WordReviewEditorRuntime,
134
206
  mountedSurface?: TwProseMirrorSurfaceRef | null,
@@ -138,6 +210,7 @@ export function __createWordReviewEditorRefBridge(
138
210
  blur: () => runtime.blur(),
139
211
  undo: () => runtime.undo(),
140
212
  redo: () => runtime.redo(),
213
+ replaceText: (text, target) => runtime.replaceText(text, target),
141
214
  addComment: (params) => runtime.addComment(params),
142
215
  openComment: (commentId) => runtime.openComment(commentId),
143
216
  resolveComment: (commentId) => runtime.resolveComment(commentId),
@@ -152,6 +225,7 @@ export function __createWordReviewEditorRefBridge(
152
225
  acceptAllChanges: () => runtime.acceptAllChanges(),
153
226
  rejectAllChanges: () => runtime.rejectAllChanges(),
154
227
  exportDocx: (options) => runtime.exportDocx(options),
228
+ getSessionState: () => runtime.getSessionState(),
155
229
  getSnapshot: () => runtime.getPersistedSnapshot(),
156
230
  getRenderSnapshot: () => clonePublicValue(runtime.getRenderSnapshot()),
157
231
  getCompatibilityReport: () => runtime.getCompatibilityReport(),
@@ -165,7 +239,7 @@ export function __createWordReviewEditorRefBridge(
165
239
  clonePublicValue(runtime.getRenderSnapshot().trackedChanges),
166
240
  isDirty: () => runtime.getRenderSnapshot().isDirty,
167
241
  getFormattingState: () => getFormattingStateFromRenderSnapshot(runtime.getRenderSnapshot()),
168
- replaceText: (text, target) => runtime.replaceText(text, target),
242
+ getStyleCatalog: () => getRuntimeStyleCatalog(runtime),
169
243
  toggleBold: () => {
170
244
  applyRuntimeFormattingOperation(runtime, { type: "toggle", mark: "bold" });
171
245
  },
@@ -214,6 +288,12 @@ export function __createWordReviewEditorRefBridge(
214
288
  alignment,
215
289
  });
216
290
  },
291
+ setParagraphStyle: (styleId) => {
292
+ applyRuntimeParagraphStyle(runtime, styleId);
293
+ },
294
+ setTableStyle: (styleId) => {
295
+ applyRuntimeTableStyle(runtime, styleId);
296
+ },
217
297
  indent: () => {
218
298
  applyRuntimeFormattingOperation(runtime, { type: "indent" });
219
299
  },
@@ -281,38 +361,71 @@ export function __createWordReviewEditorRefBridge(
281
361
  });
282
362
  },
283
363
  search: (query, options) =>
284
- mountedSurface?.search(query, options) ??
285
- searchSnapshotSurface(runtime.getRenderSnapshot(), query, options),
364
+ searchRuntimeDocument(runtime, mountedSurface ?? null, query, options),
286
365
  clearSearch: () => {
287
366
  mountedSurface?.clearSearch();
288
367
  },
289
368
  setSelection: (selection) => {
290
- runtime.dispatch({
291
- type: "selection.set",
292
- selection: toRuntimeSelectionSnapshot(
293
- normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
294
- ),
295
- });
369
+ applyRuntimeSelection(
370
+ runtime,
371
+ normalizeRequestedSelection(runtime.getRenderSnapshot(), selection),
372
+ );
296
373
  },
297
374
  scrollToRevision: (revisionId: string) => {
298
375
  const revision = runtime.getRenderSnapshot().trackedChanges.revisions.find(
299
376
  (r) => r.revisionId === revisionId,
300
377
  );
301
378
  if (!revision || revision.anchor.kind === "detached") return;
302
- runtime.dispatch({
303
- type: "selection.set",
304
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(revision.anchor)),
305
- });
379
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(revision.anchor));
306
380
  },
307
381
  scrollToComment: (commentId: string) => {
308
382
  const comment = runtime.getRenderSnapshot().comments.threads.find(
309
383
  (t) => t.commentId === commentId,
310
384
  );
311
385
  if (!comment || comment.anchor.kind === "detached") return;
312
- runtime.dispatch({
313
- type: "selection.set",
314
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(comment.anchor)),
315
- });
386
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(comment.anchor));
387
+ },
388
+ openStory: (target: EditorStoryTarget) => {
389
+ runtime.openStory(target);
390
+ },
391
+ closeStory: () => {
392
+ runtime.closeStory();
393
+ },
394
+ getPageLayoutSnapshot: () => {
395
+ return runtime.getPageLayoutSnapshot();
396
+ },
397
+ getDocumentNavigationSnapshot: () => {
398
+ return runtime.getDocumentNavigationSnapshot();
399
+ },
400
+ getViewState: () => {
401
+ return runtime.getViewState();
402
+ },
403
+ setWorkspaceMode: (mode) => {
404
+ runtime.setWorkspaceMode(mode);
405
+ },
406
+ setZoom: (level) => {
407
+ runtime.setZoom(level);
408
+ },
409
+ insertSectionBreak: (type, options) => {
410
+ applyRuntimeInsertSectionBreak(runtime, type, options);
411
+ },
412
+ deleteSectionBreak: (sectionIndex) => {
413
+ applyRuntimeDeleteSectionBreak(runtime, sectionIndex);
414
+ },
415
+ updateSectionLayout: (sectionIndex, patch) => {
416
+ applyRuntimeUpdateSectionLayout(runtime, sectionIndex, patch);
417
+ },
418
+ setSectionPageNumbering: (sectionIndex, patch) => {
419
+ applyRuntimeSetSectionPageNumbering(runtime, sectionIndex, patch);
420
+ },
421
+ setHeaderFooterLink: (sectionIndex, params) => {
422
+ applyRuntimeSetHeaderFooterLink(runtime, sectionIndex, params);
423
+ },
424
+ setImageLayout: (mediaId, dimensions) => {
425
+ applyRuntimeImageResize(runtime, mediaId, dimensions);
426
+ },
427
+ setImageFrame: (mediaId, offsets) => {
428
+ applyRuntimeImageReposition(runtime, mediaId, offsets);
316
429
  },
317
430
  };
318
431
  }
@@ -321,31 +434,61 @@ export async function __resolveWordReviewEditorSource(
321
434
  props: Pick<
322
435
  WordReviewEditorProps,
323
436
  | "documentId"
437
+ | "hostAdapter"
324
438
  | "datastore"
325
439
  | "externalDocSource"
326
440
  | "initialDocx"
441
+ | "initialSessionState"
327
442
  | "initialSnapshot"
328
443
  | "initialSourceLabel"
444
+ | "loadRevision"
445
+ | "loadSourcePolicy"
329
446
  >,
330
447
  ): Promise<ResolvedSource> {
331
448
  const explicitInitialCount =
332
- Number(Boolean(props.initialDocx)) + Number(Boolean(props.initialSnapshot));
449
+ Number(Boolean(props.initialDocx)) +
450
+ Number(Boolean(props.initialSessionState)) +
451
+ Number(Boolean(props.initialSnapshot));
333
452
  if (explicitInitialCount > 1) {
334
- throw new Error("Provide exactly one of initialDocx or initialSnapshot.");
453
+ throw new Error(
454
+ "Provide exactly one of initialDocx, initialSessionState, or initialSnapshot.",
455
+ );
335
456
  }
336
457
 
337
458
  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
- };
459
+ if (props.externalDocSource.kind === "docx") {
460
+ return {
461
+ source: "docx",
462
+ initialDocx: props.externalDocSource.bytes,
463
+ sourceLabel: props.externalDocSource.sourceLabel,
464
+ };
465
+ }
466
+
467
+ if (props.externalDocSource.kind === "session") {
468
+ return {
469
+ source: "session",
470
+ initialSessionState: props.externalDocSource.sessionState,
471
+ sourceLabel:
472
+ props.externalDocSource.sourceLabel ??
473
+ props.externalDocSource.sessionState.sourcePackage?.sourceLabel,
474
+ };
475
+ }
476
+
477
+ return {
478
+ source: "snapshot",
479
+ initialSnapshot: props.externalDocSource.snapshot,
480
+ sourceLabel: props.externalDocSource.sourceLabel,
481
+ };
482
+ }
483
+
484
+ if (props.initialSessionState) {
485
+ return {
486
+ source: "session",
487
+ initialSessionState: props.initialSessionState,
488
+ sourceLabel:
489
+ props.initialSourceLabel ??
490
+ props.initialSessionState.sourcePackage?.sourceLabel,
491
+ };
349
492
  }
350
493
 
351
494
  if (props.initialSnapshot) {
@@ -364,31 +507,48 @@ export async function __resolveWordReviewEditorSource(
364
507
  };
365
508
  }
366
509
 
367
- if (!props.datastore) {
510
+ const loader = props.hostAdapter?.load ?? props.datastore?.load;
511
+ if (!loader) {
368
512
  throw new Error(
369
- `WordReviewEditor ${props.documentId} needs initialDocx, initialSnapshot, or datastore.load().`,
513
+ `WordReviewEditor ${props.documentId} needs initialDocx, initialSessionState, initialSnapshot, or a host/datastore load source.`,
370
514
  );
371
515
  }
372
516
 
373
- const loadResult = await props.datastore.load({
517
+ const loadResult = await loader({
374
518
  documentId: props.documentId,
519
+ loadRevision: props.loadRevision,
520
+ loadSourcePolicy: props.loadSourcePolicy,
375
521
  });
376
522
 
377
523
  if (!loadResult.source) {
378
- throw new Error(`Datastore did not return a loadable source for ${props.documentId}.`);
524
+ throw new Error(
525
+ `Host or datastore loader did not return a loadable source for ${props.documentId}.`,
526
+ );
379
527
  }
380
528
 
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
- };
529
+ if (loadResult.source.kind === "docx") {
530
+ return {
531
+ source: "docx",
532
+ initialDocx: loadResult.source.bytes,
533
+ sourceLabel: loadResult.source.sourceLabel,
534
+ };
535
+ }
536
+
537
+ if (loadResult.source.kind === "session") {
538
+ return {
539
+ source: "session",
540
+ initialSessionState: loadResult.source.sessionState,
541
+ sourceLabel:
542
+ loadResult.source.sourceLabel ??
543
+ loadResult.source.sessionState.sourcePackage?.sourceLabel,
544
+ };
545
+ }
546
+
547
+ return {
548
+ source: "snapshot",
549
+ initialSnapshot: loadResult.source.snapshot,
550
+ sourceLabel: loadResult.source.sourceLabel,
551
+ };
392
552
  }
393
553
 
394
554
  export function __createFallbackRuntime(args: CreateRuntimeArgs): WordReviewEditorRuntime {
@@ -408,34 +568,39 @@ function createRuntime(
408
568
  })
409
569
  : undefined;
410
570
  const snapshotExportResolution = !args.source.initialDocx
411
- ? resolveSnapshotExportSession(args)
571
+ ? resolvePackageBackedExportSession(args)
412
572
  : 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;
573
+ const initialSessionState =
574
+ args.source.initialSessionState ??
575
+ docxSession?.initialSessionState ??
576
+ (args.source.initialSnapshot
577
+ ? editorSessionStateFromPersistedSnapshot(args.source.initialSnapshot)
578
+ : editorSessionStateFromPersistedSnapshot(
579
+ createFallbackPersistedSnapshot(
580
+ args.documentId,
581
+ args.source.sourceLabel ?? "Generated shell snapshot",
582
+ ),
583
+ ));
584
+ const runtimeSessionState = snapshotExportResolution?.barrier
585
+ ? applySessionExportBarrier(initialSessionState, snapshotExportResolution.barrier)
586
+ : initialSessionState;
423
587
 
424
588
  return createDocumentRuntime({
425
589
  documentId: args.documentId,
426
- initialSnapshot: runtimeSnapshot,
590
+ initialSessionState: runtimeSessionState,
427
591
  sourceKind: args.source.source,
428
592
  sourceLabel: args.source.sourceLabel,
593
+ initialViewState: args.initialViewState,
429
594
  readOnly: args.readOnly || docxSession?.readOnly,
430
- editorBuild: runtimeSnapshot.editorBuild,
595
+ editorBuild: runtimeSessionState.editorBuild,
431
596
  fatalError: docxSession?.fatalError,
432
- exportDocx: async (snapshot, options) => {
597
+ exportDocx: async (sessionState, options) => {
433
598
  if (docxSession) {
434
- return docxSession.exportDocx(snapshot, options);
599
+ return docxSession.exportDocx(sessionState, options);
435
600
  }
436
601
 
437
602
  if (snapshotExportResolution?.session) {
438
- return snapshotExportResolution.session.exportDocx(snapshot, options);
603
+ return snapshotExportResolution.session.exportDocx(sessionState, options);
439
604
  }
440
605
 
441
606
  throw createSnapshotExportBlockedError(
@@ -443,7 +608,7 @@ function createRuntime(
443
608
  snapshotExportResolution?.barrier ?? {
444
609
  reason: "missing_source_package_provenance",
445
610
  message:
446
- "DOCX export is blocked because this snapshot does not carry embedded source package provenance.",
611
+ "DOCX export is blocked because this session does not carry embedded source package provenance.",
447
612
  },
448
613
  );
449
614
  },
@@ -457,11 +622,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
457
622
  function WordReviewEditor(props, ref) {
458
623
  const {
459
624
  currentUser,
625
+ hostAdapter,
460
626
  datastore,
461
627
  documentId,
462
628
  externalDocSource,
463
629
  externalDocumentRevision,
464
630
  initialDocx,
631
+ loadRevision,
632
+ loadSourcePolicy = "prefer-saved-state",
633
+ initialSessionState,
465
634
  initialSnapshot,
466
635
  initialSourceLabel,
467
636
  markupDisplay = "simple",
@@ -475,24 +644,36 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
475
644
 
476
645
  const [runtime, setRuntime] = useState<WordReviewEditorRuntime | null>(null);
477
646
  const [loadError, setLoadError] = useState<EditorError | null>(null);
478
- const [viewMode, setViewMode] = useState<ViewMode>("canvas");
479
- const liveMarkupDisplay: MarkupDisplay = viewMode === "document" ? "all" : "clean";
480
647
  const [activeRailTab, setActiveRailTab] = useState<ReviewRailTab>("comments");
481
648
  const [showTrackedChanges, setShowTrackedChanges] = useState(false);
482
649
  const [activeRevisionId, setActiveRevisionId] = useState<string | undefined>();
650
+ const [selectionToolbarAnchor, setSelectionToolbarAnchor] = useState<SelectionToolbarAnchor | null>(null);
651
+ const [selectionToolbarDismissedKey, setSelectionToolbarDismissedKey] = useState<string | null>(null);
652
+ const [selectionToolbarFocusWithin, setSelectionToolbarFocusWithin] = useState(false);
483
653
  const runtimeRef = useRef<WordReviewEditorRuntime | null>(null);
484
654
  const surfaceRef = useRef<TwProseMirrorSurfaceRef | null>(null);
655
+ const selectionToolbarElementRef = useRef<HTMLDivElement | null>(null);
485
656
  const shellRef = useRef<HTMLDivElement | null>(null);
657
+ const lastSelectionToolbarKeyRef = useRef<string | null>(null);
486
658
  const lastAnnouncedErrorIdRef = useRef<string | null>(null);
487
659
  const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
488
660
  const lastSavedRevisionTokenRef = useRef<string | null>(null);
661
+ const hostAdapterRef = useRef(hostAdapter);
489
662
  const datastoreRef = useRef(datastore);
490
663
  const onEventRef = useRef(onEvent);
491
664
  const onWarningRef = useRef(onWarning);
492
665
  const onErrorRef = useRef(onError);
666
+ const runtimeViewStateSeedRef = useRef<{
667
+ workspaceMode: WorkspaceMode;
668
+ zoomLevel: ZoomLevel;
669
+ }>({
670
+ workspaceMode: "canvas",
671
+ zoomLevel: 100,
672
+ });
493
673
  const initialSourceRef = useRef<{
494
674
  documentId: string;
495
675
  initialDocx?: Uint8Array | ArrayBuffer;
676
+ initialSessionState?: EditorSessionState;
496
677
  initialSnapshot?: PersistedEditorSnapshot;
497
678
  initialSourceLabel?: string;
498
679
  } | null>(null);
@@ -501,6 +682,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
501
682
  initialSourceRef.current = {
502
683
  documentId,
503
684
  initialDocx,
685
+ initialSessionState,
504
686
  initialSnapshot,
505
687
  initialSourceLabel,
506
688
  };
@@ -509,18 +691,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
509
691
  const stableInitialSource = initialSourceRef.current;
510
692
  const sourceReloadKey = externalDocSource
511
693
  ? `external:${externalDocSource.kind}:${externalDocumentRevision ?? "static"}`
694
+ : stableInitialSource?.initialSessionState
695
+ ? "initial-session"
512
696
  : stableInitialSource?.initialSnapshot
513
697
  ? "initial-snapshot"
514
698
  : stableInitialSource?.initialDocx
515
699
  ? "initial-docx"
516
- : "datastore";
700
+ : hostAdapter
701
+ ? `host-adapter:${loadRevision ?? "static"}`
702
+ : `datastore:${loadRevision ?? "static"}`;
517
703
 
518
704
  useEffect(() => {
705
+ hostAdapterRef.current = hostAdapter;
519
706
  datastoreRef.current = datastore;
520
707
  onEventRef.current = onEvent;
521
708
  onWarningRef.current = onWarning;
522
709
  onErrorRef.current = onError;
523
- }, [datastore, onError, onEvent, onWarning]);
710
+ }, [datastore, hostAdapter, onError, onEvent, onWarning]);
524
711
 
525
712
  useEffect(() => {
526
713
  let cancelled = false;
@@ -531,11 +718,15 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
531
718
  try {
532
719
  const source = await __resolveWordReviewEditorSource({
533
720
  documentId,
721
+ hostAdapter: hostAdapterRef.current,
534
722
  datastore: datastoreRef.current,
535
723
  externalDocSource,
536
724
  initialDocx: stableInitialSource?.initialDocx,
725
+ initialSessionState: stableInitialSource?.initialSessionState,
537
726
  initialSnapshot: stableInitialSource?.initialSnapshot,
538
727
  initialSourceLabel: stableInitialSource?.initialSourceLabel,
728
+ loadRevision,
729
+ loadSourcePolicy,
539
730
  });
540
731
 
541
732
  if (cancelled) {
@@ -548,6 +739,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
548
739
  documentId,
549
740
  readOnly,
550
741
  source,
742
+ initialViewState: runtimeViewStateSeedRef.current,
743
+ hostAdapter: hostAdapterRef.current,
551
744
  datastore: datastoreRef.current,
552
745
  currentUserId: currentUser.userId,
553
746
  },
@@ -557,6 +750,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
557
750
  },
558
751
  );
559
752
  emitEditorEvent({
753
+ hostAdapter: hostAdapterRef.current,
560
754
  datastore: datastoreRef.current,
561
755
  onEvent: onEventRef.current,
562
756
  event: createReadyEvent(nextRuntime, source.source),
@@ -572,6 +766,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
572
766
  setLoadError(normalized);
573
767
  onErrorRef.current?.(normalized);
574
768
  emitEditorEvent({
769
+ hostAdapter: hostAdapterRef.current,
575
770
  datastore: datastoreRef.current,
576
771
  onEvent: onEventRef.current,
577
772
  event: {
@@ -600,7 +795,11 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
600
795
  }
601
796
 
602
797
  return runtime.subscribeToEvents((event) => {
798
+ if (event.type === "export_completed" || event.type === "ready") {
799
+ return;
800
+ }
603
801
  emitEditorEvent({
802
+ hostAdapter: hostAdapterRef.current,
604
803
  datastore: datastoreRef.current,
605
804
  onEvent: onEventRef.current,
606
805
  event,
@@ -625,20 +824,37 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
625
824
  documentId,
626
825
  readOnly,
627
826
  currentUserId: currentUser.userId,
827
+ initialViewState: runtimeViewStateSeedRef.current,
628
828
  source: {
629
- source: "snapshot",
829
+ source:
830
+ initialSessionState
831
+ ? "session"
832
+ : "snapshot",
833
+ initialSessionState:
834
+ initialSessionState ??
835
+ (initialSnapshot
836
+ ? editorSessionStateFromPersistedSnapshot(initialSnapshot)
837
+ : undefined),
630
838
  initialSnapshot:
631
839
  initialSnapshot ?? createFallbackPersistedSnapshot(documentId, initialSourceLabel),
632
- sourceLabel: guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
840
+ sourceLabel: guessSourceLabel(
841
+ initialSourceLabel,
842
+ initialSessionState,
843
+ initialSnapshot,
844
+ externalDocSource,
845
+ ),
633
846
  },
847
+ hostAdapter: hostAdapterRef.current,
634
848
  datastore: datastoreRef.current,
635
849
  }),
636
850
  [
637
851
  currentUser.userId,
638
852
  documentId,
853
+ initialSessionState,
639
854
  initialSnapshot,
640
855
  initialSourceLabel,
641
856
  readOnly,
857
+ hostAdapter,
642
858
  externalDocSource?.kind,
643
859
  externalDocSource?.sourceLabel,
644
860
  ],
@@ -651,11 +867,17 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
651
867
  : createLoadingSnapshot(
652
868
  documentId,
653
869
  readOnly,
654
- guessSourceLabel(initialSourceLabel, initialSnapshot, externalDocSource),
870
+ guessSourceLabel(
871
+ initialSourceLabel,
872
+ initialSessionState,
873
+ initialSnapshot,
874
+ externalDocSource,
875
+ ),
655
876
  ),
656
877
  [
657
878
  documentId,
658
879
  externalDocSource,
880
+ initialSessionState,
659
881
  initialSnapshot,
660
882
  initialSourceLabel,
661
883
  loadError,
@@ -670,6 +892,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
670
892
  );
671
893
 
672
894
  const activeRuntime = runtime ?? optimisticRuntime;
895
+ const viewState = activeRuntime.getViewState();
896
+ const isPageWorkspace = viewState.workspaceMode === "page";
897
+ const liveMarkupDisplay: MarkupDisplay = isPageWorkspace ? "all" : "clean";
898
+ const canonicalDocument = activeRuntime.getSessionState().canonicalDocument;
899
+ const documentNavigation = activeRuntime.getDocumentNavigationSnapshot();
900
+ const effectiveViewMode = deriveEditorViewMode(snapshot.readOnly, reviewMode);
901
+
902
+ useEffect(() => {
903
+ activeRuntime.setViewMode(effectiveViewMode);
904
+ }, [activeRuntime, effectiveViewMode]);
905
+
906
+ useEffect(() => {
907
+ runtimeViewStateSeedRef.current = {
908
+ workspaceMode: viewState.workspaceMode,
909
+ zoomLevel: viewState.zoomLevel,
910
+ };
911
+ }, [viewState.workspaceMode, viewState.zoomLevel]);
673
912
 
674
913
  useImperativeHandle(
675
914
  ref,
@@ -678,6 +917,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
678
917
  blur: () => activeRuntime.blur(),
679
918
  undo: () => activeRuntime.undo(),
680
919
  redo: () => activeRuntime.redo(),
920
+ replaceText: (text, target) => activeRuntime.replaceText(text, target),
681
921
  addComment: (params) =>
682
922
  activeRuntime.addComment({
683
923
  ...params,
@@ -700,6 +940,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
700
940
  exportDocx: (options) =>
701
941
  runtime
702
942
  ? persistAndExport({
943
+ hostAdapter: hostAdapterRef.current,
703
944
  datastore: datastoreRef.current,
704
945
  documentId,
705
946
  runtime,
@@ -711,10 +952,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
711
952
  })
712
953
  : rejectExportWhileLoading({
713
954
  documentId,
955
+ hostAdapter: hostAdapterRef.current,
714
956
  datastore: datastoreRef.current,
715
957
  onError: onErrorRef.current,
716
958
  onEvent: onEventRef.current,
717
959
  }),
960
+ getSessionState: () => activeRuntime.getSessionState(),
718
961
  getSnapshot: () => activeRuntime.getPersistedSnapshot(),
719
962
  getRenderSnapshot: () => clonePublicValue(activeRuntime.getRenderSnapshot()),
720
963
  getCompatibilityReport: () => activeRuntime.getCompatibilityReport(),
@@ -730,7 +973,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
730
973
  isDirty: () => activeRuntime.getRenderSnapshot().isDirty,
731
974
  getFormattingState: () =>
732
975
  getFormattingStateFromRenderSnapshot(activeRuntime.getRenderSnapshot()),
733
- replaceText: (text, target) => activeRuntime.replaceText(text, target),
976
+ getStyleCatalog: () => getRuntimeStyleCatalog(activeRuntime),
734
977
  toggleBold: () => {
735
978
  applyRuntimeFormattingOperation(activeRuntime, {
736
979
  type: "toggle",
@@ -797,6 +1040,12 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
797
1040
  alignment,
798
1041
  });
799
1042
  },
1043
+ setParagraphStyle: (styleId) => {
1044
+ applyRuntimeParagraphStyle(activeRuntime, styleId);
1045
+ },
1046
+ setTableStyle: (styleId) => {
1047
+ applyRuntimeTableStyle(activeRuntime, styleId);
1048
+ },
800
1049
  indent: () => {
801
1050
  applyRuntimeFormattingOperation(activeRuntime, { type: "indent" });
802
1051
  },
@@ -864,45 +1113,85 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
864
1113
  });
865
1114
  },
866
1115
  search: (query, options) =>
867
- surfaceRef.current?.search(query, options) ??
868
- searchSnapshotSurface(activeRuntime.getRenderSnapshot(), query, options),
1116
+ searchRuntimeDocument(
1117
+ activeRuntime,
1118
+ surfaceRef.current,
1119
+ query,
1120
+ options,
1121
+ ),
869
1122
  clearSearch: () => {
870
1123
  surfaceRef.current?.clearSearch();
871
1124
  },
872
1125
  setSelection: (selection) => {
873
- activeRuntime.dispatch({
874
- type: "selection.set",
875
- selection: toRuntimeSelectionSnapshot(
876
- normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
877
- ),
878
- });
1126
+ applyRuntimeSelection(
1127
+ activeRuntime,
1128
+ normalizeRequestedSelection(activeRuntime.getRenderSnapshot(), selection),
1129
+ );
879
1130
  },
880
1131
  scrollToRevision: (revisionId: string) => {
881
1132
  const revision = activeRuntime.getRenderSnapshot().trackedChanges.revisions.find(
882
1133
  (r) => r.revisionId === revisionId,
883
1134
  );
884
1135
  if (!revision || revision.anchor.kind === "detached") return;
885
- activeRuntime.dispatch({
886
- type: "selection.set",
887
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(revision.anchor)),
888
- });
1136
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(revision.anchor));
889
1137
  },
890
1138
  scrollToComment: (commentId: string) => {
891
1139
  const comment = activeRuntime.getRenderSnapshot().comments.threads.find(
892
1140
  (t) => t.commentId === commentId,
893
1141
  );
894
1142
  if (!comment || comment.anchor.kind === "detached") return;
895
- activeRuntime.dispatch({
896
- type: "selection.set",
897
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(comment.anchor)),
898
- });
1143
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(comment.anchor));
1144
+ },
1145
+ openStory: (target: EditorStoryTarget) => {
1146
+ activeRuntime.openStory(target);
1147
+ },
1148
+ closeStory: () => {
1149
+ activeRuntime.closeStory();
1150
+ },
1151
+ getPageLayoutSnapshot: () => {
1152
+ return activeRuntime.getPageLayoutSnapshot();
1153
+ },
1154
+ getDocumentNavigationSnapshot: () => {
1155
+ return activeRuntime.getDocumentNavigationSnapshot();
1156
+ },
1157
+ getViewState: () => {
1158
+ return activeRuntime.getViewState();
1159
+ },
1160
+ setWorkspaceMode: (mode) => {
1161
+ activeRuntime.setWorkspaceMode(mode);
1162
+ },
1163
+ setZoom: (level) => {
1164
+ activeRuntime.setZoom(level);
1165
+ },
1166
+ insertSectionBreak: (type, options) => {
1167
+ applyRuntimeInsertSectionBreak(activeRuntime, type, options);
1168
+ },
1169
+ deleteSectionBreak: (sectionIndex) => {
1170
+ applyRuntimeDeleteSectionBreak(activeRuntime, sectionIndex);
1171
+ },
1172
+ updateSectionLayout: (sectionIndex, patch) => {
1173
+ applyRuntimeUpdateSectionLayout(activeRuntime, sectionIndex, patch);
1174
+ },
1175
+ setSectionPageNumbering: (sectionIndex, patch) => {
1176
+ applyRuntimeSetSectionPageNumbering(activeRuntime, sectionIndex, patch);
1177
+ },
1178
+ setHeaderFooterLink: (sectionIndex, params) => {
1179
+ applyRuntimeSetHeaderFooterLink(activeRuntime, sectionIndex, params);
1180
+ },
1181
+ setImageLayout: (mediaId, dimensions) => {
1182
+ applyRuntimeImageResize(activeRuntime, mediaId, dimensions);
1183
+ },
1184
+ setImageFrame: (mediaId, offsets) => {
1185
+ applyRuntimeImageReposition(activeRuntime, mediaId, offsets);
899
1186
  },
900
1187
  }),
901
1188
  [activeRuntime, currentUser.userId, documentId, runtime],
902
1189
  );
903
1190
 
904
1191
  useEffect(() => {
905
- if (!datastoreRef.current || props.autosave?.enabled === false || !runtime || readOnly) {
1192
+ const canPersist =
1193
+ Boolean(hostAdapterRef.current?.saveSession) || Boolean(datastoreRef.current?.saveSnapshot);
1194
+ if (!canPersist || props.autosave?.enabled === false || !runtime || readOnly) {
906
1195
  if (autosaveTimerRef.current) {
907
1196
  clearTimeout(autosaveTimerRef.current);
908
1197
  autosaveTimerRef.current = null;
@@ -924,7 +1213,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
924
1213
 
925
1214
  const debounceMs = props.autosave?.debounceMs ?? 800;
926
1215
  if (debounceMs <= 0) {
927
- void persistSnapshot({
1216
+ void persistSession({
1217
+ hostAdapter: hostAdapterRef.current,
928
1218
  datastore: datastoreRef.current,
929
1219
  documentId,
930
1220
  runtime,
@@ -937,7 +1227,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
937
1227
  }
938
1228
 
939
1229
  autosaveTimerRef.current = setTimeout(() => {
940
- void persistSnapshot({
1230
+ void persistSession({
1231
+ hostAdapter: hostAdapterRef.current,
941
1232
  datastore: datastoreRef.current,
942
1233
  documentId,
943
1234
  runtime,
@@ -989,29 +1280,29 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
989
1280
  if (anchor.kind === "detached") {
990
1281
  return;
991
1282
  }
992
-
993
- activeRuntime.dispatch({
994
- type: "selection.set",
995
- selection: toRuntimeSelectionSnapshot(createSelectionFromAnchor(anchor)),
996
- });
1283
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(anchor));
997
1284
  }
998
1285
 
999
- function addReviewComment(): void {
1286
+ function addReviewComment(): string | null {
1000
1287
  try {
1001
- activeRuntime.addComment({
1288
+ const commentId = activeRuntime.addComment({
1002
1289
  anchor: snapshot.selection.activeRange,
1003
- body: "New review comment",
1290
+ body: "",
1004
1291
  authorId: currentUser.userId,
1005
1292
  });
1293
+ activeRuntime.openComment(commentId);
1006
1294
  setActiveRailTab("comments");
1295
+ return commentId;
1007
1296
  } catch {
1008
1297
  // Runtime already emitted a concrete export-safety error for invalid anchors.
1298
+ return null;
1009
1299
  }
1010
1300
  }
1011
1301
 
1012
1302
  function exportCurrentDocument(): void {
1013
1303
  void (runtime
1014
1304
  ? persistAndExport({
1305
+ hostAdapter: hostAdapterRef.current,
1015
1306
  datastore: datastoreRef.current,
1016
1307
  documentId,
1017
1308
  runtime,
@@ -1022,31 +1313,181 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1022
1313
  })
1023
1314
  : rejectExportWhileLoading({
1024
1315
  documentId,
1316
+ hostAdapter: hostAdapterRef.current,
1025
1317
  datastore: datastoreRef.current,
1026
1318
  onError: onErrorRef.current,
1027
1319
  onEvent: onEventRef.current,
1028
1320
  }));
1029
1321
  }
1030
1322
 
1031
- const selectionPreview = summarizeSelectionPreview(snapshot);
1032
1323
  const derivedCapabilities = deriveCapabilities(snapshot, reviewMode);
1033
1324
  const capabilities = showReviewPanel
1034
1325
  ? derivedCapabilities
1035
1326
  : { ...derivedCapabilities, reviewRailVisible: false };
1327
+ const formattingState = getFormattingStateFromRenderSnapshot(snapshot);
1328
+ const styleCatalog = useMemo(
1329
+ () => getRuntimeStyleCatalog(activeRuntime),
1330
+ [activeRuntime, snapshot.revisionToken],
1331
+ );
1036
1332
  const diagnosticsModeMessage = getDiagnosticsModeMessage(loadError ?? snapshot.fatalError);
1037
1333
  const addCommentDisabledReason =
1038
1334
  !capabilities.canAddComment && !snapshot.selection.isCollapsed
1039
1335
  ? "Select text within one paragraph to add a DOCX comment."
1040
1336
  : undefined;
1337
+ const selectionToolbar = buildSelectionToolbarModel({
1338
+ snapshot,
1339
+ viewState,
1340
+ capabilities,
1341
+ documentNavigation,
1342
+ styleCatalog,
1343
+ formattingState,
1344
+ addCommentDisabledReason,
1345
+ });
1346
+ const selectionToolbarSelectionKey = useMemo(
1347
+ () => createSelectionToolbarSelectionKey(snapshot.selection, viewState.activeStory),
1348
+ [snapshot.selection, viewState.activeStory],
1349
+ );
1350
+ const shouldRenderSelectionToolbar = Boolean(
1351
+ selectionToolbar &&
1352
+ selectionToolbarSelectionKey &&
1353
+ selectionToolbarDismissedKey !== selectionToolbarSelectionKey &&
1354
+ (viewState.isFocused || selectionToolbarFocusWithin),
1355
+ );
1041
1356
  const accessibilityInstructionsId = `${documentId}-accessibility-instructions`;
1042
1357
  const accessibilityStatusId = `${documentId}-accessibility-status`;
1043
1358
  const accessibilityAlertId = `${documentId}-accessibility-alert`;
1044
1359
 
1045
1360
  const dispatchSelection = (selection: PublicSelectionSnapshot) =>
1046
- activeRuntime.dispatch({
1047
- type: "selection.set",
1048
- selection: toRuntimeSelectionSnapshot(selection),
1361
+ applyRuntimeSelection(activeRuntime, selection);
1362
+
1363
+ const dismissSelectionToolbar = useCallback(
1364
+ (_reason: SelectionToolbarDismissReason) => {
1365
+ if (selectionToolbarSelectionKey) {
1366
+ setSelectionToolbarDismissedKey(selectionToolbarSelectionKey);
1367
+ } else {
1368
+ setSelectionToolbarDismissedKey(null);
1369
+ }
1370
+ setSelectionToolbarAnchor(null);
1371
+ setSelectionToolbarFocusWithin(false);
1372
+ },
1373
+ [selectionToolbarSelectionKey],
1374
+ );
1375
+
1376
+ const getDocumentSurfaceElement = useCallback((): HTMLElement | null => {
1377
+ return shellRef.current?.querySelector<HTMLElement>("[data-wre-document-surface='true']") ?? null;
1378
+ }, []);
1379
+
1380
+ const isTargetWithinSelectionToolbar = useCallback((target: EventTarget | null): boolean => {
1381
+ return target instanceof Node && Boolean(selectionToolbarElementRef.current?.contains(target));
1382
+ }, []);
1383
+
1384
+ const isTargetWithinDocumentSurface = useCallback(
1385
+ (target: EventTarget | null): boolean => {
1386
+ const surfaceElement = getDocumentSurfaceElement();
1387
+ return target instanceof Node && Boolean(surfaceElement?.contains(target));
1388
+ },
1389
+ [getDocumentSurfaceElement],
1390
+ );
1391
+
1392
+ const focusDocumentSurface = useCallback((): void => {
1393
+ const surfaceElement = getDocumentSurfaceElement();
1394
+ surfaceElement?.focus();
1395
+ activeRuntime.focus();
1396
+ }, [activeRuntime, getDocumentSurfaceElement]);
1397
+
1398
+ const handleSurfaceFocus = useCallback(
1399
+ (_event: FocusEvent<HTMLDivElement>) => {
1400
+ setSelectionToolbarFocusWithin(false);
1401
+ activeRuntime.focus();
1402
+ },
1403
+ [activeRuntime],
1404
+ );
1405
+
1406
+ const handleSurfaceBlur = useCallback(
1407
+ (event: FocusEvent<HTMLDivElement>) => {
1408
+ if (isTargetWithinSelectionToolbar(event.relatedTarget)) {
1409
+ setSelectionToolbarFocusWithin(true);
1410
+ activeRuntime.focus();
1411
+ return;
1412
+ }
1413
+
1414
+ setSelectionToolbarFocusWithin(false);
1415
+ activeRuntime.blur();
1416
+ dismissSelectionToolbar("blur");
1417
+ },
1418
+ [activeRuntime, dismissSelectionToolbar, isTargetWithinSelectionToolbar],
1419
+ );
1420
+
1421
+ const handleSelectionToolbarFocusCapture = useCallback(() => {
1422
+ setSelectionToolbarFocusWithin(true);
1423
+ activeRuntime.focus();
1424
+ }, [activeRuntime]);
1425
+
1426
+ const handleSelectionToolbarBlurCapture = useCallback(
1427
+ (event: FocusEvent<HTMLDivElement>) => {
1428
+ if (isTargetWithinSelectionToolbar(event.relatedTarget)) {
1429
+ return;
1430
+ }
1431
+
1432
+ if (isTargetWithinDocumentSurface(event.relatedTarget)) {
1433
+ setSelectionToolbarFocusWithin(false);
1434
+ activeRuntime.focus();
1435
+ return;
1436
+ }
1437
+
1438
+ setSelectionToolbarFocusWithin(false);
1439
+ activeRuntime.blur();
1440
+ dismissSelectionToolbar("blur");
1441
+ },
1442
+ [
1443
+ activeRuntime,
1444
+ dismissSelectionToolbar,
1445
+ isTargetWithinDocumentSurface,
1446
+ isTargetWithinSelectionToolbar,
1447
+ ],
1448
+ );
1449
+
1450
+ const addSelectionToolbarComment = useCallback(() => {
1451
+ const commentId = addReviewComment();
1452
+ if (!commentId) {
1453
+ return;
1454
+ }
1455
+ dismissSelectionToolbar("comment-action");
1456
+ queueMicrotask(() => {
1457
+ focusDocumentSurface();
1049
1458
  });
1459
+ }, [dismissSelectionToolbar, focusDocumentSurface]);
1460
+
1461
+ const handleSelectionToolbarAnchorChange = useCallback(
1462
+ (nextAnchor: SelectionToolbarAnchor | null) => {
1463
+ setSelectionToolbarAnchor((current) =>
1464
+ selectionToolbarAnchorsEqual(current, nextAnchor) ? current : nextAnchor);
1465
+ },
1466
+ [],
1467
+ );
1468
+
1469
+ useEffect(() => {
1470
+ if (!selectionToolbarSelectionKey) {
1471
+ setSelectionToolbarDismissedKey(null);
1472
+ setSelectionToolbarFocusWithin(false);
1473
+ setSelectionToolbarAnchor(null);
1474
+ lastSelectionToolbarKeyRef.current = null;
1475
+ return;
1476
+ }
1477
+
1478
+ if (lastSelectionToolbarKeyRef.current !== selectionToolbarSelectionKey) {
1479
+ lastSelectionToolbarKeyRef.current = selectionToolbarSelectionKey;
1480
+ setSelectionToolbarDismissedKey(null);
1481
+ setSelectionToolbarFocusWithin(false);
1482
+ }
1483
+ }, [selectionToolbarSelectionKey]);
1484
+
1485
+ useEffect(() => {
1486
+ if (!selectionToolbar) {
1487
+ setSelectionToolbarAnchor(null);
1488
+ setSelectionToolbarFocusWithin(false);
1489
+ }
1490
+ }, [selectionToolbar]);
1050
1491
 
1051
1492
  useEffect(() => {
1052
1493
  const shell = shellRef.current;
@@ -1075,6 +1516,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1075
1516
  }, [loadError, snapshot.fatalError]);
1076
1517
 
1077
1518
  function handleShellKeyDownCapture(event: React.KeyboardEvent<HTMLDivElement>): void {
1519
+ if (
1520
+ event.key === "Escape" &&
1521
+ shouldRenderSelectionToolbar &&
1522
+ (isTargetWithinDocumentSurface(event.target) || isTargetWithinSelectionToolbar(event.target))
1523
+ ) {
1524
+ event.preventDefault();
1525
+ event.stopPropagation();
1526
+ const restoreSurfaceFocus = isTargetWithinSelectionToolbar(event.target);
1527
+ dismissSelectionToolbar("escape");
1528
+ if (restoreSurfaceFocus) {
1529
+ queueMicrotask(() => {
1530
+ focusDocumentSurface();
1531
+ });
1532
+ }
1533
+ return;
1534
+ }
1535
+
1078
1536
  if (event.key !== "F6") {
1079
1537
  return;
1080
1538
  }
@@ -1089,15 +1547,16 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1089
1547
  }
1090
1548
 
1091
1549
  const editorCallbacks = {
1092
- onFocus: () => activeRuntime.focus(),
1093
- onBlur: () => activeRuntime.blur(),
1550
+ onFocus: handleSurfaceFocus,
1551
+ onBlur: handleSurfaceBlur,
1094
1552
  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" }),
1553
+ onInsertText: (text: string) => applyRuntimeTextCommand(activeRuntime, { type: "insert-text", text }),
1554
+ onDeleteBackward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-backward" }),
1555
+ onDeleteForward: () => applyRuntimeTextCommand(activeRuntime, { type: "delete-forward" }),
1556
+ onInsertTab: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-tab" }),
1557
+ onOutdentTab: () => applyRuntimeTextCommand(activeRuntime, { type: "outdent-tab" }),
1558
+ onInsertHardBreak: () => applyRuntimeTextCommand(activeRuntime, { type: "insert-hard-break" }),
1559
+ onSplitParagraph: () => applyRuntimeTextCommand(activeRuntime, { type: "split-paragraph" }),
1101
1560
  };
1102
1561
 
1103
1562
  const reviewCallbacks = {
@@ -1145,6 +1604,9 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1145
1604
  activeRuntime.rejectAllChanges();
1146
1605
  setActiveRailTab("changes");
1147
1606
  },
1607
+ onCloseStory: () => {
1608
+ activeRuntime.closeStory();
1609
+ },
1148
1610
  };
1149
1611
 
1150
1612
  return (
@@ -1185,29 +1647,80 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1185
1647
  ) : null}
1186
1648
  <TwReviewWorkspace
1187
1649
  snapshot={snapshot}
1650
+ viewState={viewState}
1188
1651
  currentUserId={currentUser.userId}
1189
1652
  capabilities={capabilities}
1653
+ documentNavigation={documentNavigation}
1190
1654
  reviewMode={reviewMode}
1191
- viewMode={viewMode}
1655
+ workspaceMode={viewState.workspaceMode}
1656
+ zoomLevel={viewState.zoomLevel}
1657
+ formattingState={formattingState}
1658
+ styleCatalog={styleCatalog}
1192
1659
  activeRailTab={activeRailTab}
1193
1660
  activeCommentId={snapshot.comments.activeCommentId}
1194
1661
  activeRevisionId={activeRevisionId}
1195
1662
  showTrackedChanges={showTrackedChanges}
1196
- selectionPreview={selectionPreview}
1197
- addCommentDisabledReason={addCommentDisabledReason}
1198
- onViewModeChange={setViewMode}
1663
+ selectionToolbar={shouldRenderSelectionToolbar ? selectionToolbar : null}
1664
+ selectionToolbarAnchor={shouldRenderSelectionToolbar ? selectionToolbarAnchor : null}
1665
+ onAddCommentFromSelection={addSelectionToolbarComment}
1666
+ onDismissSelectionToolbar={() => dismissSelectionToolbar("chrome-action")}
1667
+ onSelectionToolbarFocusCapture={handleSelectionToolbarFocusCapture}
1668
+ onSelectionToolbarBlurCapture={handleSelectionToolbarBlurCapture}
1669
+ selectionToolbarRef={selectionToolbarElementRef}
1670
+ onWorkspaceModeChange={(mode) => activeRuntime.setWorkspaceMode(mode)}
1671
+ onZoomChange={(level) => activeRuntime.setZoom(level)}
1199
1672
  onActiveRailTabChange={setActiveRailTab}
1200
1673
  onShowTrackedChangesChange={setShowTrackedChanges}
1674
+ onToggleBold={() =>
1675
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "bold" })}
1676
+ onToggleItalic={() =>
1677
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "italic" })}
1678
+ onToggleUnderline={() =>
1679
+ applyRuntimeFormattingOperation(activeRuntime, { type: "toggle", mark: "underline" })}
1680
+ onSetParagraphStyle={(styleId) =>
1681
+ applyRuntimeParagraphStyle(activeRuntime, styleId)}
1682
+ onOutdent={() =>
1683
+ applyRuntimeFormattingOperation(activeRuntime, { type: "outdent" })}
1684
+ onIndent={() =>
1685
+ applyRuntimeFormattingOperation(activeRuntime, { type: "indent" })}
1686
+ onOpenHeaderStory={() =>
1687
+ openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "header")}
1688
+ onOpenFooterStory={() =>
1689
+ openDefaultStoryVariant(activeRuntime, snapshot.pageLayout, documentNavigation, "footer")}
1690
+ onSetParagraphIndentation={(indentation) =>
1691
+ applyRuntimeParagraphIndentation(activeRuntime, indentation)
1692
+ }
1693
+ onSetParagraphTabStops={(tabStops) =>
1694
+ applyRuntimeParagraphTabStops(activeRuntime, tabStops)
1695
+ }
1696
+ onRestartNumbering={() => applyRuntimeNumberingFlow(activeRuntime, { type: "restart" })}
1697
+ onContinueNumbering={() => applyRuntimeNumberingFlow(activeRuntime, { type: "continue" })}
1698
+ onNavigateHeading={(headingId) => {
1699
+ const heading = documentNavigation.headings.find(
1700
+ (entry) => entry.headingId === headingId,
1701
+ );
1702
+ if (!heading) {
1703
+ return;
1704
+ }
1705
+ applyRuntimeSelection(
1706
+ activeRuntime,
1707
+ createCollapsedPublicSelection(heading.offset),
1708
+ );
1709
+ }}
1201
1710
  {...reviewCallbacks}
1202
1711
  document={
1203
1712
  <TwProseMirrorSurface
1204
1713
  ref={surfaceRef}
1205
1714
  currentUser={currentUser}
1206
1715
  snapshot={snapshot}
1716
+ canonicalDocument={canonicalDocument}
1717
+ documentNavigation={documentNavigation}
1207
1718
  reviewMode={reviewMode}
1208
1719
  markupDisplay={liveMarkupDisplay}
1209
1720
  activeRevisionId={activeRevisionId}
1210
1721
  showTrackedChanges={showTrackedChanges}
1722
+ isPageWorkspace={isPageWorkspace}
1723
+ onSelectionToolbarAnchorChange={handleSelectionToolbarAnchorChange}
1211
1724
  {...editorCallbacks}
1212
1725
  onCommentActivated={(commentId) => {
1213
1726
  activeRuntime.openComment(commentId);
@@ -1237,138 +1750,934 @@ function applyRuntimeFormattingOperation(
1237
1750
  | { type: "indent" }
1238
1751
  | { type: "outdent" },
1239
1752
  ): void {
1240
- const snapshot = runtime.getRenderSnapshot();
1241
- if (!snapshot.isReady || snapshot.readOnly || snapshot.fatalError) {
1753
+ const context = getStoryMutationContext(runtime);
1754
+ if (!context) {
1242
1755
  return;
1243
1756
  }
1244
1757
 
1245
1758
  const result = applyFormattingOperationToDocument(
1246
- runtime.getPersistedSnapshot().canonicalDocument,
1247
- snapshot,
1759
+ context.localDocument,
1760
+ context.localSnapshot,
1248
1761
  operation,
1249
1762
  );
1250
- if (!result.changed) {
1251
- return;
1252
- }
1763
+ dispatchStoryMutationResult(
1764
+ runtime,
1765
+ context,
1766
+ {
1767
+ ...result,
1768
+ selection: toRuntimeSelectionSnapshot(result.selection),
1769
+ },
1770
+ context.timestamp,
1771
+ );
1772
+ }
1253
1773
 
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(),
1774
+ function getRuntimeStyleCatalog(
1775
+ runtime: WordReviewEditorRuntime,
1776
+ ): StyleCatalogSnapshot {
1777
+ const styles = runtime.getSessionState().canonicalDocument.styles;
1778
+ const mapRecord = <
1779
+ T extends {
1780
+ styleId: string;
1781
+ displayName: string;
1782
+ kind: "paragraph" | "character" | "table";
1783
+ isDefault: boolean;
1784
+ basedOn?: string;
1785
+ nextStyle?: string;
1261
1786
  },
1262
- });
1787
+ >(
1788
+ record: Record<string, T>,
1789
+ ) =>
1790
+ Object.values(record)
1791
+ .map((entry) => ({
1792
+ styleId: entry.styleId,
1793
+ displayName: entry.displayName,
1794
+ kind: entry.kind,
1795
+ isDefault: entry.isDefault,
1796
+ ...(entry.basedOn ? { basedOn: entry.basedOn } : {}),
1797
+ ...(entry.nextStyle ? { nextStyle: entry.nextStyle } : {}),
1798
+ }))
1799
+ .sort((left, right) =>
1800
+ left.displayName.localeCompare(right.displayName) ||
1801
+ left.styleId.localeCompare(right.styleId),
1802
+ );
1803
+
1804
+ return {
1805
+ paragraphs: mapRecord(styles.paragraphs),
1806
+ characters: mapRecord(styles.characters),
1807
+ tables: mapRecord(styles.tables),
1808
+ fromPackage: styles.fromPackage === true,
1809
+ };
1263
1810
  }
1264
1811
 
1265
- function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
1266
- const snapshot = runtime.getRenderSnapshot();
1267
- if (!canApplyRuntimeMutation(snapshot)) {
1812
+ function applyRuntimeParagraphStyle(
1813
+ runtime: WordReviewEditorRuntime,
1814
+ styleId: string | null,
1815
+ ): void {
1816
+ const context = getStoryMutationContext(runtime);
1817
+ if (!context) {
1268
1818
  return;
1269
1819
  }
1270
1820
 
1271
- const timestamp = new Date().toISOString();
1272
- const result = insertPageBreakInDocument(
1273
- runtime.getPersistedSnapshot().canonicalDocument,
1274
- toRuntimeSelectionSnapshot(snapshot.selection),
1275
- { timestamp },
1821
+ const result = applyParagraphStyleToDocument(
1822
+ context.localDocument,
1823
+ context.localSnapshot,
1824
+ styleId,
1825
+ );
1826
+ dispatchStoryMutationResult(
1827
+ runtime,
1828
+ context,
1829
+ {
1830
+ ...result,
1831
+ selection: toRuntimeSelectionSnapshot(result.selection),
1832
+ },
1833
+ context.timestamp,
1276
1834
  );
1277
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1278
1835
  }
1279
1836
 
1280
- function applyRuntimeInsertTable(
1837
+ function applyRuntimeTableStyle(
1281
1838
  runtime: WordReviewEditorRuntime,
1282
- options: InsertTableOptions,
1839
+ styleId: string | null,
1283
1840
  ): void {
1284
- const snapshot = runtime.getRenderSnapshot();
1285
- if (!canApplyRuntimeMutation(snapshot)) {
1841
+ const context = getStoryMutationContext(runtime);
1842
+ if (!context) {
1286
1843
  return;
1287
1844
  }
1288
1845
 
1289
- const timestamp = new Date().toISOString();
1290
- const result = insertTableInDocument(
1291
- runtime.getPersistedSnapshot().canonicalDocument,
1292
- toRuntimeSelectionSnapshot(snapshot.selection),
1293
- options,
1294
- { timestamp },
1846
+ const result = applyTableStyleToDocument(
1847
+ context.localDocument,
1848
+ context.localSnapshot,
1849
+ styleId,
1850
+ );
1851
+ dispatchStoryMutationResult(
1852
+ runtime,
1853
+ context,
1854
+ {
1855
+ ...result,
1856
+ selection: toRuntimeSelectionSnapshot(result.selection),
1857
+ },
1858
+ context.timestamp,
1295
1859
  );
1296
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1297
1860
  }
1298
1861
 
1299
- function applyRuntimeInsertImage(
1862
+ function applyRuntimeParagraphIndentation(
1300
1863
  runtime: WordReviewEditorRuntime,
1301
- options: InsertImageOptions,
1864
+ indentation: {
1865
+ left?: number;
1866
+ right?: number;
1867
+ firstLine?: number;
1868
+ hanging?: number;
1869
+ },
1302
1870
  ): void {
1303
- const snapshot = runtime.getRenderSnapshot();
1304
- if (!canApplyRuntimeMutation(snapshot)) {
1871
+ const context = getStoryMutationContext(runtime);
1872
+ if (!context) {
1305
1873
  return;
1306
1874
  }
1307
1875
 
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 {
1329
- return;
1330
- }
1876
+ const result = setActiveParagraphIndentation(
1877
+ context.localDocument,
1878
+ context.localSnapshot,
1879
+ indentation,
1880
+ { timestamp: context.timestamp },
1881
+ );
1882
+ dispatchStoryMutationResult(
1883
+ runtime,
1884
+ context,
1885
+ {
1886
+ ...result,
1887
+ selection: toRuntimeSelectionSnapshot(result.selection),
1888
+ },
1889
+ context.timestamp,
1890
+ );
1331
1891
  }
1332
1892
 
1333
- function applyRuntimeTableStructureOperation(
1893
+ function applyRuntimeParagraphTabStops(
1334
1894
  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 },
1895
+ tabStops: Array<{ pos: number; val?: string; leader?: string }>,
1347
1896
  ): void {
1348
- const snapshot = runtime.getRenderSnapshot();
1349
- if (!canApplyRuntimeMutation(snapshot)) {
1897
+ const context = getStoryMutationContext(runtime);
1898
+ if (!context) {
1350
1899
  return;
1351
1900
  }
1352
1901
 
1353
- const timestamp = new Date().toISOString();
1354
- const result = applyTableStructureOperation(
1355
- runtime.getPersistedSnapshot().canonicalDocument,
1356
- snapshot,
1357
- mountedSurface?.getTableSelection() ?? null,
1358
- operation,
1902
+ const result = setActiveParagraphTabStops(
1903
+ context.localDocument,
1904
+ context.localSnapshot,
1905
+ tabStops,
1906
+ { timestamp: context.timestamp },
1907
+ );
1908
+ dispatchStoryMutationResult(
1909
+ runtime,
1910
+ context,
1911
+ {
1912
+ ...result,
1913
+ selection: toRuntimeSelectionSnapshot(result.selection),
1914
+ },
1915
+ context.timestamp,
1359
1916
  );
1360
- dispatchRuntimeDocumentMutation(runtime, result, timestamp);
1917
+ }
1918
+
1919
+ function applyRuntimeNumberingFlow(
1920
+ runtime: WordReviewEditorRuntime,
1921
+ operation: { type: "restart"; startAt?: number } | { type: "continue" },
1922
+ ): void {
1923
+ const context = getStoryMutationContext(runtime);
1924
+ if (!context) {
1925
+ return;
1926
+ }
1927
+
1928
+ const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
1929
+ if (!paragraphContext?.paragraph.numbering) {
1930
+ return;
1931
+ }
1932
+
1933
+ const result =
1934
+ operation.type === "restart"
1935
+ ? restartListNumbering(
1936
+ context.localDocument,
1937
+ paragraphContext.paragraphIndex,
1938
+ { timestamp: context.timestamp },
1939
+ operation.startAt,
1940
+ )
1941
+ : continueListNumbering(
1942
+ context.localDocument,
1943
+ paragraphContext.paragraphIndex,
1944
+ { timestamp: context.timestamp },
1945
+ );
1946
+
1947
+ dispatchStoryMutationResult(
1948
+ runtime,
1949
+ context,
1950
+ {
1951
+ changed: result.affectedParagraphIndexes.length > 0,
1952
+ document: result.document,
1953
+ selection: toRuntimeSelectionSnapshot(context.localSnapshot.selection),
1954
+ },
1955
+ context.timestamp,
1956
+ );
1957
+ }
1958
+
1959
+ function applyRuntimeInsertSectionBreak(
1960
+ runtime: WordReviewEditorRuntime,
1961
+ breakType: SectionBreakType,
1962
+ options?: { afterSectionIndex?: number },
1963
+ ): void {
1964
+ const snapshot = runtime.getRenderSnapshot();
1965
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
1966
+ return;
1967
+ }
1968
+
1969
+ const sessionState = runtime.getSessionState();
1970
+ const timestamp = new Date().toISOString();
1971
+ const result =
1972
+ typeof options?.afterSectionIndex === "number"
1973
+ ? insertSectionBreakAfterSectionIndex(
1974
+ sessionState.canonicalDocument,
1975
+ options.afterSectionIndex,
1976
+ breakType,
1977
+ { timestamp },
1978
+ )
1979
+ : insertSectionBreakAfterSectionIndex(
1980
+ sessionState.canonicalDocument,
1981
+ runtime.getDocumentNavigationSnapshot().activeSectionIndex,
1982
+ breakType,
1983
+ { timestamp },
1984
+ );
1985
+
1986
+ dispatchRuntimeDocumentMutation(
1987
+ runtime,
1988
+ {
1989
+ changed: result.changed,
1990
+ document: result.document,
1991
+ selection: toRuntimeSelectionSnapshot(result.selection),
1992
+ },
1993
+ timestamp,
1994
+ );
1995
+ }
1996
+
1997
+ function applyRuntimeDeleteSectionBreak(
1998
+ runtime: WordReviewEditorRuntime,
1999
+ sectionIndex: number,
2000
+ ): void {
2001
+ const snapshot = runtime.getRenderSnapshot();
2002
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2003
+ return;
2004
+ }
2005
+
2006
+ const sessionState = runtime.getSessionState();
2007
+ const timestamp = new Date().toISOString();
2008
+ const result = deleteSectionBreakAtSectionIndex(
2009
+ sessionState.canonicalDocument,
2010
+ sectionIndex,
2011
+ { timestamp },
2012
+ );
2013
+
2014
+ dispatchRuntimeDocumentMutation(
2015
+ runtime,
2016
+ {
2017
+ changed: result.changed,
2018
+ document: result.document,
2019
+ selection: toRuntimeSelectionSnapshot(result.selection),
2020
+ },
2021
+ timestamp,
2022
+ );
2023
+ }
2024
+
2025
+ function applyRuntimeUpdateSectionLayout(
2026
+ runtime: WordReviewEditorRuntime,
2027
+ sectionIndex: number,
2028
+ patch: SectionLayoutPatch,
2029
+ ): void {
2030
+ const snapshot = runtime.getRenderSnapshot();
2031
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2032
+ return;
2033
+ }
2034
+
2035
+ const sessionState = runtime.getSessionState();
2036
+ const timestamp = new Date().toISOString();
2037
+ const result = updateSectionLayoutAtSectionIndex(
2038
+ sessionState.canonicalDocument,
2039
+ sectionIndex,
2040
+ {
2041
+ ...(patch.pageSize ? { pageSize: patch.pageSize } : {}),
2042
+ ...(patch.pageMargins ? { pageMargins: patch.pageMargins } : {}),
2043
+ ...(patch.columns ? { columns: patch.columns } : {}),
2044
+ ...(patch.titlePage !== undefined ? { titlePage: patch.titlePage } : {}),
2045
+ ...(patch.sectionType ? { sectionType: patch.sectionType } : {}),
2046
+ },
2047
+ { timestamp },
2048
+ );
2049
+
2050
+ dispatchRuntimeDocumentMutation(
2051
+ runtime,
2052
+ {
2053
+ changed: result.changed,
2054
+ document: result.document,
2055
+ selection: toRuntimeSelectionSnapshot(result.selection),
2056
+ },
2057
+ timestamp,
2058
+ );
2059
+ }
2060
+
2061
+ function applyRuntimeSetSectionPageNumbering(
2062
+ runtime: WordReviewEditorRuntime,
2063
+ sectionIndex: number,
2064
+ patch: SectionPageNumberingPatch | null,
2065
+ ): void {
2066
+ const snapshot = runtime.getRenderSnapshot();
2067
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2068
+ return;
2069
+ }
2070
+
2071
+ const sessionState = runtime.getSessionState();
2072
+ const timestamp = new Date().toISOString();
2073
+ const normalizedPatch =
2074
+ patch === null
2075
+ ? null
2076
+ : {
2077
+ ...(patch.format !== undefined
2078
+ ? { format: patch.format ?? undefined }
2079
+ : {}),
2080
+ ...(patch.start !== undefined
2081
+ ? { start: patch.start ?? undefined }
2082
+ : {}),
2083
+ ...(patch.chapterStyle !== undefined
2084
+ ? { chapStyle: patch.chapterStyle ?? undefined }
2085
+ : {}),
2086
+ ...(patch.chapterSeparator !== undefined
2087
+ ? { chapSep: patch.chapterSeparator ?? undefined }
2088
+ : {}),
2089
+ };
2090
+ const result = setSectionPageNumberingAtSectionIndex(
2091
+ sessionState.canonicalDocument,
2092
+ sectionIndex,
2093
+ normalizedPatch,
2094
+ { timestamp },
2095
+ );
2096
+
2097
+ dispatchRuntimeDocumentMutation(
2098
+ runtime,
2099
+ {
2100
+ changed: result.changed,
2101
+ document: result.document,
2102
+ selection: toRuntimeSelectionSnapshot(result.selection),
2103
+ },
2104
+ timestamp,
2105
+ );
2106
+ }
2107
+
2108
+ function applyRuntimeSetHeaderFooterLink(
2109
+ runtime: WordReviewEditorRuntime,
2110
+ sectionIndex: number,
2111
+ patch: HeaderFooterLinkPatch,
2112
+ ): void {
2113
+ const snapshot = runtime.getRenderSnapshot();
2114
+ if (!canApplyRuntimeMutation(snapshot) || snapshot.activeStory.kind !== "main") {
2115
+ return;
2116
+ }
2117
+
2118
+ const sessionState = runtime.getSessionState();
2119
+ const timestamp = new Date().toISOString();
2120
+ const result = setHeaderFooterLinkAtSectionIndex(
2121
+ sessionState.canonicalDocument,
2122
+ sectionIndex,
2123
+ patch,
2124
+ { timestamp },
2125
+ );
2126
+
2127
+ dispatchRuntimeDocumentMutation(
2128
+ runtime,
2129
+ {
2130
+ changed: result.changed,
2131
+ document: result.document,
2132
+ selection: toRuntimeSelectionSnapshot(result.selection),
2133
+ },
2134
+ timestamp,
2135
+ );
2136
+ }
2137
+
2138
+ function applyRuntimeInsertPageBreak(runtime: WordReviewEditorRuntime): void {
2139
+ const context = getStoryMutationContext(runtime);
2140
+ if (!context) {
2141
+ return;
2142
+ }
2143
+
2144
+ const result = insertPageBreakInDocument(
2145
+ context.localDocument,
2146
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2147
+ { timestamp: context.timestamp },
2148
+ );
2149
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2150
+ }
2151
+
2152
+ function applyRuntimeInsertTable(
2153
+ runtime: WordReviewEditorRuntime,
2154
+ options: InsertTableOptions,
2155
+ ): void {
2156
+ const context = getStoryMutationContext(runtime);
2157
+ if (!context) {
2158
+ return;
2159
+ }
2160
+
2161
+ const result = insertTableInDocument(
2162
+ context.localDocument,
2163
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2164
+ options,
2165
+ { timestamp: context.timestamp },
2166
+ );
2167
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2168
+ }
2169
+
2170
+ function applyRuntimeInsertImage(
2171
+ runtime: WordReviewEditorRuntime,
2172
+ options: InsertImageOptions,
2173
+ ): void {
2174
+ const context = getStoryMutationContext(runtime);
2175
+ if (!context) {
2176
+ return;
2177
+ }
2178
+
2179
+ try {
2180
+ const result = insertImageInDocument(
2181
+ context.localDocument,
2182
+ toRuntimeSelectionSnapshot(context.localSnapshot.selection),
2183
+ options.data,
2184
+ options.mimeType,
2185
+ options.width,
2186
+ options.height,
2187
+ {
2188
+ timestamp: context.timestamp,
2189
+ altText: options.altText,
2190
+ },
2191
+ );
2192
+ dispatchStoryMutationResult(runtime, context, {
2193
+ changed: true,
2194
+ document: result.document,
2195
+ selection: result.selection,
2196
+ mapping: result.mapping,
2197
+ }, context.timestamp);
2198
+ } catch {
2199
+ return;
2200
+ }
2201
+ }
2202
+
2203
+ function applyRuntimeImageResize(
2204
+ runtime: WordReviewEditorRuntime,
2205
+ mediaId: string,
2206
+ dimensions: { widthEmu: number; heightEmu: number },
2207
+ ): void {
2208
+ const snapshot = runtime.getRenderSnapshot();
2209
+ if (!canApplyRuntimeMutation(snapshot)) {
2210
+ return;
2211
+ }
2212
+
2213
+ try {
2214
+ const sessionState = runtime.getSessionState();
2215
+ const result = resizeImageInCatalog(
2216
+ sessionState.canonicalDocument,
2217
+ mediaId,
2218
+ dimensions,
2219
+ );
2220
+ runtime.dispatch({
2221
+ type: "document.replace",
2222
+ document: result.document,
2223
+ selection: toRuntimeSelectionSnapshot(snapshot.selection),
2224
+ origin: { source: "api", timestamp: new Date().toISOString() },
2225
+ });
2226
+ } catch {
2227
+ return;
2228
+ }
2229
+ }
2230
+
2231
+ function applyRuntimeImageReposition(
2232
+ runtime: WordReviewEditorRuntime,
2233
+ mediaId: string,
2234
+ offsets: { horizontalOffsetEmu?: number; verticalOffsetEmu?: number },
2235
+ ): void {
2236
+ const snapshot = runtime.getRenderSnapshot();
2237
+ if (!canApplyRuntimeMutation(snapshot)) {
2238
+ return;
2239
+ }
2240
+
2241
+ try {
2242
+ const sessionState = runtime.getSessionState();
2243
+ const result = repositionFloatingImageInDocument(
2244
+ sessionState.canonicalDocument,
2245
+ mediaId,
2246
+ offsets,
2247
+ );
2248
+ runtime.dispatch({
2249
+ type: "document.replace",
2250
+ document: result.document,
2251
+ selection: toRuntimeSelectionSnapshot(snapshot.selection),
2252
+ origin: { source: "api", timestamp: new Date().toISOString() },
2253
+ });
2254
+ } catch {
2255
+ return;
2256
+ }
2257
+ }
2258
+
2259
+ // deriveViewState removed — the runtime's getViewState() is now the single
2260
+ // source of truth for EditorViewStateSnapshot, backed by view-state.ts.
2261
+
2262
+ function applyRuntimeTableStructureOperation(
2263
+ runtime: WordReviewEditorRuntime,
2264
+ mountedSurface: TwProseMirrorSurfaceRef | null | undefined,
2265
+ operation:
2266
+ | { type: "add-row-before" }
2267
+ | { type: "add-row-after" }
2268
+ | { type: "add-column-before" }
2269
+ | { type: "add-column-after" }
2270
+ | { type: "delete-row" }
2271
+ | { type: "delete-column" }
2272
+ | { type: "delete-table" }
2273
+ | { type: "merge-cells" }
2274
+ | { type: "split-cell" }
2275
+ | { type: "set-cell-background"; color: string },
2276
+ ): void {
2277
+ const context = getStoryMutationContext(runtime);
2278
+ if (!context) {
2279
+ return;
2280
+ }
2281
+
2282
+ const result = applyTableStructureOperation(
2283
+ context.localDocument,
2284
+ context.localSnapshot,
2285
+ mountedSurface?.getTableSelection() ?? null,
2286
+ operation,
2287
+ );
2288
+ dispatchStoryMutationResult(runtime, context, result, context.timestamp);
2289
+ }
2290
+
2291
+ function applyRuntimeTextCommand(
2292
+ runtime: WordReviewEditorRuntime,
2293
+ command:
2294
+ | { type: "insert-text"; text: string }
2295
+ | { type: "delete-backward" }
2296
+ | { type: "delete-forward" }
2297
+ | { type: "insert-tab" }
2298
+ | { type: "outdent-tab" }
2299
+ | { type: "insert-hard-break" }
2300
+ | { type: "split-paragraph" },
2301
+ ): void {
2302
+ const context = getStoryMutationContext(runtime);
2303
+ if (!context) {
2304
+ return;
2305
+ }
2306
+
2307
+ const listAwareResult = applyListAwareTextCommand(context, command);
2308
+ if (listAwareResult) {
2309
+ dispatchStoryMutationResult(runtime, context, listAwareResult, context.timestamp);
2310
+ return;
2311
+ }
2312
+
2313
+ if (context.activeStory.kind === "main") {
2314
+ switch (command.type) {
2315
+ case "insert-text":
2316
+ runtime.dispatch({ type: "text.insert", text: command.text });
2317
+ return;
2318
+ case "delete-backward":
2319
+ runtime.dispatch({ type: "text.delete-backward" });
2320
+ return;
2321
+ case "delete-forward":
2322
+ runtime.dispatch({ type: "text.delete-forward" });
2323
+ return;
2324
+ case "insert-tab":
2325
+ runtime.dispatch({ type: "text.insert-tab" });
2326
+ return;
2327
+ case "outdent-tab":
2328
+ return;
2329
+ case "insert-hard-break":
2330
+ runtime.dispatch({ type: "text.insert-hard-break" });
2331
+ return;
2332
+ case "split-paragraph":
2333
+ runtime.dispatch({ type: "paragraph.split" });
2334
+ return;
2335
+ }
2336
+ }
2337
+
2338
+ const selection = toRuntimeSelectionSnapshot(context.localSnapshot.selection);
2339
+ const localResult = (() => {
2340
+ switch (command.type) {
2341
+ case "insert-text":
2342
+ return insertTextInDocument(
2343
+ context.localDocument,
2344
+ selection,
2345
+ command.text,
2346
+ { timestamp: context.timestamp },
2347
+ );
2348
+ case "delete-backward":
2349
+ return deleteSelectionOrBackward(
2350
+ context.localDocument,
2351
+ selection,
2352
+ { timestamp: context.timestamp },
2353
+ );
2354
+ case "delete-forward":
2355
+ return deleteSelectionOrForward(
2356
+ context.localDocument,
2357
+ selection,
2358
+ { timestamp: context.timestamp },
2359
+ );
2360
+ case "insert-tab":
2361
+ return insertTabInDocument(
2362
+ context.localDocument,
2363
+ selection,
2364
+ { timestamp: context.timestamp },
2365
+ );
2366
+ case "outdent-tab":
2367
+ return {
2368
+ changed: false,
2369
+ document: context.localDocument,
2370
+ selection,
2371
+ };
2372
+ case "insert-hard-break":
2373
+ return insertHardBreakInDocument(
2374
+ context.localDocument,
2375
+ selection,
2376
+ { timestamp: context.timestamp },
2377
+ );
2378
+ case "split-paragraph":
2379
+ return splitParagraphInDocument(
2380
+ context.localDocument,
2381
+ selection,
2382
+ { timestamp: context.timestamp },
2383
+ );
2384
+ }
2385
+ })();
2386
+
2387
+ dispatchStoryMutationResult(
2388
+ runtime,
2389
+ context,
2390
+ {
2391
+ changed: "changed" in localResult ? localResult.changed : true,
2392
+ document: localResult.document,
2393
+ selection: localResult.selection,
2394
+ mapping: "mapping" in localResult ? localResult.mapping : undefined,
2395
+ },
2396
+ context.timestamp,
2397
+ );
2398
+ }
2399
+
2400
+ function applyListAwareTextCommand(
2401
+ context: NonNullable<ReturnType<typeof getStoryMutationContext>>,
2402
+ command:
2403
+ | { type: "insert-text"; text: string }
2404
+ | { type: "delete-backward" }
2405
+ | { type: "delete-forward" }
2406
+ | { type: "insert-tab" }
2407
+ | { type: "outdent-tab" }
2408
+ | { type: "insert-hard-break" }
2409
+ | { type: "split-paragraph" },
2410
+ ): {
2411
+ changed: boolean;
2412
+ document: EditorSessionState["canonicalDocument"];
2413
+ selection: InternalSelectionSnapshot;
2414
+ } | null {
2415
+ const paragraphContext = resolveActiveParagraphContext(context.localSnapshot);
2416
+ if (!paragraphContext?.paragraph.numbering) {
2417
+ return null;
2418
+ }
2419
+
2420
+ switch (command.type) {
2421
+ case "insert-tab": {
2422
+ const result = indentListItems(
2423
+ context.localDocument,
2424
+ [paragraphContext.paragraphIndex],
2425
+ { timestamp: context.timestamp },
2426
+ );
2427
+ return createListMutationResult(result, context.localSnapshot.selection);
2428
+ }
2429
+ case "outdent-tab": {
2430
+ const result = outdentListItems(
2431
+ context.localDocument,
2432
+ [paragraphContext.paragraphIndex],
2433
+ { timestamp: context.timestamp },
2434
+ );
2435
+ return createListMutationResult(result, context.localSnapshot.selection);
2436
+ }
2437
+ case "delete-backward": {
2438
+ if (!paragraphContext.atParagraphStart || !context.localSnapshot.selection.isCollapsed) {
2439
+ return null;
2440
+ }
2441
+ const result = backspaceAtListStart(
2442
+ context.localDocument,
2443
+ paragraphContext.paragraphIndex,
2444
+ { timestamp: context.timestamp },
2445
+ );
2446
+ return result.handled
2447
+ ? createListMutationResult(result, context.localSnapshot.selection)
2448
+ : null;
2449
+ }
2450
+ case "split-paragraph": {
2451
+ if (!context.localSnapshot.selection.isCollapsed || !paragraphContext.isEmpty) {
2452
+ return null;
2453
+ }
2454
+ const result = splitListParagraph(
2455
+ context.localDocument,
2456
+ paragraphContext.paragraphIndex,
2457
+ true,
2458
+ { timestamp: context.timestamp },
2459
+ );
2460
+ return result.action === "split"
2461
+ ? null
2462
+ : createListMutationResult(result, context.localSnapshot.selection);
2463
+ }
2464
+ default:
2465
+ return null;
2466
+ }
2467
+ }
2468
+
2469
+ function createListMutationResult(
2470
+ result: {
2471
+ document: EditorSessionState["canonicalDocument"];
2472
+ affectedParagraphIndexes: number[];
2473
+ },
2474
+ selection: RuntimeRenderSnapshot["selection"],
2475
+ ): {
2476
+ changed: boolean;
2477
+ document: EditorSessionState["canonicalDocument"];
2478
+ selection: InternalSelectionSnapshot;
2479
+ } {
2480
+ return {
2481
+ changed: result.affectedParagraphIndexes.length > 0,
2482
+ document: result.document,
2483
+ selection: toRuntimeSelectionSnapshot(selection),
2484
+ };
2485
+ }
2486
+
2487
+ function resolveActiveParagraphContext(
2488
+ snapshot: Pick<RuntimeRenderSnapshot, "surface" | "selection">,
2489
+ ): {
2490
+ paragraphIndex: number;
2491
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>;
2492
+ atParagraphStart: boolean;
2493
+ isEmpty: boolean;
2494
+ } | null {
2495
+ if (!snapshot.surface) {
2496
+ return null;
2497
+ }
2498
+
2499
+ const paragraphIndex = resolveActiveParagraphIndex(
2500
+ snapshot.surface.blocks,
2501
+ snapshot.selection,
2502
+ );
2503
+ if (paragraphIndex === null) {
2504
+ return null;
2505
+ }
2506
+
2507
+ const selectionPosition =
2508
+ snapshot.selection.activeRange.kind === "node"
2509
+ ? snapshot.selection.activeRange.at
2510
+ : snapshot.selection.head;
2511
+ const paragraph = findSurfaceParagraphAtPosition(snapshot.surface.blocks, selectionPosition);
2512
+ if (!paragraph) {
2513
+ return null;
2514
+ }
2515
+
2516
+ return {
2517
+ paragraphIndex,
2518
+ paragraph,
2519
+ atParagraphStart:
2520
+ snapshot.selection.isCollapsed &&
2521
+ snapshot.selection.activeRange.kind !== "node" &&
2522
+ snapshot.selection.anchor === snapshot.selection.head &&
2523
+ snapshot.selection.head === paragraph.from,
2524
+ isEmpty: isSurfaceParagraphEmpty(paragraph),
2525
+ };
2526
+ }
2527
+
2528
+ function findSurfaceParagraphAtPosition(
2529
+ blocks: readonly SurfaceBlockSnapshot[],
2530
+ position: number,
2531
+ ): Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null {
2532
+ for (const block of blocks) {
2533
+ if (position < block.from || position > block.to) {
2534
+ continue;
2535
+ }
2536
+ if (block.kind === "paragraph") {
2537
+ return block;
2538
+ }
2539
+ if (block.kind === "table") {
2540
+ for (const row of block.rows) {
2541
+ for (const cell of row.cells) {
2542
+ const paragraph = findSurfaceParagraphAtPosition(cell.content, position);
2543
+ if (paragraph) {
2544
+ return paragraph;
2545
+ }
2546
+ }
2547
+ }
2548
+ continue;
2549
+ }
2550
+ if (block.kind === "sdt_block") {
2551
+ const paragraph = findSurfaceParagraphAtPosition(block.children, position);
2552
+ if (paragraph) {
2553
+ return paragraph;
2554
+ }
2555
+ }
2556
+ }
2557
+ return null;
2558
+ }
2559
+
2560
+ function isSurfaceParagraphEmpty(
2561
+ paragraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }>,
2562
+ ): boolean {
2563
+ if (paragraph.segments.length === 0) {
2564
+ return true;
2565
+ }
2566
+ return paragraph.segments.every((segment) => segment.kind === "text" && segment.text.length === 0);
2567
+ }
2568
+
2569
+ function applyRuntimeSelection(
2570
+ runtime: WordReviewEditorRuntime,
2571
+ selection: PublicSelectionSnapshot,
2572
+ ): void {
2573
+ const requestedStory = selection.storyTarget ?? { kind: "main" };
2574
+ if (requestedStory.kind === "main") {
2575
+ runtime.closeStory();
2576
+ } else if (!storyTargetsEqual(runtime.getActiveStory(), requestedStory)) {
2577
+ if (!runtime.openStory(requestedStory)) {
2578
+ return;
2579
+ }
2580
+ }
2581
+
2582
+ runtime.dispatch({
2583
+ type: "selection.set",
2584
+ selection: toRuntimeSelectionSnapshot(stripStoryTarget(selection)),
2585
+ });
1361
2586
  }
1362
2587
 
1363
2588
  function canApplyRuntimeMutation(snapshot: RuntimeRenderSnapshot): boolean {
1364
2589
  return snapshot.isReady && !snapshot.readOnly && !snapshot.fatalError;
1365
2590
  }
1366
2591
 
2592
+ function getStoryMutationContext(
2593
+ runtime: WordReviewEditorRuntime,
2594
+ ): {
2595
+ timestamp: string;
2596
+ activeStory: EditorStoryTarget;
2597
+ persistedDocument: EditorSessionState["canonicalDocument"];
2598
+ localDocument: EditorSessionState["canonicalDocument"];
2599
+ localSnapshot: RuntimeRenderSnapshot;
2600
+ } | null {
2601
+ const snapshot = runtime.getRenderSnapshot();
2602
+ if (!canApplyRuntimeMutation(snapshot)) {
2603
+ return null;
2604
+ }
2605
+
2606
+ const persistedDocument = runtime.getSessionState().canonicalDocument;
2607
+ const activeStory = snapshot.activeStory;
2608
+ if (activeStory.kind === "main") {
2609
+ return {
2610
+ timestamp: new Date().toISOString(),
2611
+ activeStory,
2612
+ persistedDocument,
2613
+ localDocument: persistedDocument,
2614
+ localSnapshot: snapshot,
2615
+ };
2616
+ }
2617
+
2618
+ return {
2619
+ timestamp: new Date().toISOString(),
2620
+ activeStory,
2621
+ persistedDocument,
2622
+ localDocument: {
2623
+ ...persistedDocument,
2624
+ content: {
2625
+ type: "doc",
2626
+ children: [...getStoryBlocks(persistedDocument, activeStory)],
2627
+ },
2628
+ },
2629
+ localSnapshot: {
2630
+ ...snapshot,
2631
+ activeStory: { kind: "main" },
2632
+ selection: stripStoryTarget(snapshot.selection),
2633
+ },
2634
+ };
2635
+ }
2636
+
2637
+ function dispatchStoryMutationResult(
2638
+ runtime: WordReviewEditorRuntime,
2639
+ context: {
2640
+ activeStory: EditorStoryTarget;
2641
+ persistedDocument: EditorSessionState["canonicalDocument"];
2642
+ },
2643
+ result: {
2644
+ changed: boolean;
2645
+ document: EditorSessionState["canonicalDocument"];
2646
+ selection: InternalSelectionSnapshot;
2647
+ mapping?: TransactionMapping;
2648
+ },
2649
+ timestamp: string,
2650
+ ): void {
2651
+ if (context.activeStory.kind === "main") {
2652
+ dispatchRuntimeDocumentMutation(runtime, result, timestamp);
2653
+ return;
2654
+ }
2655
+
2656
+ if (!result.changed) {
2657
+ return;
2658
+ }
2659
+
2660
+ const nextDocument = replaceStoryBlocks(
2661
+ context.persistedDocument,
2662
+ context.activeStory,
2663
+ result.document.content.children,
2664
+ );
2665
+ dispatchRuntimeDocumentMutation(
2666
+ runtime,
2667
+ {
2668
+ changed: true,
2669
+ document: nextDocument,
2670
+ selection: result.selection,
2671
+ },
2672
+ timestamp,
2673
+ );
2674
+ }
2675
+
1367
2676
  function dispatchRuntimeDocumentMutation(
1368
2677
  runtime: WordReviewEditorRuntime,
1369
2678
  result: {
1370
2679
  changed: boolean;
1371
- document: PersistedEditorSnapshot["canonicalDocument"];
2680
+ document: EditorSessionState["canonicalDocument"];
1372
2681
  selection: InternalSelectionSnapshot;
1373
2682
  mapping?: TransactionMapping;
1374
2683
  },
@@ -1393,6 +2702,13 @@ function dispatchRuntimeDocumentMutation(
1393
2702
  });
1394
2703
  }
1395
2704
 
2705
+ function stripStoryTarget(
2706
+ selection: PublicSelectionSnapshot,
2707
+ ): PublicSelectionSnapshot {
2708
+ const { storyTarget: _storyTarget, ...rest } = selection;
2709
+ return rest;
2710
+ }
2711
+
1396
2712
  function applyRuntimeDeleteComment(
1397
2713
  runtime: WordReviewEditorRuntime,
1398
2714
  commentId: string,
@@ -1402,22 +2718,22 @@ function applyRuntimeDeleteComment(
1402
2718
  return;
1403
2719
  }
1404
2720
 
1405
- const persistedSnapshot = runtime.getPersistedSnapshot();
1406
- if (!persistedSnapshot.canonicalDocument.review.comments[commentId]) {
2721
+ const sessionState = runtime.getSessionState();
2722
+ if (!sessionState.canonicalDocument.review.comments[commentId]) {
1407
2723
  return;
1408
2724
  }
1409
2725
 
1410
2726
  const nextComments = {
1411
- ...persistedSnapshot.canonicalDocument.review.comments,
2727
+ ...sessionState.canonicalDocument.review.comments,
1412
2728
  };
1413
2729
  delete nextComments[commentId];
1414
2730
 
1415
2731
  runtime.dispatch({
1416
2732
  type: "document.replace",
1417
2733
  document: {
1418
- ...persistedSnapshot.canonicalDocument,
2734
+ ...sessionState.canonicalDocument,
1419
2735
  review: {
1420
- ...persistedSnapshot.canonicalDocument.review,
2736
+ ...sessionState.canonicalDocument.review,
1421
2737
  comments: nextComments,
1422
2738
  },
1423
2739
  },
@@ -1433,10 +2749,19 @@ function normalizeRequestedSelection(
1433
2749
  snapshot: RuntimeRenderSnapshot,
1434
2750
  selection: PublicSelectionSnapshot | null,
1435
2751
  ): PublicSelectionSnapshot {
1436
- return selection ?? createCollapsedPublicSelection(snapshot.selection.head);
2752
+ return (
2753
+ selection ??
2754
+ createCollapsedPublicSelection(
2755
+ snapshot.selection.head,
2756
+ snapshot.activeStory.kind === "main" ? undefined : snapshot.activeStory,
2757
+ )
2758
+ );
1437
2759
  }
1438
2760
 
1439
- function createCollapsedPublicSelection(position: number): PublicSelectionSnapshot {
2761
+ function createCollapsedPublicSelection(
2762
+ position: number,
2763
+ storyTarget?: EditorStoryTarget,
2764
+ ): PublicSelectionSnapshot {
1440
2765
  return {
1441
2766
  anchor: position,
1442
2767
  head: position,
@@ -1450,6 +2775,7 @@ function createCollapsedPublicSelection(position: number): PublicSelectionSnapsh
1450
2775
  end: 1,
1451
2776
  },
1452
2777
  },
2778
+ ...(storyTarget ? { storyTarget } : {}),
1453
2779
  };
1454
2780
  }
1455
2781
 
@@ -1457,61 +2783,66 @@ function clonePublicValue<T>(value: T): T {
1457
2783
  return structuredClone(value);
1458
2784
  }
1459
2785
 
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 [];
2786
+ function openDefaultStoryVariant(
2787
+ runtime: WordReviewEditorRuntime,
2788
+ pageLayout: PageLayoutSnapshot | undefined,
2789
+ navigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]> | undefined,
2790
+ kind: "header" | "footer",
2791
+ ): void {
2792
+ const variants =
2793
+ kind === "header"
2794
+ ? pageLayout?.headerVariants
2795
+ : pageLayout?.footerVariants;
2796
+ const activePage = navigation?.pages[navigation.activePageIndex];
2797
+ const isFirstPageInSection =
2798
+ activePage !== undefined &&
2799
+ activePage.sectionIndex === pageLayout?.sectionIndex &&
2800
+ activePage.pageInSection === 0;
2801
+ const isEvenDocumentPage = activePage !== undefined && (activePage.pageIndex + 1) % 2 === 0;
2802
+
2803
+ let variant =
2804
+ pageLayout?.differentFirstPage && isFirstPageInSection
2805
+ ? variants?.find((entry) => entry.variant === "first")
2806
+ : undefined;
2807
+
2808
+ if (!variant && pageLayout?.differentOddEvenPages && isEvenDocumentPage) {
2809
+ variant = variants?.find((entry) => entry.variant === "even");
1468
2810
  }
1469
2811
 
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);
2812
+ if (!variant) {
2813
+ variant = variants?.find((entry) => entry.variant === "default") ?? variants?.[0];
2814
+ }
1476
2815
 
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
- }));
2816
+ if (!variant) {
2817
+ return;
2818
+ }
2819
+ runtime.openStory({
2820
+ kind,
2821
+ relationshipId: variant.relationshipId,
2822
+ variant: variant.variant,
2823
+ sectionIndex: pageLayout?.sectionIndex,
2824
+ });
1495
2825
  }
1496
2826
 
1497
- function getActiveSearchResultIndex(
1498
- results: Array<{ from: number; to: number }>,
1499
- selection: PublicSelectionSnapshot,
1500
- ): number {
1501
- if (results.length === 0) {
1502
- return -1;
2827
+ function searchRuntimeDocument(
2828
+ runtime: WordReviewEditorRuntime,
2829
+ mountedSurface: TwProseMirrorSurfaceRef | null,
2830
+ query: string,
2831
+ options: SearchOptions = {},
2832
+ ): SearchResultSnapshot[] {
2833
+ if (mountedSurface) {
2834
+ return mountedSurface.search(query, options);
1503
2835
  }
1504
2836
 
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;
2837
+ const snapshot = runtime.getRenderSnapshot();
2838
+ return searchDocument(
2839
+ runtime.getSessionState().canonicalDocument,
2840
+ snapshot.selection,
2841
+ snapshot.activeStory,
2842
+ runtime.getDocumentNavigationSnapshot(),
2843
+ query,
2844
+ options,
2845
+ );
1515
2846
  }
1516
2847
 
1517
2848
  function applyRegionAttributes(shell: HTMLElement): void {
@@ -1656,12 +2987,21 @@ function normalizeEditorError(error: unknown): EditorError {
1656
2987
 
1657
2988
  function guessSourceLabel(
1658
2989
  initialSourceLabel?: string,
2990
+ initialSessionState?: EditorSessionState,
1659
2991
  initialSnapshot?: PersistedEditorSnapshot,
1660
2992
  externalDocSource?: WordReviewEditorProps["externalDocSource"],
1661
2993
  ): string | undefined {
1662
2994
  return (
1663
2995
  externalDocSource?.sourceLabel ??
2996
+ (externalDocSource?.kind === "session"
2997
+ ? externalDocSource.sessionState.sourcePackage?.sourceLabel
2998
+ : undefined) ??
2999
+ (externalDocSource?.kind === "snapshot"
3000
+ ? externalDocSource.snapshot.sourcePackage?.sourceLabel
3001
+ : undefined) ??
1664
3002
  initialSourceLabel ??
3003
+ initialSessionState?.sourcePackage?.sourceLabel ??
3004
+ initialSessionState?.editorBuild ??
1665
3005
  initialSnapshot?.sourcePackage?.sourceLabel ??
1666
3006
  initialSnapshot?.editorBuild ??
1667
3007
  undefined
@@ -1682,6 +3022,7 @@ function createLoadingSnapshot(
1682
3022
  isDirty: false,
1683
3023
  readOnly,
1684
3024
  selection: collapsedSelection(),
3025
+ activeStory: { kind: "main" },
1685
3026
  documentStats: {
1686
3027
  storyLength: 0,
1687
3028
  commentCount: 0,
@@ -1721,6 +3062,16 @@ function createLoadingSnapshot(
1721
3062
  };
1722
3063
  }
1723
3064
 
3065
+ function deriveEditorViewMode(
3066
+ readOnly: boolean,
3067
+ reviewMode: WordReviewEditorProps["reviewMode"] = "review",
3068
+ ): EditorViewMode {
3069
+ if (readOnly) {
3070
+ return "view";
3071
+ }
3072
+ return reviewMode === "editing" ? "editing" : "review";
3073
+ }
3074
+
1724
3075
  function createErrorSnapshot(documentId: string, error: EditorError): RuntimeRenderSnapshot {
1725
3076
  return {
1726
3077
  ...createLoadingSnapshot(documentId, true),
@@ -1739,6 +3090,7 @@ function createErrorSnapshot(documentId: string, error: EditorError): RuntimeRen
1739
3090
  }
1740
3091
 
1741
3092
  async function persistAndExport(input: {
3093
+ hostAdapter?: EditorHostAdapter;
1742
3094
  datastore?: EditorDatastoreAdapter;
1743
3095
  documentId: string;
1744
3096
  runtime: WordReviewEditorRuntime;
@@ -1753,7 +3105,8 @@ async function persistAndExport(input: {
1753
3105
  input.autosaveTimerRef.current = null;
1754
3106
  }
1755
3107
 
1756
- await persistSnapshot({
3108
+ await persistSession({
3109
+ hostAdapter: input.hostAdapter,
1757
3110
  datastore: input.datastore,
1758
3111
  documentId: input.documentId,
1759
3112
  runtime: input.runtime,
@@ -1770,6 +3123,7 @@ async function persistAndExport(input: {
1770
3123
  const normalized = normalizeExportError(error, input.documentId, input.options);
1771
3124
  input.onError?.(normalized);
1772
3125
  emitEditorEvent({
3126
+ hostAdapter: input.hostAdapter,
1773
3127
  datastore: input.datastore,
1774
3128
  onEvent: input.onEvent,
1775
3129
  event: {
@@ -1781,24 +3135,43 @@ async function persistAndExport(input: {
1781
3135
  throw normalized;
1782
3136
  }
1783
3137
 
1784
- if (!input.datastore) {
3138
+ const saveExport = input.hostAdapter?.saveExport ?? input.datastore?.saveExport;
3139
+ const saveExportSource = input.hostAdapter?.saveExport ? "host" : "datastore";
3140
+ if (!saveExport) {
3141
+ result = downloadExportResult(result);
3142
+ emitEditorEvent({
3143
+ hostAdapter: input.hostAdapter,
3144
+ datastore: input.datastore,
3145
+ onEvent: input.onEvent,
3146
+ event: {
3147
+ type: "export_completed",
3148
+ documentId: input.documentId,
3149
+ result,
3150
+ },
3151
+ });
1785
3152
  return result;
1786
3153
  }
1787
3154
 
1788
3155
  try {
1789
- await input.datastore.saveExport({
3156
+ const saveResult = await saveExport({
1790
3157
  documentId: input.documentId,
1791
3158
  result,
1792
3159
  });
3160
+ result = withExportDelivery(result, {
3161
+ mode: "persisted-by-host",
3162
+ savedAt: saveResult.savedAt,
3163
+ });
1793
3164
  } catch (error) {
1794
- const normalized = normalizeDatastoreError(error, {
3165
+ const normalized = normalizeStorageError(error, {
1795
3166
  message: "Export persisted bytes could not be stored.",
3167
+ source: saveExportSource,
1796
3168
  details: {
1797
3169
  operation: "saveExport",
1798
3170
  },
1799
3171
  });
1800
3172
  input.onError?.(normalized);
1801
3173
  emitEditorEvent({
3174
+ hostAdapter: input.hostAdapter,
1802
3175
  datastore: input.datastore,
1803
3176
  onEvent: input.onEvent,
1804
3177
  event: {
@@ -1807,13 +3180,28 @@ async function persistAndExport(input: {
1807
3180
  error: normalized,
1808
3181
  },
1809
3182
  });
3183
+ result = withExportDelivery(result, {
3184
+ mode: "exported-bytes-only",
3185
+ });
1810
3186
  }
1811
3187
 
3188
+ emitEditorEvent({
3189
+ hostAdapter: input.hostAdapter,
3190
+ datastore: input.datastore,
3191
+ onEvent: input.onEvent,
3192
+ event: {
3193
+ type: "export_completed",
3194
+ documentId: input.documentId,
3195
+ result,
3196
+ },
3197
+ });
3198
+
1812
3199
  return result;
1813
3200
  }
1814
3201
 
1815
3202
  function rejectExportWhileLoading(input: {
1816
3203
  documentId: string;
3204
+ hostAdapter?: EditorHostAdapter;
1817
3205
  datastore?: EditorDatastoreAdapter;
1818
3206
  onError?: (error: EditorError) => void;
1819
3207
  onEvent?: (event: WordReviewEditorEvent) => void;
@@ -1827,6 +3215,7 @@ function rejectExportWhileLoading(input: {
1827
3215
  };
1828
3216
  input.onError?.(error);
1829
3217
  emitEditorEvent({
3218
+ hostAdapter: input.hostAdapter,
1830
3219
  datastore: input.datastore,
1831
3220
  onEvent: input.onEvent,
1832
3221
  event: {
@@ -1838,7 +3227,8 @@ function rejectExportWhileLoading(input: {
1838
3227
  return Promise.reject(error);
1839
3228
  }
1840
3229
 
1841
- async function persistSnapshot(input: {
3230
+ async function persistSession(input: {
3231
+ hostAdapter?: EditorHostAdapter;
1842
3232
  datastore?: EditorDatastoreAdapter;
1843
3233
  documentId: string;
1844
3234
  runtime: WordReviewEditorRuntime;
@@ -1847,15 +3237,19 @@ async function persistSnapshot(input: {
1847
3237
  onEvent?: (event: WordReviewEditorEvent) => void;
1848
3238
  lastSavedRevisionTokenRef: React.MutableRefObject<string | null>;
1849
3239
  }): Promise<void> {
1850
- if (!input.datastore) {
3240
+ const saveSession = input.hostAdapter?.saveSession;
3241
+ const saveSnapshot = input.datastore?.saveSnapshot;
3242
+ if (!saveSession && !saveSnapshot) {
1851
3243
  return;
1852
3244
  }
1853
3245
 
3246
+ const sessionState = input.runtime.getSessionState();
1854
3247
  const snapshot = input.runtime.getPersistedSnapshot();
1855
3248
  const revisionToken = input.runtime.getRenderSnapshot().revisionToken;
1856
3249
 
1857
3250
  if (input.isAutosave) {
1858
3251
  emitEditorEvent({
3252
+ hostAdapter: input.hostAdapter,
1859
3253
  datastore: input.datastore,
1860
3254
  onEvent: input.onEvent,
1861
3255
  event: {
@@ -1869,28 +3263,43 @@ async function persistSnapshot(input: {
1869
3263
  }
1870
3264
 
1871
3265
  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
- };
3266
+ const result = saveSession
3267
+ ? await saveSession({
3268
+ documentId: input.documentId,
3269
+ sessionState,
3270
+ isAutosave: input.isAutosave,
3271
+ })
3272
+ : await saveSnapshot!({
3273
+ documentId: input.documentId,
3274
+ snapshot,
3275
+ isAutosave: input.isAutosave,
3276
+ });
1881
3277
  input.lastSavedRevisionTokenRef.current = revisionToken;
1882
3278
  emitEditorEvent({
3279
+ hostAdapter: input.hostAdapter,
1883
3280
  datastore: input.datastore,
1884
3281
  onEvent: input.onEvent,
1885
- event: {
1886
- type: "snapshot_saved",
1887
- documentId: input.documentId,
1888
- snapshot: savedSnapshot,
1889
- isAutosave: input.isAutosave,
1890
- },
3282
+ event: saveSession
3283
+ ? {
3284
+ type: "session_saved",
3285
+ documentId: input.documentId,
3286
+ sessionState: input.runtime.getSessionState(),
3287
+ savedAt: result.savedAt,
3288
+ isAutosave: input.isAutosave,
3289
+ }
3290
+ : {
3291
+ type: "snapshot_saved",
3292
+ documentId: input.documentId,
3293
+ snapshot: {
3294
+ ...snapshot,
3295
+ savedAt: result.savedAt,
3296
+ },
3297
+ isAutosave: input.isAutosave,
3298
+ },
1891
3299
  });
1892
3300
  if (input.isAutosave) {
1893
3301
  emitEditorEvent({
3302
+ hostAdapter: input.hostAdapter,
1894
3303
  datastore: input.datastore,
1895
3304
  onEvent: input.onEvent,
1896
3305
  event: {
@@ -1904,17 +3313,23 @@ async function persistSnapshot(input: {
1904
3313
  });
1905
3314
  }
1906
3315
  } catch (error) {
1907
- const normalized = normalizeDatastoreError(error, {
3316
+ const normalized = normalizeStorageError(error, {
1908
3317
  message: input.isAutosave
1909
- ? "Autosave failed while storing the editor snapshot."
1910
- : "Snapshot save failed while preparing the export checkpoint.",
3318
+ ? saveSession
3319
+ ? "Autosave failed while storing the editor session."
3320
+ : "Autosave failed while storing the editor snapshot."
3321
+ : saveSession
3322
+ ? "Session save failed while preparing the export checkpoint."
3323
+ : "Snapshot save failed while preparing the export checkpoint.",
3324
+ source: saveSession ? "host" : "datastore",
1911
3325
  details: {
1912
- operation: "saveSnapshot",
3326
+ operation: saveSession ? "saveSession" : "saveSnapshot",
1913
3327
  isAutosave: input.isAutosave,
1914
3328
  },
1915
3329
  });
1916
3330
  input.onError?.(normalized);
1917
3331
  emitEditorEvent({
3332
+ hostAdapter: input.hostAdapter,
1918
3333
  datastore: input.datastore,
1919
3334
  onEvent: input.onEvent,
1920
3335
  event: {
@@ -1925,6 +3340,7 @@ async function persistSnapshot(input: {
1925
3340
  });
1926
3341
  if (input.isAutosave) {
1927
3342
  emitEditorEvent({
3343
+ hostAdapter: input.hostAdapter,
1928
3344
  datastore: input.datastore,
1929
3345
  onEvent: input.onEvent,
1930
3346
  event: {
@@ -1944,12 +3360,14 @@ async function persistSnapshot(input: {
1944
3360
  }
1945
3361
 
1946
3362
  function emitEditorEvent(input: {
3363
+ hostAdapter?: EditorHostAdapter;
1947
3364
  datastore?: EditorDatastoreAdapter;
1948
3365
  onEvent?: (event: WordReviewEditorEvent) => void;
1949
3366
  event: WordReviewEditorEvent;
1950
3367
  }): void {
1951
3368
  input.onEvent?.(input.event);
1952
- input.datastore?.logEvent?.({
3369
+ const logEvent = input.hostAdapter?.logEvent ?? input.datastore?.logEvent;
3370
+ logEvent?.({
1953
3371
  type: input.event.type,
1954
3372
  documentId: input.event.documentId,
1955
3373
  detail: summarizeEventDetail(input.event),
@@ -1979,8 +3397,16 @@ function summarizeEventDetail(
1979
3397
  return { status: event.state.status };
1980
3398
  case "snapshot_saved":
1981
3399
  return { isAutosave: event.isAutosave, savedAt: event.snapshot.savedAt };
3400
+ case "session_saved":
3401
+ return { isAutosave: event.isAutosave, savedAt: event.savedAt };
1982
3402
  case "export_completed":
1983
- return { fileName: event.result.fileName };
3403
+ return {
3404
+ fileName: event.result.fileName,
3405
+ deliveryMode: event.result.delivery?.mode,
3406
+ savedAt: event.result.delivery?.savedAt,
3407
+ };
3408
+ case "story_changed":
3409
+ return { activeStory: event.activeStory };
1984
3410
  case "selection_changed":
1985
3411
  return {
1986
3412
  anchor: event.selection.anchor,
@@ -1996,7 +3422,7 @@ function summarizeEventDetail(
1996
3422
 
1997
3423
  function createReadyEvent(
1998
3424
  runtime: Pick<WordReviewEditorRuntime, "getCompatibilityReport" | "getRenderSnapshot">,
1999
- source: "docx" | "snapshot" | "datastore" | "canonical",
3425
+ source: "docx" | "session" | "snapshot",
2000
3426
  ): Extract<WordReviewEditorEvent, { type: "ready" }> {
2001
3427
  const snapshot = runtime.getRenderSnapshot();
2002
3428
  return {
@@ -2011,10 +3437,11 @@ function createReadyEvent(
2011
3437
  };
2012
3438
  }
2013
3439
 
2014
- function normalizeDatastoreError(
3440
+ function normalizeStorageError(
2015
3441
  error: unknown,
2016
3442
  fallback: {
2017
3443
  message: string;
3444
+ source: "host" | "datastore";
2018
3445
  details?: Record<string, unknown>;
2019
3446
  },
2020
3447
  ): EditorError {
@@ -2029,18 +3456,26 @@ function normalizeDatastoreError(
2029
3456
  }
2030
3457
 
2031
3458
  return {
2032
- errorId: "word-review-editor-datastore",
3459
+ errorId:
3460
+ fallback.source === "host"
3461
+ ? "word-review-editor-host"
3462
+ : "word-review-editor-datastore",
2033
3463
  code: "datastore_failed",
2034
3464
  message: error instanceof Error ? error.message : fallback.message,
2035
3465
  isFatal: false,
2036
- source: "datastore",
3466
+ source: fallback.source === "host" ? "host" : "datastore",
2037
3467
  details: fallback.details,
2038
3468
  };
2039
3469
  }
2040
3470
 
2041
3471
  function createFallbackSnapshot(args: CreateRuntimeArgs): RuntimeRenderSnapshot {
2042
- const warnings = args.source.initialSnapshot?.warningLog ?? [];
2043
- const compatibility = args.source.initialSnapshot?.compatibility ?? emptyCompatibilityReport();
3472
+ const initialSessionState =
3473
+ args.source.initialSessionState ??
3474
+ (args.source.initialSnapshot
3475
+ ? editorSessionStateFromPersistedSnapshot(args.source.initialSnapshot)
3476
+ : undefined);
3477
+ const warnings = initialSessionState?.warningLog ?? [];
3478
+ const compatibility = initialSessionState?.compatibility ?? emptyCompatibilityReport();
2044
3479
 
2045
3480
  return {
2046
3481
  ...createLoadingSnapshot(args.documentId, args.readOnly, args.source.sourceLabel),
@@ -2048,7 +3483,7 @@ function createFallbackSnapshot(args: CreateRuntimeArgs): RuntimeRenderSnapshot
2048
3483
  revisionToken: `${args.documentId}:0`,
2049
3484
  isReady: true,
2050
3485
  documentStats: {
2051
- storyLength: estimateStoryLength(args.source.initialSnapshot),
3486
+ storyLength: estimateStoryLength(initialSessionState ?? args.source.initialSnapshot),
2052
3487
  commentCount: 0,
2053
3488
  revisionCount: 0,
2054
3489
  opaqueFragmentCount: 0,
@@ -2131,17 +3566,19 @@ function emptyCompatibilityReport(): CompatibilityReport {
2131
3566
  };
2132
3567
  }
2133
3568
 
2134
- function resolveSnapshotExportSession(args: CreateRuntimeArgs): {
3569
+ function resolvePackageBackedExportSession(args: CreateRuntimeArgs): {
2135
3570
  session?: PackageBackedDocxSession;
2136
3571
  barrier?: SnapshotExportBarrier;
2137
3572
  } {
2138
- const sourcePackage = args.source.initialSnapshot?.sourcePackage;
3573
+ const sourcePackage =
3574
+ args.source.initialSessionState?.sourcePackage ??
3575
+ args.source.initialSnapshot?.sourcePackage;
2139
3576
  if (!sourcePackage) {
2140
3577
  return {
2141
3578
  barrier: {
2142
3579
  reason: "missing_source_package_provenance",
2143
3580
  message:
2144
- "DOCX export is blocked because this snapshot was loaded without embedded source package provenance.",
3581
+ "DOCX export is blocked because this session was loaded without embedded source package provenance.",
2145
3582
  },
2146
3583
  };
2147
3584
  }
@@ -2162,7 +3599,10 @@ function resolveSnapshotExportSession(args: CreateRuntimeArgs): {
2162
3599
  documentId: args.documentId,
2163
3600
  sourceLabel: sourcePackage.sourceLabel ?? args.source.sourceLabel,
2164
3601
  bytes,
2165
- editorBuild: args.source.initialSnapshot?.editorBuild ?? "dev",
3602
+ editorBuild:
3603
+ args.source.initialSessionState?.editorBuild ??
3604
+ args.source.initialSnapshot?.editorBuild ??
3605
+ "dev",
2166
3606
  });
2167
3607
  if (session.readOnly || session.fatalError) {
2168
3608
  return {
@@ -2186,17 +3626,17 @@ function resolveSnapshotExportSession(args: CreateRuntimeArgs): {
2186
3626
  }
2187
3627
  }
2188
3628
 
2189
- function applySnapshotExportBarrier(
2190
- snapshot: PersistedEditorSnapshot,
3629
+ function applySessionExportBarrier(
3630
+ sessionState: EditorSessionState,
2191
3631
  barrier: SnapshotExportBarrier,
2192
- ): PersistedEditorSnapshot {
3632
+ ): EditorSessionState {
2193
3633
  const featureEntryId = `feature:source-package-provenance:${barrier.reason}`;
2194
- const featureEntries = snapshot.compatibility.featureEntries.some(
3634
+ const featureEntries = sessionState.compatibility.featureEntries.some(
2195
3635
  (entry) => entry.featureEntryId === featureEntryId,
2196
3636
  )
2197
- ? snapshot.compatibility.featureEntries
3637
+ ? sessionState.compatibility.featureEntries
2198
3638
  : [
2199
- ...snapshot.compatibility.featureEntries,
3639
+ ...sessionState.compatibility.featureEntries,
2200
3640
  {
2201
3641
  featureEntryId,
2202
3642
  featureKey: "source-package-provenance",
@@ -2209,9 +3649,9 @@ function applySnapshotExportBarrier(
2209
3649
  ];
2210
3650
 
2211
3651
  return {
2212
- ...snapshot,
3652
+ ...sessionState,
2213
3653
  compatibility: {
2214
- ...snapshot.compatibility,
3654
+ ...sessionState.compatibility,
2215
3655
  blockExport: true,
2216
3656
  featureEntries,
2217
3657
  },
@@ -2311,9 +3751,17 @@ function createSelectionFromAnchor(
2311
3751
  }
2312
3752
  }
2313
3753
 
2314
- function estimateStoryLength(snapshot?: PersistedEditorSnapshot): number {
2315
- const content = snapshot?.canonicalDocument.content;
2316
- return Array.isArray(content) ? content.length : 0;
3754
+ function estimateStoryLength(
3755
+ sessionStateOrSnapshot?: EditorSessionState | PersistedEditorSnapshot,
3756
+ ): number {
3757
+ if (!sessionStateOrSnapshot) {
3758
+ return 0;
3759
+ }
3760
+
3761
+ const content = "sessionVersion" in sessionStateOrSnapshot
3762
+ ? sessionStateOrSnapshot.canonicalDocument.content
3763
+ : sessionStateOrSnapshot.canonicalDocument.content;
3764
+ return Array.isArray(content?.children) ? content.children.length : 0;
2317
3765
  }
2318
3766
 
2319
3767
  function collapsedSelection(): RuntimeRenderSnapshot["selection"] {
@@ -2358,3 +3806,145 @@ function summarizeSelectionPreview(snapshot: RuntimeRenderSnapshot): string | nu
2358
3806
 
2359
3807
  return preview.length > 48 ? `${preview.slice(0, 45)}...` : preview;
2360
3808
  }
3809
+
3810
+ function selectionToolbarAnchorsEqual(
3811
+ left: SelectionToolbarAnchor | null,
3812
+ right: SelectionToolbarAnchor | null,
3813
+ ): boolean {
3814
+ if (left === right) {
3815
+ return true;
3816
+ }
3817
+ if (!left || !right) {
3818
+ return false;
3819
+ }
3820
+ return (
3821
+ left.left === right.left &&
3822
+ left.right === right.right &&
3823
+ left.top === right.top &&
3824
+ left.bottom === right.bottom
3825
+ );
3826
+ }
3827
+
3828
+ function createSelectionToolbarSelectionKey(
3829
+ selection: RuntimeRenderSnapshot["selection"],
3830
+ activeStory: EditorStoryTarget,
3831
+ ): string | null {
3832
+ if (selection.isCollapsed || selection.activeRange.kind !== "range") {
3833
+ return null;
3834
+ }
3835
+
3836
+ return JSON.stringify({
3837
+ story: activeStory,
3838
+ from: selection.activeRange.from,
3839
+ to: selection.activeRange.to,
3840
+ });
3841
+ }
3842
+
3843
+ function buildSelectionToolbarModel(args: {
3844
+ snapshot: RuntimeRenderSnapshot;
3845
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>;
3846
+ capabilities: ReturnType<typeof deriveCapabilities>;
3847
+ documentNavigation: ReturnType<WordReviewEditorRuntime["getDocumentNavigationSnapshot"]>;
3848
+ styleCatalog: StyleCatalogSnapshot;
3849
+ formattingState: FormattingStateSnapshot;
3850
+ addCommentDisabledReason?: string;
3851
+ }): SelectionToolbarModel | null {
3852
+ const {
3853
+ snapshot,
3854
+ viewState,
3855
+ capabilities,
3856
+ documentNavigation,
3857
+ styleCatalog,
3858
+ formattingState,
3859
+ addCommentDisabledReason,
3860
+ } = args;
3861
+
3862
+ if (
3863
+ !snapshot.surface ||
3864
+ snapshot.selection.isCollapsed ||
3865
+ snapshot.selection.activeRange.kind !== "range" ||
3866
+ !capabilities.canEdit ||
3867
+ viewState.viewMode === "view"
3868
+ ) {
3869
+ return null;
3870
+ }
3871
+
3872
+ const previewText = summarizeSelectionPreview(snapshot);
3873
+ if (!previewText) {
3874
+ return null;
3875
+ }
3876
+
3877
+ const badges = [
3878
+ createSelectionToolbarStoryBadge(viewState.activeStory),
3879
+ viewState.workspaceMode === "page" && documentNavigation.pageCount > 0
3880
+ ? { label: `Page ${documentNavigation.activePageIndex + 1}` as const }
3881
+ : null,
3882
+ createSelectionToolbarStyleBadge(styleCatalog, formattingState),
3883
+ createSelectionToolbarListBadge(viewState),
3884
+ ].filter((badge): badge is SelectionToolbarModel["badges"][number] => Boolean(badge));
3885
+
3886
+ return {
3887
+ previewText,
3888
+ badges,
3889
+ canToggleFormatting: true,
3890
+ boldActive: formattingState.bold,
3891
+ italicActive: formattingState.italic,
3892
+ underlineActive: formattingState.underline,
3893
+ canAddComment: capabilities.canAddComment,
3894
+ ...(addCommentDisabledReason ? { disabledReason: addCommentDisabledReason } : {}),
3895
+ };
3896
+ }
3897
+
3898
+ function createSelectionToolbarStoryBadge(
3899
+ target: EditorStoryTarget,
3900
+ ): SelectionToolbarModel["badges"][number] | null {
3901
+ if (target.kind === "main") {
3902
+ return null;
3903
+ }
3904
+
3905
+ return {
3906
+ label:
3907
+ target.kind === "header"
3908
+ ? target.variant === "default"
3909
+ ? "Header"
3910
+ : `Header ${target.variant}`
3911
+ : target.kind === "footer"
3912
+ ? target.variant === "default"
3913
+ ? "Footer"
3914
+ : `Footer ${target.variant}`
3915
+ : target.kind === "footnote"
3916
+ ? "Footnote"
3917
+ : "Endnote",
3918
+ tone: "accent",
3919
+ };
3920
+ }
3921
+
3922
+ function createSelectionToolbarStyleBadge(
3923
+ styleCatalog: StyleCatalogSnapshot,
3924
+ formattingState: FormattingStateSnapshot,
3925
+ ): SelectionToolbarModel["badges"][number] | null {
3926
+ if (!formattingState.paragraphStyleId) {
3927
+ return null;
3928
+ }
3929
+
3930
+ const styleEntry = styleCatalog.paragraphs.find(
3931
+ (entry) => entry.styleId === formattingState.paragraphStyleId,
3932
+ );
3933
+ if (!styleEntry || styleEntry.isDefault) {
3934
+ return null;
3935
+ }
3936
+
3937
+ return { label: styleEntry.displayName };
3938
+ }
3939
+
3940
+ function createSelectionToolbarListBadge(
3941
+ viewState: ReturnType<WordReviewEditorRuntime["getViewState"]>,
3942
+ ): SelectionToolbarModel["badges"][number] | null {
3943
+ if (!viewState.activeListContext) {
3944
+ return null;
3945
+ }
3946
+
3947
+ return {
3948
+ label: viewState.activeListContext.isOrdered ? "Numbered list" : "Bulleted list",
3949
+ };
3950
+ }