@beyondwork/docx-react-component 1.0.56 → 1.0.58

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 (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -12,6 +12,11 @@ import {
12
12
  type EditorState,
13
13
  type EditorWarning as InternalEditorWarning,
14
14
  } from "../core/state/editor-state.ts";
15
+ import {
16
+ logicalPositionToUnitIndex,
17
+ parseTextStory,
18
+ serializeTextStory,
19
+ } from "../core/schema/text-schema.ts";
15
20
  import type {
16
21
  AddCommentParams,
17
22
  AddCommentReplyResult,
@@ -61,6 +66,7 @@ import type {
61
66
  SurfaceInlineSegment,
62
67
  StoryTextStreamSnapshot,
63
68
  TextCommandAck,
69
+ TocRefreshTrigger,
64
70
  TocSnapshot,
65
71
  StyleCatalogSnapshot,
66
72
  TocRefreshOptions,
@@ -80,6 +86,8 @@ import type {
80
86
  WorkflowOverlay,
81
87
  WorkflowScope,
82
88
  WorkflowScopeSnapshot,
89
+ ScopeQueryFilter,
90
+ ScopeQueryResult,
83
91
  WorkspaceMode,
84
92
  WordReviewEditorEvent,
85
93
  ZoomLevel,
@@ -115,6 +123,8 @@ import {
115
123
  snapCommentAnchorAwayFromTable,
116
124
  } from "../core/selection/review-anchors.ts";
117
125
  import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
126
+ import { createFieldResolver, type FieldResolver } from "./field-resolver.ts";
127
+ import { createFootnoteResolver, type FootnoteResolver } from "./footnote-resolver.ts";
118
128
  import {
119
129
  describeOpaqueFragment,
120
130
  findOpaqueFragmentsIntersectingRange,
@@ -128,7 +138,16 @@ import {
128
138
  } from "../review/store/revision-store.ts";
129
139
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
130
140
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
131
- import { resolveScope } from "./scope-resolver.ts";
141
+ import {
142
+ collectScopeLocations,
143
+ findAllScopesAt,
144
+ findScopesIntersecting,
145
+ resolveScope,
146
+ } from "./scope-resolver.ts";
147
+ import {
148
+ projectScopeQueryResults,
149
+ queryScopes as runQueryScopes,
150
+ } from "./query-scopes.ts";
132
151
  import {
133
152
  insertScopeMarkers,
134
153
  removeScopeMarkers,
@@ -205,13 +224,16 @@ import {
205
224
  createEditorViewStateSnapshot,
206
225
  type ViewState,
207
226
  } from "./view-state.ts";
227
+ import { ThemeColorResolver } from "./theme-color-resolver.ts";
208
228
  import type {
209
229
  BlockNode,
230
+ CanonicalDocument,
210
231
  FieldNode,
211
232
  FieldRefreshStatus,
212
233
  InlineNode,
213
234
  PageMargins,
214
235
  ParagraphNode,
236
+ SectionProperties,
215
237
  SubPartsCatalog,
216
238
  } from "../model/canonical-document.ts";
217
239
  import {
@@ -240,6 +262,14 @@ import type {
240
262
  EditorStatePersister,
241
263
  } from "../api/editor-state-types.ts";
242
264
  import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
265
+ import { serializeFragmentToWordML } from "../io/paste/word-clipboard.ts";
266
+ import {
267
+ createObjectGrabState,
268
+ deselectObject as grabDeselectObject,
269
+ getGrabbedObject as grabGetGrabbedObject,
270
+ selectObject as grabSelectObject,
271
+ type ObjectGrabState,
272
+ } from "./object-grab/index.ts";
243
273
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
244
274
  import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
245
275
  import { formatPageNumber } from "./page-number-format.ts";
@@ -277,8 +307,77 @@ export interface DocumentRuntime {
277
307
  getRenderSnapshot(): RuntimeRenderSnapshot;
278
308
  getCanonicalDocument(): CanonicalDocumentEnvelope;
279
309
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
310
+ /** Return the parsed fontTable, if present in the loaded package. */
311
+ getFontTable(): CanonicalDocumentEnvelope["fontTable"];
312
+ /**
313
+ * Convenience accessor — return the `CanonicalFontEntry` for `name`, or
314
+ * undefined when the loaded package has no fontTable or no matching entry.
315
+ */
316
+ getFontEntry(
317
+ name: string,
318
+ ): NonNullable<CanonicalDocumentEnvelope["fontTable"]>["fonts"][string] | undefined;
280
319
  replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
281
320
  insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
321
+ /**
322
+ * I2 Tier B Slice 4b — serialize the selection range to a
323
+ * `CanonicalDocumentFragment` and store it in an internal clipboard buffer.
324
+ * No document mutation. `target` defaults to the current selection.
325
+ */
326
+ copy(target?: EditorAnchorProjection): void;
327
+ /**
328
+ * I2 Tier B Slice 4b — `copy(target)` + delete the range. Safe on empty /
329
+ * collapsed ranges (no-op).
330
+ */
331
+ cut(target?: EditorAnchorProjection): void;
332
+ /**
333
+ * I2 Tier B Slice 4b — return the last fragment stored via `cut` / `copy`,
334
+ * or `null` when no clipboard operation has been performed yet. Mirrors
335
+ * what a browser `clipboardData.getData("web application/x-clip")` would
336
+ * return in a system-clipboard-aware build; for now hosts can use this to
337
+ * feed `insertFragment` directly.
338
+ */
339
+ getClipboardBuffer(): CanonicalDocumentFragment | null;
340
+ /**
341
+ * v5 close-out — return the current clipboard buffer serialized to the
342
+ * wire formats browsers/Word accept, or `null` when no clipboard op has
343
+ * been performed. Hosts pair this with `navigator.clipboard.write` inside
344
+ * their own DOM `copy`/`cut` event handler; the editor does not install
345
+ * the DOM handler itself because hosts often route cut/copy through their
346
+ * own protocol. Three formats: WordML (`application/x-docx-fragment`),
347
+ * HTML (`text/html`), and plain text (`text/plain`).
348
+ */
349
+ getClipboardWireFormats(): { wordml: string; html: string; plainText: string } | null;
350
+ /**
351
+ * R.3 ObjectGrabLayer — grab an inline / floating object (image, shape)
352
+ * by its stable id. Single-select model. Local-only state; not broadcast
353
+ * through collab. Lane 6 P11 paints the chrome handles.
354
+ */
355
+ selectObject(objectId: string): void;
356
+ /**
357
+ * R.3 — release any grabbed object. Safe to call when nothing is grabbed.
358
+ */
359
+ deselectObject(): void;
360
+ /**
361
+ * R.3 — return the currently grabbed object id, or `null` when no
362
+ * object is grabbed.
363
+ */
364
+ getGrabbedObject(): string | null;
365
+ /**
366
+ * R.5.a — open an action bracket. Hosts use this to group compound edits
367
+ * (paste → insertFragment, cut → copy+delete, agent suggestion-apply) so
368
+ * snapshot emission + collab broadcast + undo grouping see them as one
369
+ * action. Nested brackets are tracked by depth; only the outermost
370
+ * `endAction` completes the bracket.
371
+ *
372
+ * Phase 1 (Item E): this API ships opt-in. Commands don't auto-bracket
373
+ * themselves yet — hosts that want single-undo paste must call
374
+ * `startAction` / `endAction` around their `insertFragment` call.
375
+ */
376
+ startAction(name: string): void;
377
+ /** R.5.a — close one level of action bracketing. Unbalanced calls are no-ops. */
378
+ endAction(): void;
379
+ /** R.5.a — `true` when the runtime is inside one or more action brackets. */
380
+ isInAction(): boolean;
282
381
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
283
382
  dispatch(command: EditorCommand): void;
284
383
  /**
@@ -382,6 +481,20 @@ export interface DocumentRuntime {
382
481
  }): DocumentSectionSnapshot | null;
383
482
  describeEventImpact(event: WordReviewEditorEvent): SnapshotRefreshHints;
384
483
  getFieldSnapshot(): FieldSnapshot;
484
+ /**
485
+ * CO3.5 — Field resolver exposing `resolve(entry)` for PAGE / NUMPAGES /
486
+ * PAGEREF / REF / STYLEREF. TOC entries return `undefined`. Reads
487
+ * `layoutEngine.getPageGraph()` + the active page index + the bookmark
488
+ * name map + paragraph start-offsets, so it updates naturally with the
489
+ * current document state.
490
+ */
491
+ getFieldResolver(): FieldResolver;
492
+ /**
493
+ * CO3.5 — Footnote / endnote resolver. Returns `undefined` when the
494
+ * document has no footnote collection attached (no `footnotes.xml`
495
+ * part).
496
+ */
497
+ getFootnoteResolver(): FootnoteResolver | undefined;
385
498
  updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
386
499
  updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
387
500
  getSessionState(): EditorSessionState;
@@ -402,6 +515,27 @@ export interface DocumentRuntime {
402
515
  setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
403
516
  clearWorkflowMetadataEntries(): void;
404
517
  getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
518
+ /**
519
+ * Phase C §C1 — snapshot-based filter + join projection. See
520
+ * `WordReviewEditorRef.queryScopes` for contract.
521
+ */
522
+ queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
523
+ /**
524
+ * Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
525
+ * for contract. Non-range anchors yield `[]`.
526
+ */
527
+ findScopesAt(
528
+ position: EditorAnchorProjection,
529
+ options?: { includeHidden?: boolean; includeInvisible?: boolean },
530
+ ): ScopeQueryResult[];
531
+ findScopesIntersecting(
532
+ range: EditorAnchorProjection,
533
+ options?: {
534
+ includeHidden?: boolean;
535
+ includeInvisible?: boolean;
536
+ mode?: "overlap" | "contain";
537
+ },
538
+ ): ScopeQueryResult[];
405
539
  setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
406
540
  clearHostAnnotationOverlay(): void;
407
541
  getHostAnnotationSnapshot(): HostAnnotationSnapshot;
@@ -638,6 +772,12 @@ export function createDocumentRuntime(
638
772
  // checks this flag) runs during construction.
639
773
  let analyticsEmitScheduled = false;
640
774
 
775
+ // V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
776
+ // a mismatch schedules a microtask refresh of TOC fields.
777
+ let lastHeadingFingerprint: string = "";
778
+ let tocAutoRefreshScheduled = false;
779
+ let pendingTocTrigger: TocRefreshTrigger | null = null;
780
+
641
781
  // Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
642
782
  // Instantiated once per runtime; forwarded to the public interface.
643
783
  const editorStateChannel = createEditorStateChannel();
@@ -693,7 +833,21 @@ export function createDocumentRuntime(
693
833
  canonicalDocument: options.initialCanonicalDocument,
694
834
  fatalError: options.fatalError as never,
695
835
  });
836
+ // I2 Tier B Slice 4b — internal clipboard buffer for cut/copy. System-
837
+ // clipboard write lands with Slice 5 drag; for now hosts read this via
838
+ // `getClipboardBuffer()` and feed it to `insertFragment`.
839
+ let clipboardBuffer: CanonicalDocumentFragment | null = null;
840
+ // R.3 ObjectGrabLayer — local-only grab state for inline / floating objects.
841
+ // Not broadcast through collab; each peer has their own (mirrors text selection).
842
+ let grabState: ObjectGrabState = createObjectGrabState();
843
+ // R.5.a action bracketing — depth counter. `startAction` increments,
844
+ // `endAction` decrements (clamped at 0). `isInAction` returns `depth > 0`.
845
+ // Phase 2 (follow-up) will use this to gate snapshot emission so nested
846
+ // commands observe a single boundary.
847
+ let actionDepth = 0;
848
+ const actionStack: string[] = [];
696
849
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
850
+ lastHeadingFingerprint = computeHeadingFingerprint(state.document);
697
851
 
698
852
  // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
699
853
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
@@ -736,17 +890,18 @@ export function createDocumentRuntime(
736
890
  canonicalDocument: () => state.document,
737
891
  renderKernel: () => renderKernelRef,
738
892
  getWorkflowRailInput: () => {
739
- if (!workflowOverlay) return null;
740
- const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
893
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
894
+ if (!normalizedWorkflowOverlay) return null;
895
+ const activeWorkItemId = normalizedWorkflowOverlay.activeWorkItemId ?? null;
741
896
  const activeWorkItem =
742
897
  activeWorkItemId !== null
743
- ? workflowOverlay.workItems?.find(
898
+ ? normalizedWorkflowOverlay.workItems?.find(
744
899
  (item) => item.workItemId === activeWorkItemId,
745
900
  )
746
901
  : undefined;
747
902
  return {
748
- scopes: workflowOverlay.scopes,
749
- candidates: workflowOverlay.candidates,
903
+ scopes: normalizedWorkflowOverlay.scopes,
904
+ candidates: normalizedWorkflowOverlay.candidates,
750
905
  activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
751
906
  activeStory,
752
907
  };
@@ -933,6 +1088,13 @@ export function createDocumentRuntime(
933
1088
  snapshot: WorkflowScopeSnapshot;
934
1089
  }
935
1090
  | undefined;
1091
+ let cachedNormalizedWorkflowOverlay:
1092
+ | {
1093
+ document: CanonicalDocumentEnvelope;
1094
+ workflowOverlay: WorkflowOverlay;
1095
+ normalized: WorkflowOverlay;
1096
+ }
1097
+ | undefined;
936
1098
  let cachedWorkflowMarkupSnapshot:
937
1099
  | {
938
1100
  revisionToken: string;
@@ -1258,10 +1420,12 @@ export function createDocumentRuntime(
1258
1420
  }
1259
1421
  }
1260
1422
 
1261
- if (workflowOverlay) {
1423
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1424
+ if (normalizedWorkflowOverlay) {
1262
1425
  const matchingScope = getMatchingWorkflowScope(selection);
1426
+ const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
1263
1427
 
1264
- if (!matchingScope && workflowOverlay.scopes.length > 0) {
1428
+ if (!matchingScope && activeScopes.length > 0) {
1265
1429
  reasons.push({
1266
1430
  code: "outside_workflow_scope",
1267
1431
  message: "Selection is outside any active workflow scope.",
@@ -1557,20 +1721,114 @@ export function createDocumentRuntime(
1557
1721
  return left.from < right.to && right.from < left.to;
1558
1722
  }
1559
1723
 
1560
- function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1724
+ function workflowAnchorsEqual(
1725
+ left: EditorAnchorProjection,
1726
+ right: EditorAnchorProjection,
1727
+ ): boolean {
1728
+ if (left.kind !== right.kind) return false;
1729
+ switch (left.kind) {
1730
+ case "range":
1731
+ return (
1732
+ right.kind === "range" &&
1733
+ left.from === right.from &&
1734
+ left.to === right.to &&
1735
+ left.assoc.start === right.assoc.start &&
1736
+ left.assoc.end === right.assoc.end
1737
+ );
1738
+ case "node":
1739
+ return right.kind === "node" && left.at === right.at;
1740
+ case "detached":
1741
+ return (
1742
+ right.kind === "detached" &&
1743
+ left.reason === right.reason &&
1744
+ left.lastKnownRange.from === right.lastKnownRange.from &&
1745
+ left.lastKnownRange.to === right.lastKnownRange.to
1746
+ );
1747
+ default:
1748
+ return false;
1749
+ }
1750
+ }
1751
+
1752
+ function normalizeWorkflowOverlayForDocument(
1753
+ document: CanonicalDocumentEnvelope,
1754
+ overlay: WorkflowOverlay,
1755
+ ): WorkflowOverlay {
1756
+ if (
1757
+ cachedNormalizedWorkflowOverlay &&
1758
+ cachedNormalizedWorkflowOverlay.document === document &&
1759
+ cachedNormalizedWorkflowOverlay.workflowOverlay === overlay
1760
+ ) {
1761
+ return cachedNormalizedWorkflowOverlay.normalized;
1762
+ }
1763
+
1764
+ const scopeIdCounts = new Map<string, number>();
1765
+ for (const scope of overlay.scopes) {
1766
+ scopeIdCounts.set(scope.scopeId, (scopeIdCounts.get(scope.scopeId) ?? 0) + 1);
1767
+ }
1768
+ const locations = collectScopeLocations(document);
1769
+ let changed = false;
1770
+ const normalizedScopes = overlay.scopes.map((scope) => {
1771
+ if ((scopeIdCounts.get(scope.scopeId) ?? 0) !== 1) {
1772
+ return scope;
1773
+ }
1774
+ const location = locations.get(scope.scopeId);
1775
+ if (
1776
+ !location ||
1777
+ location.startPos === undefined ||
1778
+ location.endPos === undefined
1779
+ ) {
1780
+ return scope;
1781
+ }
1782
+ const nextAnchor: EditorAnchorProjection = {
1783
+ kind: "range",
1784
+ from: Math.min(location.startPos, location.endPos),
1785
+ to: Math.max(location.startPos, location.endPos),
1786
+ assoc: { start: -1, end: 1 },
1787
+ };
1788
+ if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
1789
+ return scope;
1790
+ }
1791
+ changed = true;
1792
+ return {
1793
+ ...scope,
1794
+ anchor: nextAnchor,
1795
+ };
1796
+ });
1797
+
1798
+ const normalized = changed
1799
+ ? {
1800
+ ...overlay,
1801
+ scopes: normalizedScopes,
1802
+ }
1803
+ : overlay;
1804
+ cachedNormalizedWorkflowOverlay = {
1805
+ document,
1806
+ workflowOverlay: overlay,
1807
+ normalized,
1808
+ };
1809
+ return normalized;
1810
+ }
1811
+
1812
+ function getNormalizedWorkflowOverlay(): WorkflowOverlay | null {
1561
1813
  if (!workflowOverlay) return null;
1814
+ return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
1815
+ }
1816
+
1817
+ function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1818
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1819
+ if (!normalizedWorkflowOverlay) return null;
1562
1820
  const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
1563
- const activeItem = workflowOverlay.activeWorkItemId
1564
- ? workflowOverlay.workItems?.find(
1565
- (item) => item.workItemId === workflowOverlay!.activeWorkItemId,
1821
+ const activeItem = normalizedWorkflowOverlay.activeWorkItemId
1822
+ ? normalizedWorkflowOverlay.workItems?.find(
1823
+ (item) => item.workItemId === normalizedWorkflowOverlay.activeWorkItemId,
1566
1824
  )
1567
1825
  : undefined;
1568
1826
  return {
1569
1827
  overlayPresent: true,
1570
- activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
1828
+ activeWorkItemId: normalizedWorkflowOverlay.activeWorkItemId ?? null,
1571
1829
  activeWorkItem: activeItem,
1572
- scopes: workflowOverlay.scopes,
1573
- candidates: workflowOverlay.candidates ?? [],
1830
+ scopes: normalizedWorkflowOverlay.scopes,
1831
+ candidates: normalizedWorkflowOverlay.candidates ?? [],
1574
1832
  blockedReasons,
1575
1833
  };
1576
1834
  }
@@ -1590,15 +1848,16 @@ export function createDocumentRuntime(
1590
1848
  }
1591
1849
 
1592
1850
  function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
1593
- const activeWorkItemId = overlay.activeWorkItemId ?? null;
1851
+ const normalizedOverlay = normalizeWorkflowOverlayForDocument(state.document, overlay);
1852
+ const activeWorkItemId = normalizedOverlay.activeWorkItemId ?? null;
1594
1853
  const activeWorkItemScopeIds =
1595
1854
  activeWorkItemId === null
1596
1855
  ? null
1597
1856
  : new Set(
1598
- overlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
1857
+ normalizedOverlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
1599
1858
  );
1600
1859
 
1601
- return overlay.scopes.filter((scope) => {
1860
+ return normalizedOverlay.scopes.filter((scope) => {
1602
1861
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
1603
1862
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) {
1604
1863
  return false;
@@ -1844,12 +2103,13 @@ export function createDocumentRuntime(
1844
2103
  const tCompat = performance.now();
1845
2104
  const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
1846
2105
  perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
2106
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1847
2107
 
1848
2108
  const tBuild = performance.now();
1849
2109
  const snapshot = createRuntimeContextAnalyticsSnapshot({
1850
2110
  query,
1851
2111
  renderSnapshot: cachedRenderSnapshot,
1852
- workflowOverlay,
2112
+ workflowOverlay: normalizedWorkflowOverlay,
1853
2113
  workflowScopeSnapshot: wfScope,
1854
2114
  interactionGuardSnapshot: wfGuard,
1855
2115
  workflowMarkupSnapshot: wfMarkup,
@@ -1987,6 +2247,7 @@ export function createDocumentRuntime(
1987
2247
  },
1988
2248
  surface,
1989
2249
  protectionSnapshot,
2250
+ grabbedObjectId: grabState.objectId,
1990
2251
  };
1991
2252
  }
1992
2253
 
@@ -2022,6 +2283,47 @@ export function createDocumentRuntime(
2022
2283
  }
2023
2284
  }
2024
2285
 
2286
+ function invalidateDerivedRuntimeCaches(): void {
2287
+ cachedSurface = undefined;
2288
+ cachedCompatibility = undefined;
2289
+ cachedComments = undefined;
2290
+ cachedTrackedChanges = undefined;
2291
+ cachedSuggestions = undefined;
2292
+ cachedReviewWork = undefined;
2293
+ cachedPageLayout = undefined;
2294
+ cachedNavigation = undefined;
2295
+ cachedViewStateSnapshot = undefined;
2296
+ cachedInteractionGuardSnapshot = undefined;
2297
+ cachedWorkflowScopeSnapshot = undefined;
2298
+ cachedNormalizedWorkflowOverlay = undefined;
2299
+ cachedWorkflowMarkupSnapshot = undefined;
2300
+ cachedContextAnalyticsSnapshots.clear();
2301
+ lastEmittedContextAnalyticsSnapshots = undefined;
2302
+ }
2303
+
2304
+ function hydrateCanonicalDocumentInternally(
2305
+ document: CanonicalDocumentEnvelope,
2306
+ ): boolean {
2307
+ if (document === state.document) {
2308
+ return false;
2309
+ }
2310
+ const previousDocument = state.document;
2311
+ state = {
2312
+ ...state,
2313
+ document,
2314
+ };
2315
+ if (previousDocument.subParts !== document.subParts) {
2316
+ fontLoader.refresh(collectFontLoaderInput(document));
2317
+ layoutEngine.invalidateMeasurementCache();
2318
+ }
2319
+ invalidateDerivedRuntimeCaches();
2320
+ cachedRenderSnapshot = refreshRenderSnapshot();
2321
+ for (const listener of listeners) {
2322
+ listener();
2323
+ }
2324
+ return true;
2325
+ }
2326
+
2025
2327
  function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
2026
2328
  const activeStoryKey = storyTargetKey(activeStory);
2027
2329
  const pageLayout = cachedRenderSnapshot.pageLayout;
@@ -2131,7 +2433,11 @@ export function createDocumentRuntime(
2131
2433
  const r5ScratchReplayState: typeof state = { ...state };
2132
2434
  const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
2133
2435
 
2134
- return {
2436
+ const runtime: DocumentRuntime & {
2437
+ hydrateCanonicalDocumentInternally(
2438
+ document: CanonicalDocumentEnvelope,
2439
+ ): boolean;
2440
+ } = {
2135
2441
  subscribe(listener) {
2136
2442
  listeners.add(listener);
2137
2443
  return () => {
@@ -2153,6 +2459,12 @@ export function createDocumentRuntime(
2153
2459
  getSourcePackage() {
2154
2460
  return state.sourcePackage;
2155
2461
  },
2462
+ getFontTable() {
2463
+ return state.document.fontTable;
2464
+ },
2465
+ getFontEntry(name: string) {
2466
+ return state.document.fontTable?.fonts[name];
2467
+ },
2156
2468
  emitBlockedCommand(command, reasons) {
2157
2469
  emit({
2158
2470
  type: "command_blocked",
@@ -2480,6 +2792,11 @@ export function createDocumentRuntime(
2480
2792
  insertFragment(fragment, target) {
2481
2793
  // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
2482
2794
  // runtime command handler routes into `applyFragmentInsert` (structure-ops).
2795
+ // v5 B1: auto-bracketed via R.5.a so a host that wants single-undo paste
2796
+ // gets one action. Idempotent on nested brackets: if a caller already
2797
+ // opened `startAction`, this bracket just increments / decrements depth.
2798
+ actionDepth += 1;
2799
+ actionStack.push("insertFragment");
2483
2800
  try {
2484
2801
  const timestamp = clock();
2485
2802
  applyTextCommandInActiveStory(
@@ -2495,8 +2812,144 @@ export function createDocumentRuntime(
2495
2812
  );
2496
2813
  } catch (error) {
2497
2814
  emitError(toRuntimeError(error));
2815
+ } finally {
2816
+ actionDepth -= 1;
2817
+ actionStack.pop();
2498
2818
  }
2499
2819
  },
2820
+ copy(target) {
2821
+ // I2 Tier B Slice 4b — serialize the selection range to a fragment, store
2822
+ // it in the internal buffer. Does NOT mutate the document.
2823
+ // v5 A1 fix: story-aware extraction — footnote / header / endnote
2824
+ // selections read the right content tree, not main body.
2825
+ try {
2826
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
2827
+ const fragment = extractSelectionFragment(state.document, selection, activeStory);
2828
+ clipboardBuffer = fragment;
2829
+ } catch (error) {
2830
+ emitError(toRuntimeError(error));
2831
+ }
2832
+ },
2833
+ cut(target) {
2834
+ // I2 Tier B Slice 4b — copy into buffer, then delete the range by
2835
+ // replacing it with empty text. (Empty fragment.insert is a deliberate
2836
+ // no-op in the splicer per Slice 1, so we use the text.insert path with
2837
+ // an empty string to delete the selected content.)
2838
+ // v5 B1: auto-bracketed so a host that wants single-undo paste-at-drop
2839
+ // gets one action even if its own bracket isn't open.
2840
+ try {
2841
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
2842
+ const fragment = extractSelectionFragment(state.document, selection, activeStory);
2843
+ clipboardBuffer = fragment;
2844
+ if (selection.anchor !== selection.head) {
2845
+ actionDepth += 1;
2846
+ actionStack.push("cut");
2847
+ try {
2848
+ const timestamp = clock();
2849
+ applyTextCommandInActiveStory(
2850
+ {
2851
+ type: "text.insert",
2852
+ text: "",
2853
+ origin: createOrigin("api", timestamp),
2854
+ },
2855
+ {
2856
+ selection,
2857
+ blockedCommandName: "cut",
2858
+ },
2859
+ );
2860
+ } finally {
2861
+ actionDepth -= 1;
2862
+ actionStack.pop();
2863
+ }
2864
+ }
2865
+ } catch (error) {
2866
+ emitError(toRuntimeError(error));
2867
+ }
2868
+ },
2869
+ getClipboardBuffer() {
2870
+ return clipboardBuffer;
2871
+ },
2872
+ getClipboardWireFormats() {
2873
+ // v5 close-out — serialize the buffer to WordML + HTML + plain text for
2874
+ // host-owned `navigator.clipboard.write`. Returns null if nothing has
2875
+ // been cut/copied yet. Zero allocation on the null path.
2876
+ if (!clipboardBuffer || clipboardBuffer.blocks.length === 0) return null;
2877
+ const wordml = serializeFragmentToWordML(clipboardBuffer);
2878
+ // Minimal HTML: walk paragraph children emitting <p> wrappers with
2879
+ // text content. Keeps parity with our own parser (round-trips
2880
+ // cleanly) without implementing full CSS export.
2881
+ const htmlParts: string[] = [];
2882
+ const plainParts: string[] = [];
2883
+ for (const block of clipboardBuffer.blocks) {
2884
+ if (block.type !== "paragraph") continue;
2885
+ const runs: string[] = [];
2886
+ const plainRuns: string[] = [];
2887
+ for (const child of block.children) {
2888
+ if (child.type === "text") {
2889
+ const text = (child.text ?? "")
2890
+ .replace(/&/g, "&amp;")
2891
+ .replace(/</g, "&lt;")
2892
+ .replace(/>/g, "&gt;");
2893
+ let wrapped = text;
2894
+ const marks = child.marks ?? [];
2895
+ if (marks.some((m) => m.type === "bold")) wrapped = `<b>${wrapped}</b>`;
2896
+ if (marks.some((m) => m.type === "italic")) wrapped = `<i>${wrapped}</i>`;
2897
+ if (marks.some((m) => m.type === "underline")) wrapped = `<u>${wrapped}</u>`;
2898
+ if (marks.some((m) => m.type === "strikethrough")) wrapped = `<s>${wrapped}</s>`;
2899
+ runs.push(wrapped);
2900
+ plainRuns.push(child.text ?? "");
2901
+ } else if (child.type === "hard_break") {
2902
+ runs.push("<br/>");
2903
+ plainRuns.push("\n");
2904
+ }
2905
+ }
2906
+ htmlParts.push(`<p>${runs.join("")}</p>`);
2907
+ plainParts.push(plainRuns.join(""));
2908
+ }
2909
+ return {
2910
+ wordml,
2911
+ html: htmlParts.join(""),
2912
+ plainText: plainParts.join("\n"),
2913
+ };
2914
+ },
2915
+ selectObject(objectId) {
2916
+ // R.3 — local grab state mutation. No command dispatch; this is pure UI
2917
+ // state that chrome (Lane 6 P11) reads to paint handles.
2918
+ //
2919
+ // v5 A3: when the grab state actually changes, notify subscribers so
2920
+ // chrome can re-render. We do NOT bump `revisionToken` — grab state is
2921
+ // local UI state, not a document mutation, so collab/autosave/undo
2922
+ // should not observe a change.
2923
+ const next = grabSelectObject(grabState, objectId);
2924
+ if (next === grabState) return;
2925
+ grabState = next;
2926
+ for (const listener of listeners) {
2927
+ listener();
2928
+ }
2929
+ },
2930
+ deselectObject() {
2931
+ const next = grabDeselectObject(grabState);
2932
+ if (next === grabState) return;
2933
+ grabState = next;
2934
+ for (const listener of listeners) {
2935
+ listener();
2936
+ }
2937
+ },
2938
+ getGrabbedObject() {
2939
+ return grabGetGrabbedObject(grabState);
2940
+ },
2941
+ startAction(name) {
2942
+ actionDepth += 1;
2943
+ actionStack.push(name);
2944
+ },
2945
+ endAction() {
2946
+ if (actionDepth === 0) return; // unbalanced — ignore
2947
+ actionDepth -= 1;
2948
+ actionStack.pop();
2949
+ },
2950
+ isInAction() {
2951
+ return actionDepth > 0;
2952
+ },
2500
2953
  applyActiveStoryTextCommand(command) {
2501
2954
  try {
2502
2955
  return applyTextCommandInActiveStory(command);
@@ -2553,15 +3006,14 @@ export function createDocumentRuntime(
2553
3006
  anchor,
2554
3007
  );
2555
3008
  if (rejectionReason !== null) {
2556
- const message =
2557
- rejectionReason === "comment_anchor_table_adjacent"
2558
- ? "DOCX comments cannot currently anchor mid-run within a paragraph adjacent to a table boundary — snap the range to a paragraph or word boundary and retry."
2559
- : "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.";
3009
+ // Post-O8: only `invalid_comment_anchor` remains as a rejection
3010
+ // reason (empty range / crosses opaque block / out-of-story).
2560
3011
  const error: InternalEditorError = {
2561
3012
  errorId: createSessionId("comment-anchor", clock()),
2562
3013
  code: "validation_failed",
2563
3014
  isFatal: false,
2564
- message,
3015
+ message:
3016
+ "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
2565
3017
  source: "runtime",
2566
3018
  details: {
2567
3019
  reason: rejectionReason,
@@ -2681,7 +3133,7 @@ export function createDocumentRuntime(
2681
3133
  });
2682
3134
  }
2683
3135
 
2684
- const resolved = resolveScope(state.document, scopeId);
3136
+ const resolved = resolveScope(nextDocument, scopeId);
2685
3137
  const publicAnchor: EditorAnchorProjection =
2686
3138
  resolved && resolved.kind === "range"
2687
3139
  ? resolved
@@ -2741,21 +3193,19 @@ export function createDocumentRuntime(
2741
3193
  };
2742
3194
  },
2743
3195
  getScope(scopeId) {
3196
+ const normalizedScope =
3197
+ getNormalizedWorkflowOverlay()?.scopes.find((scope) => scope.scopeId === scopeId) ??
3198
+ null;
3199
+ if (normalizedScope) {
3200
+ return normalizedScope;
3201
+ }
2744
3202
  const resolved = resolveScope(state.document, scopeId);
2745
3203
  if (!resolved) {
2746
- const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2747
- return stored ?? null;
2748
- }
2749
- const stored = workflowOverlay?.scopes.find((s) => s.scopeId === scopeId);
2750
- if (!stored) {
2751
- return {
2752
- scopeId,
2753
- mode: "comment",
2754
- anchor: resolved,
2755
- };
3204
+ return null;
2756
3205
  }
2757
3206
  return {
2758
- ...stored,
3207
+ scopeId,
3208
+ mode: "comment",
2759
3209
  anchor: resolved,
2760
3210
  };
2761
3211
  },
@@ -2907,6 +3357,33 @@ export function createDocumentRuntime(
2907
3357
  getDocumentNavigationSnapshot() {
2908
3358
  return getCachedDocumentNavigationSnapshot(state, activeStory);
2909
3359
  },
3360
+ getFieldResolver(): FieldResolver {
3361
+ const pageGraph = layoutEngine.getPageGraph({
3362
+ document: state.document,
3363
+ viewState: {
3364
+ activeStory,
3365
+ workspaceMode: viewState.workspaceMode,
3366
+ zoomLevel: viewState.zoomLevel,
3367
+ },
3368
+ });
3369
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
3370
+ const bookmarkMap = buildBookmarkNameMap(state.document);
3371
+ const paragraphContexts = collectParagraphContexts(state.document.content.children);
3372
+ const paragraphOffsets = paragraphContexts.map((p) => p.startOffset);
3373
+ return createFieldResolver({
3374
+ pageGraph,
3375
+ activePageIndex: navigation.activePageIndex,
3376
+ bookmarkMap,
3377
+ paragraphOffsets,
3378
+ styles: state.document.styles,
3379
+ contentRoot: state.document.content as unknown as import("./field-resolver.ts").DocumentContainerNode,
3380
+ });
3381
+ },
3382
+ getFootnoteResolver(): FootnoteResolver | undefined {
3383
+ const collection = state.document.subParts?.footnoteCollection;
3384
+ if (!collection) return undefined;
3385
+ return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
3386
+ },
2910
3387
  layout: layoutFacet,
2911
3388
  getCurrentLocation() {
2912
3389
  const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
@@ -3090,6 +3567,7 @@ export function createDocumentRuntime(
3090
3567
  },
3091
3568
  getSessionState() {
3092
3569
  const compatibility = createDerivedCompatibility(state);
3570
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
3093
3571
  return editorSessionStateFromPersistedSnapshot(
3094
3572
  {
3095
3573
  ...(createPersistedEditorSnapshot(state, {
@@ -3098,7 +3576,7 @@ export function createDocumentRuntime(
3098
3576
  compatibility,
3099
3577
  protectionSnapshot,
3100
3578
  }) as unknown as PersistedEditorSnapshot),
3101
- workflowOverlay: workflowOverlay ?? undefined,
3579
+ workflowOverlay: normalizedWorkflowOverlay ?? undefined,
3102
3580
  workflowMetadata: deriveWorkflowMetadataSnapshot(),
3103
3581
  },
3104
3582
  );
@@ -3144,7 +3622,7 @@ export function createDocumentRuntime(
3144
3622
  return { schemaVersion: "host-annotation-overlay/1", data: snap };
3145
3623
  }
3146
3624
  case "workflowOverlay": {
3147
- const ov = workflowOverlay;
3625
+ const ov = getNormalizedWorkflowOverlay();
3148
3626
  if (!ov) return null;
3149
3627
  return { schemaVersion: "workflow-overlay/1", data: ov };
3150
3628
  }
@@ -3179,10 +3657,11 @@ export function createDocumentRuntime(
3179
3657
  overlay,
3180
3658
  origin: createOrigin("api", clock()),
3181
3659
  });
3660
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
3182
3661
  editorStateChannel.recordMutation("workflowOverlay", {
3183
3662
  namespace: "workflowOverlay",
3184
3663
  schemaVersion: "workflow-overlay/1",
3185
- data: overlay,
3664
+ data: normalizedWorkflowOverlay ?? overlay,
3186
3665
  });
3187
3666
  },
3188
3667
  clearWorkflowOverlay() {
@@ -3192,7 +3671,7 @@ export function createDocumentRuntime(
3192
3671
  });
3193
3672
  },
3194
3673
  getWorkflowOverlay() {
3195
- return workflowOverlay;
3674
+ return getNormalizedWorkflowOverlay();
3196
3675
  },
3197
3676
  setSharedWorkflowState(state) {
3198
3677
  if (state === sharedWorkflowState) return;
@@ -3244,6 +3723,40 @@ export function createDocumentRuntime(
3244
3723
  getWorkflowMetadataSnapshot() {
3245
3724
  return deriveWorkflowMetadataSnapshot();
3246
3725
  },
3726
+ queryScopes(filter) {
3727
+ return runQueryScopes(
3728
+ {
3729
+ overlay: workflowOverlay,
3730
+ entries: workflowMetadataEntries,
3731
+ document: state.document,
3732
+ },
3733
+ filter,
3734
+ );
3735
+ },
3736
+ findScopesAt(position, options) {
3737
+ const pos =
3738
+ position.kind === "range"
3739
+ ? position.from
3740
+ : position.kind === "node"
3741
+ ? position.at
3742
+ : null;
3743
+ if (pos === null) return [];
3744
+ const hits = findAllScopesAt(state.document, pos);
3745
+ return projectScopeQueryResults(
3746
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
3747
+ hits.map((h) => h.scopeId),
3748
+ options,
3749
+ );
3750
+ },
3751
+ findScopesIntersecting(range, options) {
3752
+ if (range.kind !== "range") return [];
3753
+ const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
3754
+ return projectScopeQueryResults(
3755
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
3756
+ hits.map((h) => h.scopeId),
3757
+ options,
3758
+ );
3759
+ },
3247
3760
  setHostAnnotationOverlay(overlay) {
3248
3761
  this.dispatch({
3249
3762
  type: "host-annotation.set-overlay",
@@ -3341,6 +3854,9 @@ export function createDocumentRuntime(
3341
3854
  get editorStateChannel() {
3342
3855
  return editorStateChannel;
3343
3856
  },
3857
+ hydrateCanonicalDocumentInternally(document: CanonicalDocumentEnvelope) {
3858
+ return hydrateCanonicalDocumentInternally(document);
3859
+ },
3344
3860
  getPerfCountersSnapshot() {
3345
3861
  return perfCounters.snapshot();
3346
3862
  },
@@ -3364,6 +3880,8 @@ export function createDocumentRuntime(
3364
3880
  },
3365
3881
  };
3366
3882
 
3883
+ return runtime;
3884
+
3367
3885
  function applyHistory(direction: "undo" | "redo"): void {
3368
3886
  const source = direction === "undo" ? history.past : history.future;
3369
3887
  const target = source.pop();
@@ -3486,6 +4004,18 @@ export function createDocumentRuntime(
3486
4004
  next: EditorState,
3487
4005
  transaction: EditorTransaction,
3488
4006
  ): void {
4007
+ // V6c — heading-fingerprint comparison schedules an automatic TOC rebuild
4008
+ // when paragraph styleIds or heading text drift. Short-circuit on
4009
+ // document-identity equality (selection-only commits) to skip the walk.
4010
+ if (previous.document !== next.document) {
4011
+ const nextFingerprint = computeHeadingFingerprint(next.document);
4012
+ if (nextFingerprint !== lastHeadingFingerprint) {
4013
+ const trigger = deriveTocTrigger(lastHeadingFingerprint, nextFingerprint);
4014
+ lastHeadingFingerprint = nextFingerprint;
4015
+ scheduleTocAutoRefresh(trigger);
4016
+ }
4017
+ }
4018
+
3489
4019
  const emittedSuggestionIds = new Set<string>();
3490
4020
  if (previous.isDirty !== next.isDirty) {
3491
4021
  emit({
@@ -3982,6 +4512,80 @@ export function createDocumentRuntime(
3982
4512
  });
3983
4513
  }
3984
4514
 
4515
+ // V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
4516
+ // microtask-coalesce shape. Bursts of heading edits within one synchronous
4517
+ // call stack collapse to a single rebuild + a single toc_auto_refreshed
4518
+ // event. Trigger flags accumulate across coalesced edits.
4519
+ function scheduleTocAutoRefresh(trigger: TocRefreshTrigger): void {
4520
+ if (pendingTocTrigger) {
4521
+ pendingTocTrigger = {
4522
+ headingContentChanged:
4523
+ pendingTocTrigger.headingContentChanged || trigger.headingContentChanged,
4524
+ headingStructureChanged:
4525
+ pendingTocTrigger.headingStructureChanged || trigger.headingStructureChanged,
4526
+ };
4527
+ } else {
4528
+ pendingTocTrigger = trigger;
4529
+ }
4530
+ if (tocAutoRefreshScheduled) {
4531
+ perfCounters.increment("toc.autoRefresh.coalesced");
4532
+ return;
4533
+ }
4534
+ tocAutoRefreshScheduled = true;
4535
+ queueMicrotask(() => {
4536
+ tocAutoRefreshScheduled = false;
4537
+ const flushedTrigger = pendingTocTrigger;
4538
+ pendingTocTrigger = null;
4539
+ if (!flushedTrigger) return;
4540
+ const t = performance.now();
4541
+ const refreshed = refreshDocumentTableOfContents(
4542
+ state.document,
4543
+ state.selection.head,
4544
+ activeStory,
4545
+ undefined,
4546
+ (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
4547
+ );
4548
+ perfCounters.increment("toc.autoRefresh.us", Math.round((performance.now() - t) * 1000));
4549
+ if (!refreshed.changed) {
4550
+ perfCounters.increment("toc.autoRefresh.noopRebuild");
4551
+ return;
4552
+ }
4553
+ // Replay through executeEditorCommand so history, mapping, and the
4554
+ // downstream notify() stay consistent. The replay's notify() will
4555
+ // recompute the heading fingerprint and find no change (TOC field
4556
+ // text is not heading text), so this does not loop.
4557
+ const ctx = {
4558
+ timestamp: clock(),
4559
+ documentMode: getEffectiveDocumentMode(state.selection),
4560
+ defaultAuthorId: defaultAuthorId ?? undefined,
4561
+ renderSnapshot: cachedRenderSnapshot,
4562
+ } as const;
4563
+ try {
4564
+ const transaction = executeEditorCommand(
4565
+ state,
4566
+ {
4567
+ type: "document.replace",
4568
+ document: refreshed.document,
4569
+ mapping: createEmptyMapping(),
4570
+ protectionSelection: refreshed.protectionSelection,
4571
+ origin: createOrigin("api", clock()),
4572
+ },
4573
+ ctx,
4574
+ );
4575
+ commit(transaction);
4576
+ } catch (error) {
4577
+ emitError(toRuntimeError(error));
4578
+ return;
4579
+ }
4580
+ emit({
4581
+ type: "toc_auto_refreshed",
4582
+ documentId: state.documentId,
4583
+ entryCount: refreshed.result.entryCount,
4584
+ trigger: flushedTrigger,
4585
+ });
4586
+ });
4587
+ }
4588
+
3985
4589
  function emit(event: DocumentRuntimeEvent): void {
3986
4590
  perfCounters.increment(`emit.${event.type}.calls`);
3987
4591
  const t0 = performance.now();
@@ -3998,6 +4602,7 @@ export function createDocumentRuntime(
3998
4602
  switch (command.type) {
3999
4603
  case "workflow.set-overlay": {
4000
4604
  workflowOverlay = structuredClone(command.overlay);
4605
+ cachedNormalizedWorkflowOverlay = undefined;
4001
4606
  cachedRenderSnapshot = refreshRenderSnapshot();
4002
4607
  const snapshot = deriveWorkflowScopeSnapshot()!;
4003
4608
  emit({
@@ -4016,6 +4621,7 @@ export function createDocumentRuntime(
4016
4621
  }
4017
4622
  case "workflow.clear-overlay": {
4018
4623
  workflowOverlay = null;
4624
+ cachedNormalizedWorkflowOverlay = undefined;
4019
4625
  cachedRenderSnapshot = refreshRenderSnapshot();
4020
4626
  emit({
4021
4627
  type: "workflow_active_work_item_changed",
@@ -4415,6 +5021,61 @@ function createSelectionFromPublicAnchor(
4415
5021
  }
4416
5022
  }
4417
5023
 
5024
+ /**
5025
+ * I2 Tier B Slice 4b — extract the selection range from a document as a
5026
+ * `CanonicalDocumentFragment`. The fragment preserves text + marks +
5027
+ * hard-breaks + paragraph-breaks + tabs that fall inside the range.
5028
+ *
5029
+ * Uses the linear story layer (`parseTextStory` + `logicalPositionToUnitIndex`
5030
+ * + `serializeTextStory`) so the result is a properly-structured block list
5031
+ * that `insertFragment` can splice back in. Collapsed ranges return an empty
5032
+ * fragment.
5033
+ *
5034
+ * Story-aware (v5 A1 fix): `activeStory` selects which content tree the
5035
+ * selection offsets apply to. `getStoryBlocks` is the same helper
5036
+ * `applyTextCommandInActiveStory` uses, so cut/copy in footnote / header /
5037
+ * endnote stories extract from the right content rather than from main body.
5038
+ *
5039
+ * Complex content (tables, opaque blocks) inside the range serializes per
5040
+ * the underlying story layer's semantics — for a richer table-aware
5041
+ * clipboard, callers should use `serializeFragmentToWordML` (Slice 4a) on
5042
+ * the result of this extraction.
5043
+ */
5044
+ function extractSelectionFragment(
5045
+ document: CanonicalDocumentEnvelope,
5046
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
5047
+ activeStory: EditorStoryTarget,
5048
+ ): CanonicalDocumentFragment {
5049
+ const from = Math.min(selection.anchor, selection.head);
5050
+ const to = Math.max(selection.anchor, selection.head);
5051
+ if (from === to) {
5052
+ return { blocks: [] };
5053
+ }
5054
+ // Resolve the content node for the active story. For main body this is the
5055
+ // document root; for secondary stories (header / footer / footnote / endnote)
5056
+ // we wrap the story's blocks in a doc root so parseTextStory produces
5057
+ // offsets in the same frame as the selection.
5058
+ const storyBlocks = getStoryBlocks(document, activeStory);
5059
+ const storyContent =
5060
+ activeStory.kind === "main"
5061
+ ? document.content
5062
+ : { type: "doc" as const, children: [...storyBlocks] };
5063
+ const story = parseTextStory(storyContent);
5064
+ const unitFrom = logicalPositionToUnitIndex(story.units, from, "before");
5065
+ const unitTo = logicalPositionToUnitIndex(story.units, to, "after");
5066
+ const slicedUnits = story.units.slice(unitFrom, unitTo);
5067
+ if (slicedUnits.length === 0) {
5068
+ return { blocks: [] };
5069
+ }
5070
+ const slicedStory = {
5071
+ firstParagraph: story.firstParagraph,
5072
+ units: slicedUnits,
5073
+ size: 0,
5074
+ };
5075
+ const root = serializeTextStory(slicedStory);
5076
+ return { blocks: root.children };
5077
+ }
5078
+
4418
5079
  /**
4419
5080
  * Collect the stable ids of comment threads whose entry differs
4420
5081
  * (present in one side but not the other, OR present in both but
@@ -4960,6 +5621,31 @@ function extractFieldDisplayText(field: FieldNode): string {
4960
5621
  return flattenInlineDisplayText(field.children);
4961
5622
  }
4962
5623
 
5624
+ // V6c — heading fingerprint over (styleId, visible text) pairs in document
5625
+ // order. Walks top-level paragraphs only; matches what
5626
+ // buildHeadingOutline()/createDocumentNavigationSnapshot read.
5627
+ function computeHeadingFingerprint(
5628
+ document: CanonicalDocumentEnvelope,
5629
+ ): string {
5630
+ const parts: string[] = [];
5631
+ for (const block of document.content.children) {
5632
+ if (block.type !== "paragraph") continue;
5633
+ const styleId = block.styleId ?? "";
5634
+ if (!styleId.toLowerCase().startsWith("heading")) continue;
5635
+ parts.push(`${styleId}\u0001${flattenInlineDisplayText(block.children)}`);
5636
+ }
5637
+ return parts.join("\u0002");
5638
+ }
5639
+
5640
+ function deriveTocTrigger(prev: string, next: string): TocRefreshTrigger {
5641
+ const prevCount = prev === "" ? 0 : prev.split("\u0002").length;
5642
+ const nextCount = next === "" ? 0 : next.split("\u0002").length;
5643
+ return {
5644
+ headingStructureChanged: prevCount !== nextCount,
5645
+ headingContentChanged: prevCount === nextCount,
5646
+ };
5647
+ }
5648
+
4963
5649
  function flattenInlineDisplayText(children: readonly InlineNode[]): string {
4964
5650
  return children
4965
5651
  .map((child) => {
@@ -5368,6 +6054,17 @@ function refreshBlocksWithCursor(
5368
6054
  return { blocks: nextBlocks, cursor, previousParagraph };
5369
6055
  }
5370
6056
 
6057
+ /**
6058
+ * Get a ThemeColorResolver for the given document, or undefined if the
6059
+ * document has no theme part. Use this rather than reaching into subParts.canonicalTheme.
6060
+ */
6061
+ export function getThemeColorResolver(
6062
+ doc: CanonicalDocument,
6063
+ ): ThemeColorResolver | undefined {
6064
+ const ct = doc.subParts?.canonicalTheme;
6065
+ return ct ? new ThemeColorResolver(ct) : undefined;
6066
+ }
6067
+
5371
6068
  function refreshInlineNodesWithCursor(
5372
6069
  nodes: readonly InlineNode[],
5373
6070
  visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
@@ -5701,6 +6398,28 @@ function collectParagraphContexts(blocks: readonly BlockNode[]): ParagraphContex
5701
6398
  return paragraphs;
5702
6399
  }
5703
6400
 
6401
+ /**
6402
+ * Collect every section's `SectionProperties` in document order. Each
6403
+ * `section_break` block contributes its own section; the final section
6404
+ * (the implicit one after the last break) is read from
6405
+ * `subParts.finalSectionProperties`. Used by `getFootnoteResolver()` so
6406
+ * per-section `footnotePr`/`endnotePr` can be resolved by section index.
6407
+ */
6408
+ function collectSectionPropertiesInOrder(
6409
+ document: CanonicalDocumentEnvelope,
6410
+ ): SectionProperties[] {
6411
+ const sections: SectionProperties[] = [];
6412
+ for (const block of document.content.children) {
6413
+ if (block.type === "section_break" && block.sectionProperties) {
6414
+ sections.push(block.sectionProperties);
6415
+ }
6416
+ }
6417
+ if (document.subParts?.finalSectionProperties) {
6418
+ sections.push(document.subParts.finalSectionProperties);
6419
+ }
6420
+ return sections;
6421
+ }
6422
+
5704
6423
  function collectParagraphContextsFromBlocks(
5705
6424
  blocks: readonly BlockNode[],
5706
6425
  paragraphs: ParagraphContext[],