@beyondwork/docx-react-component 1.0.56 → 1.0.57

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 (107) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +188 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -2
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. 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,
@@ -115,6 +121,8 @@ import {
115
121
  snapCommentAnchorAwayFromTable,
116
122
  } from "../core/selection/review-anchors.ts";
117
123
  import { buildBookmarkNameMap } from "../legal/bookmarks.ts";
124
+ import { createFieldResolver, type FieldResolver } from "./field-resolver.ts";
125
+ import { createFootnoteResolver, type FootnoteResolver } from "./footnote-resolver.ts";
118
126
  import {
119
127
  describeOpaqueFragment,
120
128
  findOpaqueFragmentsIntersectingRange,
@@ -128,7 +136,7 @@ import {
128
136
  } from "../review/store/revision-store.ts";
129
137
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
130
138
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
131
- import { resolveScope } from "./scope-resolver.ts";
139
+ import { collectScopeLocations, resolveScope } from "./scope-resolver.ts";
132
140
  import {
133
141
  insertScopeMarkers,
134
142
  removeScopeMarkers,
@@ -205,13 +213,16 @@ import {
205
213
  createEditorViewStateSnapshot,
206
214
  type ViewState,
207
215
  } from "./view-state.ts";
216
+ import { ThemeColorResolver } from "./theme-color-resolver.ts";
208
217
  import type {
209
218
  BlockNode,
219
+ CanonicalDocument,
210
220
  FieldNode,
211
221
  FieldRefreshStatus,
212
222
  InlineNode,
213
223
  PageMargins,
214
224
  ParagraphNode,
225
+ SectionProperties,
215
226
  SubPartsCatalog,
216
227
  } from "../model/canonical-document.ts";
217
228
  import {
@@ -240,6 +251,14 @@ import type {
240
251
  EditorStatePersister,
241
252
  } from "../api/editor-state-types.ts";
242
253
  import { collectEditorStateForSerialize } from "./editor-state-integration.ts";
254
+ import { serializeFragmentToWordML } from "../io/paste/word-clipboard.ts";
255
+ import {
256
+ createObjectGrabState,
257
+ deselectObject as grabDeselectObject,
258
+ getGrabbedObject as grabGetGrabbedObject,
259
+ selectObject as grabSelectObject,
260
+ type ObjectGrabState,
261
+ } from "./object-grab/index.ts";
243
262
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
244
263
  import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
245
264
  import { formatPageNumber } from "./page-number-format.ts";
@@ -277,8 +296,77 @@ export interface DocumentRuntime {
277
296
  getRenderSnapshot(): RuntimeRenderSnapshot;
278
297
  getCanonicalDocument(): CanonicalDocumentEnvelope;
279
298
  getSourcePackage(): EditorSessionState["sourcePackage"] | undefined;
299
+ /** Return the parsed fontTable, if present in the loaded package. */
300
+ getFontTable(): CanonicalDocumentEnvelope["fontTable"];
301
+ /**
302
+ * Convenience accessor — return the `CanonicalFontEntry` for `name`, or
303
+ * undefined when the loaded package has no fontTable or no matching entry.
304
+ */
305
+ getFontEntry(
306
+ name: string,
307
+ ): NonNullable<CanonicalDocumentEnvelope["fontTable"]>["fonts"][string] | undefined;
280
308
  replaceText(text: string, target?: EditorAnchorProjection, formatting?: TextFormattingDirective): void;
281
309
  insertFragment(fragment: CanonicalDocumentFragment, target?: EditorAnchorProjection): void;
310
+ /**
311
+ * I2 Tier B Slice 4b — serialize the selection range to a
312
+ * `CanonicalDocumentFragment` and store it in an internal clipboard buffer.
313
+ * No document mutation. `target` defaults to the current selection.
314
+ */
315
+ copy(target?: EditorAnchorProjection): void;
316
+ /**
317
+ * I2 Tier B Slice 4b — `copy(target)` + delete the range. Safe on empty /
318
+ * collapsed ranges (no-op).
319
+ */
320
+ cut(target?: EditorAnchorProjection): void;
321
+ /**
322
+ * I2 Tier B Slice 4b — return the last fragment stored via `cut` / `copy`,
323
+ * or `null` when no clipboard operation has been performed yet. Mirrors
324
+ * what a browser `clipboardData.getData("web application/x-clip")` would
325
+ * return in a system-clipboard-aware build; for now hosts can use this to
326
+ * feed `insertFragment` directly.
327
+ */
328
+ getClipboardBuffer(): CanonicalDocumentFragment | null;
329
+ /**
330
+ * v5 close-out — return the current clipboard buffer serialized to the
331
+ * wire formats browsers/Word accept, or `null` when no clipboard op has
332
+ * been performed. Hosts pair this with `navigator.clipboard.write` inside
333
+ * their own DOM `copy`/`cut` event handler; the editor does not install
334
+ * the DOM handler itself because hosts often route cut/copy through their
335
+ * own protocol. Three formats: WordML (`application/x-docx-fragment`),
336
+ * HTML (`text/html`), and plain text (`text/plain`).
337
+ */
338
+ getClipboardWireFormats(): { wordml: string; html: string; plainText: string } | null;
339
+ /**
340
+ * R.3 ObjectGrabLayer — grab an inline / floating object (image, shape)
341
+ * by its stable id. Single-select model. Local-only state; not broadcast
342
+ * through collab. Lane 6 P11 paints the chrome handles.
343
+ */
344
+ selectObject(objectId: string): void;
345
+ /**
346
+ * R.3 — release any grabbed object. Safe to call when nothing is grabbed.
347
+ */
348
+ deselectObject(): void;
349
+ /**
350
+ * R.3 — return the currently grabbed object id, or `null` when no
351
+ * object is grabbed.
352
+ */
353
+ getGrabbedObject(): string | null;
354
+ /**
355
+ * R.5.a — open an action bracket. Hosts use this to group compound edits
356
+ * (paste → insertFragment, cut → copy+delete, agent suggestion-apply) so
357
+ * snapshot emission + collab broadcast + undo grouping see them as one
358
+ * action. Nested brackets are tracked by depth; only the outermost
359
+ * `endAction` completes the bracket.
360
+ *
361
+ * Phase 1 (Item E): this API ships opt-in. Commands don't auto-bracket
362
+ * themselves yet — hosts that want single-undo paste must call
363
+ * `startAction` / `endAction` around their `insertFragment` call.
364
+ */
365
+ startAction(name: string): void;
366
+ /** R.5.a — close one level of action bracketing. Unbalanced calls are no-ops. */
367
+ endAction(): void;
368
+ /** R.5.a — `true` when the runtime is inside one or more action brackets. */
369
+ isInAction(): boolean;
282
370
  applyActiveStoryTextCommand(command: ActiveStoryTextCommand): TextCommandAck;
283
371
  dispatch(command: EditorCommand): void;
284
372
  /**
@@ -382,6 +470,20 @@ export interface DocumentRuntime {
382
470
  }): DocumentSectionSnapshot | null;
383
471
  describeEventImpact(event: WordReviewEditorEvent): SnapshotRefreshHints;
384
472
  getFieldSnapshot(): FieldSnapshot;
473
+ /**
474
+ * CO3.5 — Field resolver exposing `resolve(entry)` for PAGE / NUMPAGES /
475
+ * PAGEREF / REF / STYLEREF. TOC entries return `undefined`. Reads
476
+ * `layoutEngine.getPageGraph()` + the active page index + the bookmark
477
+ * name map + paragraph start-offsets, so it updates naturally with the
478
+ * current document state.
479
+ */
480
+ getFieldResolver(): FieldResolver;
481
+ /**
482
+ * CO3.5 — Footnote / endnote resolver. Returns `undefined` when the
483
+ * document has no footnote collection attached (no `footnotes.xml`
484
+ * part).
485
+ */
486
+ getFootnoteResolver(): FootnoteResolver | undefined;
385
487
  updateFields(options?: UpdateFieldsOptions): UpdateFieldsResult;
386
488
  updateTableOfContents(options?: TocRefreshOptions): TocRefreshResult;
387
489
  getSessionState(): EditorSessionState;
@@ -638,6 +740,12 @@ export function createDocumentRuntime(
638
740
  // checks this flag) runs during construction.
639
741
  let analyticsEmitScheduled = false;
640
742
 
743
+ // V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
744
+ // a mismatch schedules a microtask refresh of TOC fields.
745
+ let lastHeadingFingerprint: string = "";
746
+ let tocAutoRefreshScheduled = false;
747
+ let pendingTocTrigger: TocRefreshTrigger | null = null;
748
+
641
749
  // Schema 1.2 — editor-state channel (policy, resolver/persister, debounce queues).
642
750
  // Instantiated once per runtime; forwarded to the public interface.
643
751
  const editorStateChannel = createEditorStateChannel();
@@ -693,7 +801,21 @@ export function createDocumentRuntime(
693
801
  canonicalDocument: options.initialCanonicalDocument,
694
802
  fatalError: options.fatalError as never,
695
803
  });
804
+ // I2 Tier B Slice 4b — internal clipboard buffer for cut/copy. System-
805
+ // clipboard write lands with Slice 5 drag; for now hosts read this via
806
+ // `getClipboardBuffer()` and feed it to `insertFragment`.
807
+ let clipboardBuffer: CanonicalDocumentFragment | null = null;
808
+ // R.3 ObjectGrabLayer — local-only grab state for inline / floating objects.
809
+ // Not broadcast through collab; each peer has their own (mirrors text selection).
810
+ let grabState: ObjectGrabState = createObjectGrabState();
811
+ // R.5.a action bracketing — depth counter. `startAction` increments,
812
+ // `endAction` decrements (clamped at 0). `isInAction` returns `depth > 0`.
813
+ // Phase 2 (follow-up) will use this to gate snapshot emission so nested
814
+ // commands observe a single boundary.
815
+ let actionDepth = 0;
816
+ const actionStack: string[] = [];
696
817
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
818
+ lastHeadingFingerprint = computeHeadingFingerprint(state.document);
697
819
 
698
820
  // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
699
821
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
@@ -736,17 +858,18 @@ export function createDocumentRuntime(
736
858
  canonicalDocument: () => state.document,
737
859
  renderKernel: () => renderKernelRef,
738
860
  getWorkflowRailInput: () => {
739
- if (!workflowOverlay) return null;
740
- const activeWorkItemId = workflowOverlay.activeWorkItemId ?? null;
861
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
862
+ if (!normalizedWorkflowOverlay) return null;
863
+ const activeWorkItemId = normalizedWorkflowOverlay.activeWorkItemId ?? null;
741
864
  const activeWorkItem =
742
865
  activeWorkItemId !== null
743
- ? workflowOverlay.workItems?.find(
866
+ ? normalizedWorkflowOverlay.workItems?.find(
744
867
  (item) => item.workItemId === activeWorkItemId,
745
868
  )
746
869
  : undefined;
747
870
  return {
748
- scopes: workflowOverlay.scopes,
749
- candidates: workflowOverlay.candidates,
871
+ scopes: normalizedWorkflowOverlay.scopes,
872
+ candidates: normalizedWorkflowOverlay.candidates,
750
873
  activeWorkItemScopeIds: activeWorkItem?.scopeIds ?? [],
751
874
  activeStory,
752
875
  };
@@ -933,6 +1056,13 @@ export function createDocumentRuntime(
933
1056
  snapshot: WorkflowScopeSnapshot;
934
1057
  }
935
1058
  | undefined;
1059
+ let cachedNormalizedWorkflowOverlay:
1060
+ | {
1061
+ document: CanonicalDocumentEnvelope;
1062
+ workflowOverlay: WorkflowOverlay;
1063
+ normalized: WorkflowOverlay;
1064
+ }
1065
+ | undefined;
936
1066
  let cachedWorkflowMarkupSnapshot:
937
1067
  | {
938
1068
  revisionToken: string;
@@ -1258,10 +1388,12 @@ export function createDocumentRuntime(
1258
1388
  }
1259
1389
  }
1260
1390
 
1261
- if (workflowOverlay) {
1391
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1392
+ if (normalizedWorkflowOverlay) {
1262
1393
  const matchingScope = getMatchingWorkflowScope(selection);
1394
+ const activeScopes = getEffectiveWorkflowScopes(normalizedWorkflowOverlay);
1263
1395
 
1264
- if (!matchingScope && workflowOverlay.scopes.length > 0) {
1396
+ if (!matchingScope && activeScopes.length > 0) {
1265
1397
  reasons.push({
1266
1398
  code: "outside_workflow_scope",
1267
1399
  message: "Selection is outside any active workflow scope.",
@@ -1557,20 +1689,114 @@ export function createDocumentRuntime(
1557
1689
  return left.from < right.to && right.from < left.to;
1558
1690
  }
1559
1691
 
1560
- function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1692
+ function workflowAnchorsEqual(
1693
+ left: EditorAnchorProjection,
1694
+ right: EditorAnchorProjection,
1695
+ ): boolean {
1696
+ if (left.kind !== right.kind) return false;
1697
+ switch (left.kind) {
1698
+ case "range":
1699
+ return (
1700
+ right.kind === "range" &&
1701
+ left.from === right.from &&
1702
+ left.to === right.to &&
1703
+ left.assoc.start === right.assoc.start &&
1704
+ left.assoc.end === right.assoc.end
1705
+ );
1706
+ case "node":
1707
+ return right.kind === "node" && left.at === right.at;
1708
+ case "detached":
1709
+ return (
1710
+ right.kind === "detached" &&
1711
+ left.reason === right.reason &&
1712
+ left.lastKnownRange.from === right.lastKnownRange.from &&
1713
+ left.lastKnownRange.to === right.lastKnownRange.to
1714
+ );
1715
+ default:
1716
+ return false;
1717
+ }
1718
+ }
1719
+
1720
+ function normalizeWorkflowOverlayForDocument(
1721
+ document: CanonicalDocumentEnvelope,
1722
+ overlay: WorkflowOverlay,
1723
+ ): WorkflowOverlay {
1724
+ if (
1725
+ cachedNormalizedWorkflowOverlay &&
1726
+ cachedNormalizedWorkflowOverlay.document === document &&
1727
+ cachedNormalizedWorkflowOverlay.workflowOverlay === overlay
1728
+ ) {
1729
+ return cachedNormalizedWorkflowOverlay.normalized;
1730
+ }
1731
+
1732
+ const scopeIdCounts = new Map<string, number>();
1733
+ for (const scope of overlay.scopes) {
1734
+ scopeIdCounts.set(scope.scopeId, (scopeIdCounts.get(scope.scopeId) ?? 0) + 1);
1735
+ }
1736
+ const locations = collectScopeLocations(document);
1737
+ let changed = false;
1738
+ const normalizedScopes = overlay.scopes.map((scope) => {
1739
+ if ((scopeIdCounts.get(scope.scopeId) ?? 0) !== 1) {
1740
+ return scope;
1741
+ }
1742
+ const location = locations.get(scope.scopeId);
1743
+ if (
1744
+ !location ||
1745
+ location.startPos === undefined ||
1746
+ location.endPos === undefined
1747
+ ) {
1748
+ return scope;
1749
+ }
1750
+ const nextAnchor: EditorAnchorProjection = {
1751
+ kind: "range",
1752
+ from: Math.min(location.startPos, location.endPos),
1753
+ to: Math.max(location.startPos, location.endPos),
1754
+ assoc: { start: -1, end: 1 },
1755
+ };
1756
+ if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
1757
+ return scope;
1758
+ }
1759
+ changed = true;
1760
+ return {
1761
+ ...scope,
1762
+ anchor: nextAnchor,
1763
+ };
1764
+ });
1765
+
1766
+ const normalized = changed
1767
+ ? {
1768
+ ...overlay,
1769
+ scopes: normalizedScopes,
1770
+ }
1771
+ : overlay;
1772
+ cachedNormalizedWorkflowOverlay = {
1773
+ document,
1774
+ workflowOverlay: overlay,
1775
+ normalized,
1776
+ };
1777
+ return normalized;
1778
+ }
1779
+
1780
+ function getNormalizedWorkflowOverlay(): WorkflowOverlay | null {
1561
1781
  if (!workflowOverlay) return null;
1782
+ return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
1783
+ }
1784
+
1785
+ function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1786
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1787
+ if (!normalizedWorkflowOverlay) return null;
1562
1788
  const blockedReasons = getCachedInteractionGuardSnapshot().blockedReasons;
1563
- const activeItem = workflowOverlay.activeWorkItemId
1564
- ? workflowOverlay.workItems?.find(
1565
- (item) => item.workItemId === workflowOverlay!.activeWorkItemId,
1789
+ const activeItem = normalizedWorkflowOverlay.activeWorkItemId
1790
+ ? normalizedWorkflowOverlay.workItems?.find(
1791
+ (item) => item.workItemId === normalizedWorkflowOverlay.activeWorkItemId,
1566
1792
  )
1567
1793
  : undefined;
1568
1794
  return {
1569
1795
  overlayPresent: true,
1570
- activeWorkItemId: workflowOverlay.activeWorkItemId ?? null,
1796
+ activeWorkItemId: normalizedWorkflowOverlay.activeWorkItemId ?? null,
1571
1797
  activeWorkItem: activeItem,
1572
- scopes: workflowOverlay.scopes,
1573
- candidates: workflowOverlay.candidates ?? [],
1798
+ scopes: normalizedWorkflowOverlay.scopes,
1799
+ candidates: normalizedWorkflowOverlay.candidates ?? [],
1574
1800
  blockedReasons,
1575
1801
  };
1576
1802
  }
@@ -1590,15 +1816,16 @@ export function createDocumentRuntime(
1590
1816
  }
1591
1817
 
1592
1818
  function getEffectiveWorkflowScopes(overlay: WorkflowOverlay): WorkflowOverlay["scopes"] {
1593
- const activeWorkItemId = overlay.activeWorkItemId ?? null;
1819
+ const normalizedOverlay = normalizeWorkflowOverlayForDocument(state.document, overlay);
1820
+ const activeWorkItemId = normalizedOverlay.activeWorkItemId ?? null;
1594
1821
  const activeWorkItemScopeIds =
1595
1822
  activeWorkItemId === null
1596
1823
  ? null
1597
1824
  : new Set(
1598
- overlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
1825
+ normalizedOverlay.workItems?.find((item) => item.workItemId === activeWorkItemId)?.scopeIds ?? [],
1599
1826
  );
1600
1827
 
1601
- return overlay.scopes.filter((scope) => {
1828
+ return normalizedOverlay.scopes.filter((scope) => {
1602
1829
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
1603
1830
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) {
1604
1831
  return false;
@@ -1844,12 +2071,13 @@ export function createDocumentRuntime(
1844
2071
  const tCompat = performance.now();
1845
2072
  const compat = toPublicCompatibilityReport(createDerivedCompatibility(state));
1846
2073
  perfCounters.increment("ctxa.compat.us", Math.round((performance.now() - tCompat) * 1000));
2074
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1847
2075
 
1848
2076
  const tBuild = performance.now();
1849
2077
  const snapshot = createRuntimeContextAnalyticsSnapshot({
1850
2078
  query,
1851
2079
  renderSnapshot: cachedRenderSnapshot,
1852
- workflowOverlay,
2080
+ workflowOverlay: normalizedWorkflowOverlay,
1853
2081
  workflowScopeSnapshot: wfScope,
1854
2082
  interactionGuardSnapshot: wfGuard,
1855
2083
  workflowMarkupSnapshot: wfMarkup,
@@ -2022,6 +2250,47 @@ export function createDocumentRuntime(
2022
2250
  }
2023
2251
  }
2024
2252
 
2253
+ function invalidateDerivedRuntimeCaches(): void {
2254
+ cachedSurface = undefined;
2255
+ cachedCompatibility = undefined;
2256
+ cachedComments = undefined;
2257
+ cachedTrackedChanges = undefined;
2258
+ cachedSuggestions = undefined;
2259
+ cachedReviewWork = undefined;
2260
+ cachedPageLayout = undefined;
2261
+ cachedNavigation = undefined;
2262
+ cachedViewStateSnapshot = undefined;
2263
+ cachedInteractionGuardSnapshot = undefined;
2264
+ cachedWorkflowScopeSnapshot = undefined;
2265
+ cachedNormalizedWorkflowOverlay = undefined;
2266
+ cachedWorkflowMarkupSnapshot = undefined;
2267
+ cachedContextAnalyticsSnapshots.clear();
2268
+ lastEmittedContextAnalyticsSnapshots = undefined;
2269
+ }
2270
+
2271
+ function hydrateCanonicalDocumentInternally(
2272
+ document: CanonicalDocumentEnvelope,
2273
+ ): boolean {
2274
+ if (document === state.document) {
2275
+ return false;
2276
+ }
2277
+ const previousDocument = state.document;
2278
+ state = {
2279
+ ...state,
2280
+ document,
2281
+ };
2282
+ if (previousDocument.subParts !== document.subParts) {
2283
+ fontLoader.refresh(collectFontLoaderInput(document));
2284
+ layoutEngine.invalidateMeasurementCache();
2285
+ }
2286
+ invalidateDerivedRuntimeCaches();
2287
+ cachedRenderSnapshot = refreshRenderSnapshot();
2288
+ for (const listener of listeners) {
2289
+ listener();
2290
+ }
2291
+ return true;
2292
+ }
2293
+
2025
2294
  function getCachedViewStateSnapshot(): EditorViewStateSnapshot {
2026
2295
  const activeStoryKey = storyTargetKey(activeStory);
2027
2296
  const pageLayout = cachedRenderSnapshot.pageLayout;
@@ -2131,7 +2400,11 @@ export function createDocumentRuntime(
2131
2400
  const r5ScratchReplayState: typeof state = { ...state };
2132
2401
  const r5ScratchReplaySnapshot: typeof cachedRenderSnapshot = { ...cachedRenderSnapshot };
2133
2402
 
2134
- return {
2403
+ const runtime: DocumentRuntime & {
2404
+ hydrateCanonicalDocumentInternally(
2405
+ document: CanonicalDocumentEnvelope,
2406
+ ): boolean;
2407
+ } = {
2135
2408
  subscribe(listener) {
2136
2409
  listeners.add(listener);
2137
2410
  return () => {
@@ -2153,6 +2426,12 @@ export function createDocumentRuntime(
2153
2426
  getSourcePackage() {
2154
2427
  return state.sourcePackage;
2155
2428
  },
2429
+ getFontTable() {
2430
+ return state.document.fontTable;
2431
+ },
2432
+ getFontEntry(name: string) {
2433
+ return state.document.fontTable?.fonts[name];
2434
+ },
2156
2435
  emitBlockedCommand(command, reasons) {
2157
2436
  emit({
2158
2437
  type: "command_blocked",
@@ -2480,6 +2759,11 @@ export function createDocumentRuntime(
2480
2759
  insertFragment(fragment, target) {
2481
2760
  // I2 Tier B Slice 1 — dispatch `fragment.insert` against the active story. The
2482
2761
  // runtime command handler routes into `applyFragmentInsert` (structure-ops).
2762
+ // v5 B1: auto-bracketed via R.5.a so a host that wants single-undo paste
2763
+ // gets one action. Idempotent on nested brackets: if a caller already
2764
+ // opened `startAction`, this bracket just increments / decrements depth.
2765
+ actionDepth += 1;
2766
+ actionStack.push("insertFragment");
2483
2767
  try {
2484
2768
  const timestamp = clock();
2485
2769
  applyTextCommandInActiveStory(
@@ -2495,7 +2779,143 @@ export function createDocumentRuntime(
2495
2779
  );
2496
2780
  } catch (error) {
2497
2781
  emitError(toRuntimeError(error));
2782
+ } finally {
2783
+ actionDepth -= 1;
2784
+ actionStack.pop();
2785
+ }
2786
+ },
2787
+ copy(target) {
2788
+ // I2 Tier B Slice 4b — serialize the selection range to a fragment, store
2789
+ // it in the internal buffer. Does NOT mutate the document.
2790
+ // v5 A1 fix: story-aware extraction — footnote / header / endnote
2791
+ // selections read the right content tree, not main body.
2792
+ try {
2793
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
2794
+ const fragment = extractSelectionFragment(state.document, selection, activeStory);
2795
+ clipboardBuffer = fragment;
2796
+ } catch (error) {
2797
+ emitError(toRuntimeError(error));
2798
+ }
2799
+ },
2800
+ cut(target) {
2801
+ // I2 Tier B Slice 4b — copy into buffer, then delete the range by
2802
+ // replacing it with empty text. (Empty fragment.insert is a deliberate
2803
+ // no-op in the splicer per Slice 1, so we use the text.insert path with
2804
+ // an empty string to delete the selected content.)
2805
+ // v5 B1: auto-bracketed so a host that wants single-undo paste-at-drop
2806
+ // gets one action even if its own bracket isn't open.
2807
+ try {
2808
+ const selection = target ? createSelectionFromPublicAnchor(target) : state.selection;
2809
+ const fragment = extractSelectionFragment(state.document, selection, activeStory);
2810
+ clipboardBuffer = fragment;
2811
+ if (selection.anchor !== selection.head) {
2812
+ actionDepth += 1;
2813
+ actionStack.push("cut");
2814
+ try {
2815
+ const timestamp = clock();
2816
+ applyTextCommandInActiveStory(
2817
+ {
2818
+ type: "text.insert",
2819
+ text: "",
2820
+ origin: createOrigin("api", timestamp),
2821
+ },
2822
+ {
2823
+ selection,
2824
+ blockedCommandName: "cut",
2825
+ },
2826
+ );
2827
+ } finally {
2828
+ actionDepth -= 1;
2829
+ actionStack.pop();
2830
+ }
2831
+ }
2832
+ } catch (error) {
2833
+ emitError(toRuntimeError(error));
2834
+ }
2835
+ },
2836
+ getClipboardBuffer() {
2837
+ return clipboardBuffer;
2838
+ },
2839
+ getClipboardWireFormats() {
2840
+ // v5 close-out — serialize the buffer to WordML + HTML + plain text for
2841
+ // host-owned `navigator.clipboard.write`. Returns null if nothing has
2842
+ // been cut/copied yet. Zero allocation on the null path.
2843
+ if (!clipboardBuffer || clipboardBuffer.blocks.length === 0) return null;
2844
+ const wordml = serializeFragmentToWordML(clipboardBuffer);
2845
+ // Minimal HTML: walk paragraph children emitting <p> wrappers with
2846
+ // text content. Keeps parity with our own parser (round-trips
2847
+ // cleanly) without implementing full CSS export.
2848
+ const htmlParts: string[] = [];
2849
+ const plainParts: string[] = [];
2850
+ for (const block of clipboardBuffer.blocks) {
2851
+ if (block.type !== "paragraph") continue;
2852
+ const runs: string[] = [];
2853
+ const plainRuns: string[] = [];
2854
+ for (const child of block.children) {
2855
+ if (child.type === "text") {
2856
+ const text = (child.text ?? "")
2857
+ .replace(/&/g, "&amp;")
2858
+ .replace(/</g, "&lt;")
2859
+ .replace(/>/g, "&gt;");
2860
+ let wrapped = text;
2861
+ const marks = child.marks ?? [];
2862
+ if (marks.some((m) => m.type === "bold")) wrapped = `<b>${wrapped}</b>`;
2863
+ if (marks.some((m) => m.type === "italic")) wrapped = `<i>${wrapped}</i>`;
2864
+ if (marks.some((m) => m.type === "underline")) wrapped = `<u>${wrapped}</u>`;
2865
+ if (marks.some((m) => m.type === "strikethrough")) wrapped = `<s>${wrapped}</s>`;
2866
+ runs.push(wrapped);
2867
+ plainRuns.push(child.text ?? "");
2868
+ } else if (child.type === "hard_break") {
2869
+ runs.push("<br/>");
2870
+ plainRuns.push("\n");
2871
+ }
2872
+ }
2873
+ htmlParts.push(`<p>${runs.join("")}</p>`);
2874
+ plainParts.push(plainRuns.join(""));
2498
2875
  }
2876
+ return {
2877
+ wordml,
2878
+ html: htmlParts.join(""),
2879
+ plainText: plainParts.join("\n"),
2880
+ };
2881
+ },
2882
+ selectObject(objectId) {
2883
+ // R.3 — local grab state mutation. No command dispatch; this is pure UI
2884
+ // state that chrome (Lane 6 P11) reads to paint handles.
2885
+ //
2886
+ // v5 A3: when the grab state actually changes, notify subscribers so
2887
+ // chrome can re-render. We do NOT bump `revisionToken` — grab state is
2888
+ // local UI state, not a document mutation, so collab/autosave/undo
2889
+ // should not observe a change.
2890
+ const next = grabSelectObject(grabState, objectId);
2891
+ if (next === grabState) return;
2892
+ grabState = next;
2893
+ for (const listener of listeners) {
2894
+ listener();
2895
+ }
2896
+ },
2897
+ deselectObject() {
2898
+ const next = grabDeselectObject(grabState);
2899
+ if (next === grabState) return;
2900
+ grabState = next;
2901
+ for (const listener of listeners) {
2902
+ listener();
2903
+ }
2904
+ },
2905
+ getGrabbedObject() {
2906
+ return grabGetGrabbedObject(grabState);
2907
+ },
2908
+ startAction(name) {
2909
+ actionDepth += 1;
2910
+ actionStack.push(name);
2911
+ },
2912
+ endAction() {
2913
+ if (actionDepth === 0) return; // unbalanced — ignore
2914
+ actionDepth -= 1;
2915
+ actionStack.pop();
2916
+ },
2917
+ isInAction() {
2918
+ return actionDepth > 0;
2499
2919
  },
2500
2920
  applyActiveStoryTextCommand(command) {
2501
2921
  try {
@@ -2553,15 +2973,14 @@ export function createDocumentRuntime(
2553
2973
  anchor,
2554
2974
  );
2555
2975
  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.";
2976
+ // Post-O8: only `invalid_comment_anchor` remains as a rejection
2977
+ // reason (empty range / crosses opaque block / out-of-story).
2560
2978
  const error: InternalEditorError = {
2561
2979
  errorId: createSessionId("comment-anchor", clock()),
2562
2980
  code: "validation_failed",
2563
2981
  isFatal: false,
2564
- message,
2982
+ message:
2983
+ "DOCX comments must use a non-empty range that stays within a single story and does not cross table or opaque-fragment boundaries.",
2565
2984
  source: "runtime",
2566
2985
  details: {
2567
2986
  reason: rejectionReason,
@@ -2681,7 +3100,7 @@ export function createDocumentRuntime(
2681
3100
  });
2682
3101
  }
2683
3102
 
2684
- const resolved = resolveScope(state.document, scopeId);
3103
+ const resolved = resolveScope(nextDocument, scopeId);
2685
3104
  const publicAnchor: EditorAnchorProjection =
2686
3105
  resolved && resolved.kind === "range"
2687
3106
  ? resolved
@@ -2741,21 +3160,19 @@ export function createDocumentRuntime(
2741
3160
  };
2742
3161
  },
2743
3162
  getScope(scopeId) {
3163
+ const normalizedScope =
3164
+ getNormalizedWorkflowOverlay()?.scopes.find((scope) => scope.scopeId === scopeId) ??
3165
+ null;
3166
+ if (normalizedScope) {
3167
+ return normalizedScope;
3168
+ }
2744
3169
  const resolved = resolveScope(state.document, scopeId);
2745
3170
  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
- };
3171
+ return null;
2756
3172
  }
2757
3173
  return {
2758
- ...stored,
3174
+ scopeId,
3175
+ mode: "comment",
2759
3176
  anchor: resolved,
2760
3177
  };
2761
3178
  },
@@ -2907,6 +3324,33 @@ export function createDocumentRuntime(
2907
3324
  getDocumentNavigationSnapshot() {
2908
3325
  return getCachedDocumentNavigationSnapshot(state, activeStory);
2909
3326
  },
3327
+ getFieldResolver(): FieldResolver {
3328
+ const pageGraph = layoutEngine.getPageGraph({
3329
+ document: state.document,
3330
+ viewState: {
3331
+ activeStory,
3332
+ workspaceMode: viewState.workspaceMode,
3333
+ zoomLevel: viewState.zoomLevel,
3334
+ },
3335
+ });
3336
+ const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
3337
+ const bookmarkMap = buildBookmarkNameMap(state.document);
3338
+ const paragraphContexts = collectParagraphContexts(state.document.content.children);
3339
+ const paragraphOffsets = paragraphContexts.map((p) => p.startOffset);
3340
+ return createFieldResolver({
3341
+ pageGraph,
3342
+ activePageIndex: navigation.activePageIndex,
3343
+ bookmarkMap,
3344
+ paragraphOffsets,
3345
+ styles: state.document.styles,
3346
+ contentRoot: state.document.content as unknown as import("./field-resolver.ts").DocumentContainerNode,
3347
+ });
3348
+ },
3349
+ getFootnoteResolver(): FootnoteResolver | undefined {
3350
+ const collection = state.document.subParts?.footnoteCollection;
3351
+ if (!collection) return undefined;
3352
+ return createFootnoteResolver(collection, collectSectionPropertiesInOrder(state.document));
3353
+ },
2910
3354
  layout: layoutFacet,
2911
3355
  getCurrentLocation() {
2912
3356
  const navigation = getCachedDocumentNavigationSnapshot(state, activeStory);
@@ -3090,6 +3534,7 @@ export function createDocumentRuntime(
3090
3534
  },
3091
3535
  getSessionState() {
3092
3536
  const compatibility = createDerivedCompatibility(state);
3537
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
3093
3538
  return editorSessionStateFromPersistedSnapshot(
3094
3539
  {
3095
3540
  ...(createPersistedEditorSnapshot(state, {
@@ -3098,7 +3543,7 @@ export function createDocumentRuntime(
3098
3543
  compatibility,
3099
3544
  protectionSnapshot,
3100
3545
  }) as unknown as PersistedEditorSnapshot),
3101
- workflowOverlay: workflowOverlay ?? undefined,
3546
+ workflowOverlay: normalizedWorkflowOverlay ?? undefined,
3102
3547
  workflowMetadata: deriveWorkflowMetadataSnapshot(),
3103
3548
  },
3104
3549
  );
@@ -3144,7 +3589,7 @@ export function createDocumentRuntime(
3144
3589
  return { schemaVersion: "host-annotation-overlay/1", data: snap };
3145
3590
  }
3146
3591
  case "workflowOverlay": {
3147
- const ov = workflowOverlay;
3592
+ const ov = getNormalizedWorkflowOverlay();
3148
3593
  if (!ov) return null;
3149
3594
  return { schemaVersion: "workflow-overlay/1", data: ov };
3150
3595
  }
@@ -3179,10 +3624,11 @@ export function createDocumentRuntime(
3179
3624
  overlay,
3180
3625
  origin: createOrigin("api", clock()),
3181
3626
  });
3627
+ const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
3182
3628
  editorStateChannel.recordMutation("workflowOverlay", {
3183
3629
  namespace: "workflowOverlay",
3184
3630
  schemaVersion: "workflow-overlay/1",
3185
- data: overlay,
3631
+ data: normalizedWorkflowOverlay ?? overlay,
3186
3632
  });
3187
3633
  },
3188
3634
  clearWorkflowOverlay() {
@@ -3192,7 +3638,7 @@ export function createDocumentRuntime(
3192
3638
  });
3193
3639
  },
3194
3640
  getWorkflowOverlay() {
3195
- return workflowOverlay;
3641
+ return getNormalizedWorkflowOverlay();
3196
3642
  },
3197
3643
  setSharedWorkflowState(state) {
3198
3644
  if (state === sharedWorkflowState) return;
@@ -3341,6 +3787,9 @@ export function createDocumentRuntime(
3341
3787
  get editorStateChannel() {
3342
3788
  return editorStateChannel;
3343
3789
  },
3790
+ hydrateCanonicalDocumentInternally(document: CanonicalDocumentEnvelope) {
3791
+ return hydrateCanonicalDocumentInternally(document);
3792
+ },
3344
3793
  getPerfCountersSnapshot() {
3345
3794
  return perfCounters.snapshot();
3346
3795
  },
@@ -3364,6 +3813,8 @@ export function createDocumentRuntime(
3364
3813
  },
3365
3814
  };
3366
3815
 
3816
+ return runtime;
3817
+
3367
3818
  function applyHistory(direction: "undo" | "redo"): void {
3368
3819
  const source = direction === "undo" ? history.past : history.future;
3369
3820
  const target = source.pop();
@@ -3486,6 +3937,18 @@ export function createDocumentRuntime(
3486
3937
  next: EditorState,
3487
3938
  transaction: EditorTransaction,
3488
3939
  ): void {
3940
+ // V6c — heading-fingerprint comparison schedules an automatic TOC rebuild
3941
+ // when paragraph styleIds or heading text drift. Short-circuit on
3942
+ // document-identity equality (selection-only commits) to skip the walk.
3943
+ if (previous.document !== next.document) {
3944
+ const nextFingerprint = computeHeadingFingerprint(next.document);
3945
+ if (nextFingerprint !== lastHeadingFingerprint) {
3946
+ const trigger = deriveTocTrigger(lastHeadingFingerprint, nextFingerprint);
3947
+ lastHeadingFingerprint = nextFingerprint;
3948
+ scheduleTocAutoRefresh(trigger);
3949
+ }
3950
+ }
3951
+
3489
3952
  const emittedSuggestionIds = new Set<string>();
3490
3953
  if (previous.isDirty !== next.isDirty) {
3491
3954
  emit({
@@ -3982,6 +4445,80 @@ export function createDocumentRuntime(
3982
4445
  });
3983
4446
  }
3984
4447
 
4448
+ // V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
4449
+ // microtask-coalesce shape. Bursts of heading edits within one synchronous
4450
+ // call stack collapse to a single rebuild + a single toc_auto_refreshed
4451
+ // event. Trigger flags accumulate across coalesced edits.
4452
+ function scheduleTocAutoRefresh(trigger: TocRefreshTrigger): void {
4453
+ if (pendingTocTrigger) {
4454
+ pendingTocTrigger = {
4455
+ headingContentChanged:
4456
+ pendingTocTrigger.headingContentChanged || trigger.headingContentChanged,
4457
+ headingStructureChanged:
4458
+ pendingTocTrigger.headingStructureChanged || trigger.headingStructureChanged,
4459
+ };
4460
+ } else {
4461
+ pendingTocTrigger = trigger;
4462
+ }
4463
+ if (tocAutoRefreshScheduled) {
4464
+ perfCounters.increment("toc.autoRefresh.coalesced");
4465
+ return;
4466
+ }
4467
+ tocAutoRefreshScheduled = true;
4468
+ queueMicrotask(() => {
4469
+ tocAutoRefreshScheduled = false;
4470
+ const flushedTrigger = pendingTocTrigger;
4471
+ pendingTocTrigger = null;
4472
+ if (!flushedTrigger) return;
4473
+ const t = performance.now();
4474
+ const refreshed = refreshDocumentTableOfContents(
4475
+ state.document,
4476
+ state.selection.head,
4477
+ activeStory,
4478
+ undefined,
4479
+ (pageIndex: number) => layoutFacet.getDisplayPageNumber(pageIndex),
4480
+ );
4481
+ perfCounters.increment("toc.autoRefresh.us", Math.round((performance.now() - t) * 1000));
4482
+ if (!refreshed.changed) {
4483
+ perfCounters.increment("toc.autoRefresh.noopRebuild");
4484
+ return;
4485
+ }
4486
+ // Replay through executeEditorCommand so history, mapping, and the
4487
+ // downstream notify() stay consistent. The replay's notify() will
4488
+ // recompute the heading fingerprint and find no change (TOC field
4489
+ // text is not heading text), so this does not loop.
4490
+ const ctx = {
4491
+ timestamp: clock(),
4492
+ documentMode: getEffectiveDocumentMode(state.selection),
4493
+ defaultAuthorId: defaultAuthorId ?? undefined,
4494
+ renderSnapshot: cachedRenderSnapshot,
4495
+ } as const;
4496
+ try {
4497
+ const transaction = executeEditorCommand(
4498
+ state,
4499
+ {
4500
+ type: "document.replace",
4501
+ document: refreshed.document,
4502
+ mapping: createEmptyMapping(),
4503
+ protectionSelection: refreshed.protectionSelection,
4504
+ origin: createOrigin("api", clock()),
4505
+ },
4506
+ ctx,
4507
+ );
4508
+ commit(transaction);
4509
+ } catch (error) {
4510
+ emitError(toRuntimeError(error));
4511
+ return;
4512
+ }
4513
+ emit({
4514
+ type: "toc_auto_refreshed",
4515
+ documentId: state.documentId,
4516
+ entryCount: refreshed.result.entryCount,
4517
+ trigger: flushedTrigger,
4518
+ });
4519
+ });
4520
+ }
4521
+
3985
4522
  function emit(event: DocumentRuntimeEvent): void {
3986
4523
  perfCounters.increment(`emit.${event.type}.calls`);
3987
4524
  const t0 = performance.now();
@@ -3998,6 +4535,7 @@ export function createDocumentRuntime(
3998
4535
  switch (command.type) {
3999
4536
  case "workflow.set-overlay": {
4000
4537
  workflowOverlay = structuredClone(command.overlay);
4538
+ cachedNormalizedWorkflowOverlay = undefined;
4001
4539
  cachedRenderSnapshot = refreshRenderSnapshot();
4002
4540
  const snapshot = deriveWorkflowScopeSnapshot()!;
4003
4541
  emit({
@@ -4016,6 +4554,7 @@ export function createDocumentRuntime(
4016
4554
  }
4017
4555
  case "workflow.clear-overlay": {
4018
4556
  workflowOverlay = null;
4557
+ cachedNormalizedWorkflowOverlay = undefined;
4019
4558
  cachedRenderSnapshot = refreshRenderSnapshot();
4020
4559
  emit({
4021
4560
  type: "workflow_active_work_item_changed",
@@ -4415,6 +4954,61 @@ function createSelectionFromPublicAnchor(
4415
4954
  }
4416
4955
  }
4417
4956
 
4957
+ /**
4958
+ * I2 Tier B Slice 4b — extract the selection range from a document as a
4959
+ * `CanonicalDocumentFragment`. The fragment preserves text + marks +
4960
+ * hard-breaks + paragraph-breaks + tabs that fall inside the range.
4961
+ *
4962
+ * Uses the linear story layer (`parseTextStory` + `logicalPositionToUnitIndex`
4963
+ * + `serializeTextStory`) so the result is a properly-structured block list
4964
+ * that `insertFragment` can splice back in. Collapsed ranges return an empty
4965
+ * fragment.
4966
+ *
4967
+ * Story-aware (v5 A1 fix): `activeStory` selects which content tree the
4968
+ * selection offsets apply to. `getStoryBlocks` is the same helper
4969
+ * `applyTextCommandInActiveStory` uses, so cut/copy in footnote / header /
4970
+ * endnote stories extract from the right content rather than from main body.
4971
+ *
4972
+ * Complex content (tables, opaque blocks) inside the range serializes per
4973
+ * the underlying story layer's semantics — for a richer table-aware
4974
+ * clipboard, callers should use `serializeFragmentToWordML` (Slice 4a) on
4975
+ * the result of this extraction.
4976
+ */
4977
+ function extractSelectionFragment(
4978
+ document: CanonicalDocumentEnvelope,
4979
+ selection: import("../core/state/editor-state.ts").SelectionSnapshot,
4980
+ activeStory: EditorStoryTarget,
4981
+ ): CanonicalDocumentFragment {
4982
+ const from = Math.min(selection.anchor, selection.head);
4983
+ const to = Math.max(selection.anchor, selection.head);
4984
+ if (from === to) {
4985
+ return { blocks: [] };
4986
+ }
4987
+ // Resolve the content node for the active story. For main body this is the
4988
+ // document root; for secondary stories (header / footer / footnote / endnote)
4989
+ // we wrap the story's blocks in a doc root so parseTextStory produces
4990
+ // offsets in the same frame as the selection.
4991
+ const storyBlocks = getStoryBlocks(document, activeStory);
4992
+ const storyContent =
4993
+ activeStory.kind === "main"
4994
+ ? document.content
4995
+ : { type: "doc" as const, children: [...storyBlocks] };
4996
+ const story = parseTextStory(storyContent);
4997
+ const unitFrom = logicalPositionToUnitIndex(story.units, from, "before");
4998
+ const unitTo = logicalPositionToUnitIndex(story.units, to, "after");
4999
+ const slicedUnits = story.units.slice(unitFrom, unitTo);
5000
+ if (slicedUnits.length === 0) {
5001
+ return { blocks: [] };
5002
+ }
5003
+ const slicedStory = {
5004
+ firstParagraph: story.firstParagraph,
5005
+ units: slicedUnits,
5006
+ size: 0,
5007
+ };
5008
+ const root = serializeTextStory(slicedStory);
5009
+ return { blocks: root.children };
5010
+ }
5011
+
4418
5012
  /**
4419
5013
  * Collect the stable ids of comment threads whose entry differs
4420
5014
  * (present in one side but not the other, OR present in both but
@@ -4960,6 +5554,31 @@ function extractFieldDisplayText(field: FieldNode): string {
4960
5554
  return flattenInlineDisplayText(field.children);
4961
5555
  }
4962
5556
 
5557
+ // V6c — heading fingerprint over (styleId, visible text) pairs in document
5558
+ // order. Walks top-level paragraphs only; matches what
5559
+ // buildHeadingOutline()/createDocumentNavigationSnapshot read.
5560
+ function computeHeadingFingerprint(
5561
+ document: CanonicalDocumentEnvelope,
5562
+ ): string {
5563
+ const parts: string[] = [];
5564
+ for (const block of document.content.children) {
5565
+ if (block.type !== "paragraph") continue;
5566
+ const styleId = block.styleId ?? "";
5567
+ if (!styleId.toLowerCase().startsWith("heading")) continue;
5568
+ parts.push(`${styleId}\u0001${flattenInlineDisplayText(block.children)}`);
5569
+ }
5570
+ return parts.join("\u0002");
5571
+ }
5572
+
5573
+ function deriveTocTrigger(prev: string, next: string): TocRefreshTrigger {
5574
+ const prevCount = prev === "" ? 0 : prev.split("\u0002").length;
5575
+ const nextCount = next === "" ? 0 : next.split("\u0002").length;
5576
+ return {
5577
+ headingStructureChanged: prevCount !== nextCount,
5578
+ headingContentChanged: prevCount === nextCount,
5579
+ };
5580
+ }
5581
+
4963
5582
  function flattenInlineDisplayText(children: readonly InlineNode[]): string {
4964
5583
  return children
4965
5584
  .map((child) => {
@@ -5368,6 +5987,17 @@ function refreshBlocksWithCursor(
5368
5987
  return { blocks: nextBlocks, cursor, previousParagraph };
5369
5988
  }
5370
5989
 
5990
+ /**
5991
+ * Get a ThemeColorResolver for the given document, or undefined if the
5992
+ * document has no theme part. Use this rather than reaching into subParts.canonicalTheme.
5993
+ */
5994
+ export function getThemeColorResolver(
5995
+ doc: CanonicalDocument,
5996
+ ): ThemeColorResolver | undefined {
5997
+ const ct = doc.subParts?.canonicalTheme;
5998
+ return ct ? new ThemeColorResolver(ct) : undefined;
5999
+ }
6000
+
5371
6001
  function refreshInlineNodesWithCursor(
5372
6002
  nodes: readonly InlineNode[],
5373
6003
  visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,
@@ -5701,6 +6331,28 @@ function collectParagraphContexts(blocks: readonly BlockNode[]): ParagraphContex
5701
6331
  return paragraphs;
5702
6332
  }
5703
6333
 
6334
+ /**
6335
+ * Collect every section's `SectionProperties` in document order. Each
6336
+ * `section_break` block contributes its own section; the final section
6337
+ * (the implicit one after the last break) is read from
6338
+ * `subParts.finalSectionProperties`. Used by `getFootnoteResolver()` so
6339
+ * per-section `footnotePr`/`endnotePr` can be resolved by section index.
6340
+ */
6341
+ function collectSectionPropertiesInOrder(
6342
+ document: CanonicalDocumentEnvelope,
6343
+ ): SectionProperties[] {
6344
+ const sections: SectionProperties[] = [];
6345
+ for (const block of document.content.children) {
6346
+ if (block.type === "section_break" && block.sectionProperties) {
6347
+ sections.push(block.sectionProperties);
6348
+ }
6349
+ }
6350
+ if (document.subParts?.finalSectionProperties) {
6351
+ sections.push(document.subParts.finalSectionProperties);
6352
+ }
6353
+ return sections;
6354
+ }
6355
+
5704
6356
  function collectParagraphContextsFromBlocks(
5705
6357
  blocks: readonly BlockNode[],
5706
6358
  paragraphs: ParagraphContext[],