@beyondwork/docx-react-component 1.0.86 → 1.0.88

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +49 -0
  3. package/src/api/v3/ui/chrome-composition.ts +2 -11
  4. package/src/api/v3/ui/chrome.ts +6 -8
  5. package/src/index.ts +5 -0
  6. package/src/io/export/serialize-main-document.ts +215 -6
  7. package/src/io/ooxml/parse-drawing.ts +15 -1
  8. package/src/io/ooxml/parse-fields.ts +410 -12
  9. package/src/model/canonical-document.ts +177 -2
  10. package/src/model/layout/page-layout-snapshot.ts +2 -0
  11. package/src/model/layout/runtime-page-graph-types.ts +6 -0
  12. package/src/preservation/store.ts +4 -5
  13. package/src/runtime/document-outline.ts +80 -0
  14. package/src/runtime/document-runtime.ts +580 -40
  15. package/src/runtime/formatting/field/page-number-format.ts +49 -0
  16. package/src/runtime/formatting/field/resolver.ts +61 -40
  17. package/src/runtime/layout/layout-engine-instance.ts +18 -1
  18. package/src/runtime/layout/layout-engine-version.ts +19 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +21 -9
  20. package/src/runtime/layout/measurement-backend-empirical.ts +18 -4
  21. package/src/runtime/layout/page-graph.ts +13 -2
  22. package/src/runtime/layout/paginated-layout-engine.ts +440 -117
  23. package/src/runtime/layout/project-block-fragments.ts +87 -4
  24. package/src/runtime/layout/resolve-page-fields.ts +8 -5
  25. package/src/runtime/layout/table-row-split.ts +97 -23
  26. package/src/runtime/surface-projection.ts +227 -27
  27. package/src/shell/session-bootstrap.ts +6 -1
  28. package/src/ui/WordReviewEditor.tsx +8 -0
  29. package/src/ui/editor-surface-controller.tsx +1 -0
  30. package/src/ui/headless/revision-decoration-model.ts +11 -13
  31. package/src/ui-tailwind/chrome/responsive-chrome.ts +2 -2
  32. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +27 -0
  33. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +57 -6
  34. package/src/ui-tailwind/editor-surface/page-slice-util.ts +17 -0
  35. package/src/ui-tailwind/editor-surface/perf-probe.ts +5 -0
  36. package/src/ui-tailwind/editor-surface/pm-contextual-ui.ts +34 -0
  37. package/src/ui-tailwind/editor-surface/pm-decorations.ts +146 -20
  38. package/src/ui-tailwind/editor-surface/pm-position-map.ts +8 -2
  39. package/src/ui-tailwind/editor-surface/preserve-position.ts +31 -6
  40. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +20 -5
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +92 -50
  42. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +1 -1
  43. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +32 -3
  44. package/src/ui-tailwind/review-workspace/use-review-rail-state.ts +1 -1
  45. package/src/ui-tailwind/tw-review-workspace.tsx +18 -0
@@ -124,6 +124,7 @@ import {
124
124
  MAIN_STORY_TARGET,
125
125
  storyTargetsEqual,
126
126
  type EditorAnchorProjection as InternalEditorAnchorProjection,
127
+ type TransactionMapping,
127
128
  } from "../core/selection/mapping.ts";
128
129
  import {
129
130
  toInternalAnchorProjection,
@@ -291,6 +292,7 @@ import type {
291
292
  ParagraphNode,
292
293
  SectionProperties,
293
294
  SubPartsCatalog,
295
+ TocRegion,
294
296
  } from "../model/canonical-document.ts";
295
297
  import {
296
298
  isSupportedFieldFamily,
@@ -1173,6 +1175,8 @@ export function createDocumentRuntime(
1173
1175
  // to a single microtask-deferred emit. Declared early because emit() (which
1174
1176
  // checks this flag) runs during construction.
1175
1177
  let analyticsEmitScheduled = false;
1178
+ let analyticsEmitScheduleMode: "none" | "microtask" | "idle" = "none";
1179
+ let deferNextContextAnalyticsEmit = false;
1176
1180
 
1177
1181
  // V6c — heading fingerprint for TOC auto-invalidation. Compared in notify();
1178
1182
  // a mismatch schedules a microtask refresh of TOC fields.
@@ -1379,6 +1383,8 @@ export function createDocumentRuntime(
1379
1383
  let viewportBlockRanges: readonly { start: number; end: number }[] | null = null;
1380
1384
  /** Serialized fingerprint of the active ranges — used as the idempotency key for `refreshSurfaceOnly`. */
1381
1385
  let viewportRangesKey: string = serializeViewportRanges(null);
1386
+ const EDITING_CORRIDOR_BLOCK_RADIUS = 8;
1387
+ const EDITING_CORRIDOR_MIN_BLOCKS = 24;
1382
1388
 
1383
1389
  function applyViewportRanges(
1384
1390
  incoming: readonly { start: number; end: number }[] | null,
@@ -1566,19 +1572,35 @@ export function createDocumentRuntime(
1566
1572
  function getCachedSurface(
1567
1573
  document: CanonicalDocumentEnvelope,
1568
1574
  nextActiveStory: EditorStoryTarget,
1575
+ options: {
1576
+ viewportBlockRangesOverride?: readonly { start: number; end: number }[] | null;
1577
+ enrichCulledPlaceholders?: boolean;
1578
+ } = {},
1569
1579
  ): RuntimeRenderSnapshot["surface"] {
1570
1580
  const activeStoryKey = storyTargetKey(nextActiveStory);
1581
+ const surfaceViewportRanges =
1582
+ "viewportBlockRangesOverride" in options
1583
+ ? options.viewportBlockRangesOverride ?? null
1584
+ : viewportBlockRanges;
1585
+ const surfaceViewportRangesKey =
1586
+ "viewportBlockRangesOverride" in options
1587
+ ? serializeViewportRanges(surfaceViewportRanges)
1588
+ : viewportRangesKey;
1589
+ const surfaceCacheKey =
1590
+ options.enrichCulledPlaceholders === false
1591
+ ? `${surfaceViewportRangesKey}|raw-placeholders`
1592
+ : surfaceViewportRangesKey;
1571
1593
  if (
1572
1594
  cachedSurface &&
1573
1595
  cachedSurface.revisionToken === state.revisionToken &&
1574
1596
  cachedSurface.activeStoryKey === activeStoryKey &&
1575
- cachedSurface.viewportRangesKey === viewportRangesKey
1597
+ cachedSurface.viewportRangesKey === surfaceCacheKey
1576
1598
  ) {
1577
1599
  return cachedSurface.snapshot;
1578
1600
  }
1579
1601
 
1580
1602
  const snapshot = createEditorSurfaceSnapshot(document, state.selection, nextActiveStory, {
1581
- viewportBlockRanges,
1603
+ viewportBlockRanges: surfaceViewportRanges,
1582
1604
  ...(effectiveMarkupModeProvider
1583
1605
  ? { getEffectiveMarkupMode: effectiveMarkupModeProvider }
1584
1606
  : {}),
@@ -1604,20 +1626,57 @@ export function createDocumentRuntime(
1604
1626
  //
1605
1627
  // No-op on pre-pagination calls (engine not yet ready → empty map)
1606
1628
  // and on the inert facet.
1607
- const enrichedSnapshot = enrichCulledPlaceholdersWithHeights(snapshot);
1629
+ const enrichedSnapshot =
1630
+ options.enrichCulledPlaceholders === false
1631
+ ? snapshot
1632
+ : enrichCulledPlaceholdersWithHeights(snapshot);
1608
1633
  cachedSurface = {
1609
1634
  revisionToken: state.revisionToken,
1610
1635
  activeStoryKey,
1611
- viewportRangesKey,
1636
+ viewportRangesKey: surfaceCacheKey,
1612
1637
  snapshot: enrichedSnapshot,
1613
1638
  };
1614
1639
  // Keep the scroll-path fingerprint in lockstep so a subsequent
1615
1640
  // `maybeRefreshSurfaceForViewport` sees the freshly-built snapshot
1616
1641
  // and short-circuits instead of paying a redundant projection.
1617
- cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${viewportRangesKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1642
+ cachedSurfaceFingerprint = `${state.revisionToken}|${activeStoryKey}|${surfaceCacheKey}|${String(state.selection.anchor)}:${String(state.selection.head)}`;
1618
1643
  return enrichedSnapshot;
1619
1644
  }
1620
1645
 
1646
+ function getLocalTextCommitViewportRanges(
1647
+ previousSurface: RuntimeRenderSnapshot["surface"] | undefined,
1648
+ ): readonly { start: number; end: number }[] | null {
1649
+ if (!previousSurface || previousSurface.blocks.length < EDITING_CORRIDOR_MIN_BLOCKS) {
1650
+ return viewportBlockRanges;
1651
+ }
1652
+ const caretBlockIndex = findSurfaceBlockIndexForSelection(previousSurface, state.selection);
1653
+ if (caretBlockIndex === -1) {
1654
+ return viewportBlockRanges;
1655
+ }
1656
+ const corridor = {
1657
+ start: Math.max(0, caretBlockIndex - EDITING_CORRIDOR_BLOCK_RADIUS),
1658
+ end: Math.min(previousSurface.blocks.length, caretBlockIndex + EDITING_CORRIDOR_BLOCK_RADIUS + 1),
1659
+ };
1660
+ return normalizeViewportRanges([...(viewportBlockRanges ?? []), corridor]);
1661
+ }
1662
+
1663
+ function findSurfaceBlockIndexForSelection(
1664
+ surface: RuntimeRenderSnapshot["surface"],
1665
+ selection: EditorState["selection"],
1666
+ ): number {
1667
+ if (!surface) return -1;
1668
+ const from = Math.min(selection.anchor, selection.head);
1669
+ const to = Math.max(selection.anchor, selection.head);
1670
+ for (let index = 0; index < surface.blocks.length; index += 1) {
1671
+ const block = surface.blocks[index];
1672
+ if (!block) continue;
1673
+ if (to >= block.from && from <= block.to) {
1674
+ return index;
1675
+ }
1676
+ }
1677
+ return -1;
1678
+ }
1679
+
1621
1680
  function enrichCulledPlaceholdersWithHeights(
1622
1681
  snapshot: EditorSurfaceSnapshot,
1623
1682
  ): EditorSurfaceSnapshot {
@@ -2468,6 +2527,57 @@ export function createDocumentRuntime(
2468
2527
  return snapshot;
2469
2528
  }
2470
2529
 
2530
+ function refreshLocalTextCommitSnapshot(
2531
+ surface: RuntimeRenderSnapshot["surface"],
2532
+ mapping: TransactionMapping,
2533
+ ): RuntimeRenderSnapshot {
2534
+ perfCounters.increment("refresh.localTextEquivalent");
2535
+ return {
2536
+ ...cachedRenderSnapshot,
2537
+ documentId: state.documentId,
2538
+ sessionId: state.sessionId,
2539
+ sourceLabel: state.sourceLabel,
2540
+ revisionToken: state.revisionToken,
2541
+ isReady: state.phase === "ready",
2542
+ isDirty: state.isDirty,
2543
+ readOnly: state.readOnly,
2544
+ documentMode: viewState.documentMode,
2545
+ selection: toPublicSelectionSnapshot(state.selection, activeStory),
2546
+ activeStory,
2547
+ documentStats: updateDocumentStatsForLocalTextCommit(
2548
+ cachedRenderSnapshot.documentStats,
2549
+ mapping,
2550
+ ),
2551
+ warnings: cachedRenderSnapshot.warnings,
2552
+ fatalError: state.fatalError ? toPublicError(state.fatalError) : undefined,
2553
+ commandState: {
2554
+ canUndo: history.past.length > 0,
2555
+ canRedo: history.future.length > 0,
2556
+ readOnly: state.readOnly,
2557
+ },
2558
+ surface,
2559
+ protectionSnapshot,
2560
+ grabbedObjectId: grabState.objectId,
2561
+ };
2562
+ }
2563
+
2564
+ function updateDocumentStatsForLocalTextCommit(
2565
+ previousStats: RuntimeRenderSnapshot["documentStats"],
2566
+ mapping: TransactionMapping,
2567
+ ): RuntimeRenderSnapshot["documentStats"] {
2568
+ if (!previousStats) {
2569
+ return toPublicDocumentStats(state);
2570
+ }
2571
+ let delta = 0;
2572
+ for (const step of mapping.steps) {
2573
+ delta += step.insertSize - Math.max(0, step.to - step.from);
2574
+ }
2575
+ return {
2576
+ ...previousStats,
2577
+ storyLength: Math.max(0, previousStats.storyLength + delta),
2578
+ };
2579
+ }
2580
+
2471
2581
  /**
2472
2582
  * Fingerprint over the inputs that the viewport-refresh fast-path knows
2473
2583
  * about. Used to short-circuit {@link maybeRefreshSurfaceForViewport}
@@ -3565,6 +3675,7 @@ export function createDocumentRuntime(
3565
3675
  emitError(runtimeError);
3566
3676
  return {
3567
3677
  kind: "rejected",
3678
+ refreshClass: "blocked",
3568
3679
  opId: (command.origin as { opId?: string } | undefined)?.opId,
3569
3680
  newRevisionToken: "",
3570
3681
  blockedReasons: [
@@ -4890,14 +5001,52 @@ export function createDocumentRuntime(
4890
5001
  history.future = [];
4891
5002
  }
4892
5003
 
4893
- applyTransactionToState(transaction);
5004
+ applyTransactionToState(transaction, { allowLocalTextFastPath: true });
4894
5005
  }
4895
5006
 
4896
5007
  function commitRemote(transaction: EditorTransaction): void {
4897
5008
  applyTransactionToState(transaction);
4898
5009
  }
4899
5010
 
4900
- function applyTransactionToState(transaction: EditorTransaction): void {
5011
+ function shouldUseLocalTextCommitSnapshot(
5012
+ previous: EditorState,
5013
+ next: EditorState,
5014
+ transaction: EditorTransaction,
5015
+ effects: EditorTransaction["effects"],
5016
+ ): boolean {
5017
+ if (activeStory.kind !== "main") return false;
5018
+ if (!transaction.markDirty || transaction.mapping.steps.length === 0) {
5019
+ return false;
5020
+ }
5021
+ if (transaction.mapping.metadata?.invalidatesStructures) {
5022
+ return false;
5023
+ }
5024
+ if (previous.document.subParts !== next.document.subParts) {
5025
+ return false;
5026
+ }
5027
+ if (effects.warningsAdded.length > 0 || effects.warningsCleared.length > 0) {
5028
+ return false;
5029
+ }
5030
+ if ((effects.transientWarnings?.length ?? 0) > 0) {
5031
+ return false;
5032
+ }
5033
+ return (
5034
+ !effects.commentAdded &&
5035
+ !effects.commentResolved &&
5036
+ !effects.commentReopened &&
5037
+ !effects.commentReplyAdded &&
5038
+ !effects.commentBodyEdited &&
5039
+ !effects.changeAccepted &&
5040
+ !effects.changeRejected &&
5041
+ !effects.revisionAuthored &&
5042
+ !effects.commandBlocked
5043
+ );
5044
+ }
5045
+
5046
+ function applyTransactionToState(
5047
+ transaction: EditorTransaction,
5048
+ options: { allowLocalTextFastPath?: boolean } = {},
5049
+ ): void {
4901
5050
  // Pure-no-op short-circuit: when a reducer skipped without emitting any
4902
5051
  // observable effect AND the selection is identical, skip finalizeState
4903
5052
  // / invalidate / refresh / notify entirely. This keeps hot paths (e.g.
@@ -4939,7 +5088,9 @@ export function createDocumentRuntime(
4939
5088
  const previous = state;
4940
5089
 
4941
5090
  const tApply0 = performance.now();
5091
+ const tProtection0 = performance.now();
4942
5092
  protectionSnapshot = remapProtectionSnapshot(protectionSnapshot, transaction.mapping);
5093
+ perfCounters.increment("commit.protectionRemap.us", Math.round((performance.now() - tProtection0) * 1000));
4943
5094
  const tFinalize0 = performance.now();
4944
5095
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
4945
5096
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
@@ -4947,8 +5098,10 @@ export function createDocumentRuntime(
4947
5098
  // Re-sync marker-backed scope IDs against the new document; the
4948
5099
  // store handles set computation + telemetry. `replaceOverlay(current, doc)`
4949
5100
  // is idempotent on state and re-derives the marker-backed set.
5101
+ const tOverlay0 = performance.now();
4950
5102
  overlayStore.replaceOverlay(overlayStore.getOverlay(), state.document);
4951
5103
  const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
5104
+ perfCounters.increment("commit.overlaySync.us", Math.round((performance.now() - tOverlay0) * 1000));
4952
5105
 
4953
5106
  const tInvalidate0 = performance.now();
4954
5107
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -4972,12 +5125,37 @@ export function createDocumentRuntime(
4972
5125
  }
4973
5126
  perfCounters.increment("commit.invalidate.us", Math.round((performance.now() - tInvalidate0) * 1000));
4974
5127
 
5128
+ const notifyEffects: EditorTransaction["effects"] = {
5129
+ ...transaction.effects,
5130
+ warningsAdded: [
5131
+ ...transaction.effects.warningsAdded,
5132
+ ...detachedWorkflowScopeWarnings.added,
5133
+ ],
5134
+ warningsCleared: [
5135
+ ...transaction.effects.warningsCleared,
5136
+ ...detachedWorkflowScopeWarnings.cleared,
5137
+ ],
5138
+ };
5139
+
5140
+ const tClassify0 = performance.now();
5141
+ const useLocalTextCommitSnapshot =
5142
+ options.allowLocalTextFastPath === true &&
5143
+ shouldUseLocalTextCommitSnapshot(
5144
+ previous,
5145
+ state,
5146
+ transaction,
5147
+ notifyEffects,
5148
+ );
5149
+ perfCounters.increment("commit.refreshClassify.us", Math.round((performance.now() - tClassify0) * 1000));
5150
+
4975
5151
  // I5: validate post-mutation selection against the new document bound.
4976
5152
  // First call to getCachedSurface() here computes the post-mutation surface
4977
5153
  // snapshot (cache miss — revisionToken just changed in finalizeState).
4978
- // refreshRenderSnapshot's internal call below hits the cache and reuses it.
4979
- // Net cost: identical to pre-I5 one surface walk per commit, just shifted
4980
- // a few lines earlier so the validator can read storySize.
5154
+ // For local text commits, this is an editing-corridor surface (current
5155
+ // block plus a small halo) so the immediate typing path keeps visible
5156
+ // truth hot without rebuilding offscreen table/comment markup. Structural,
5157
+ // review, and warning commits still use the full current viewport surface
5158
+ // and fall back to refreshRenderSnapshot().
4981
5159
  //
4982
5160
  // Wired ONLY at this chokepoint. The other 12 `cachedRenderSnapshot =
4983
5161
  // refreshRenderSnapshot()` sites in this file are intentionally skipped:
@@ -4997,7 +5175,13 @@ export function createDocumentRuntime(
4997
5175
  // The return type of getCachedSurface is `RuntimeRenderSnapshot["surface"]`
4998
5176
  // which is optional in the public API for shape-only reasons — the helper
4999
5177
  // itself always returns a defined snapshot.
5000
- const surfaceForValidation = getCachedSurface(state.document, activeStory)!;
5178
+ const tValidation0 = performance.now();
5179
+ const surfaceForValidation = useLocalTextCommitSnapshot
5180
+ ? getCachedSurface(state.document, activeStory, {
5181
+ viewportBlockRangesOverride: getLocalTextCommitViewportRanges(cachedRenderSnapshot.surface),
5182
+ enrichCulledPlaceholders: false,
5183
+ })!
5184
+ : getCachedSurface(state.document, activeStory)!;
5001
5185
  const validationOptions = state.selection.activeRange.kind === "node"
5002
5186
  ? {
5003
5187
  isValidNodeTarget: createSurfaceNodeSelectionProbe(surfaceForValidation),
@@ -5013,26 +5197,24 @@ export function createDocumentRuntime(
5013
5197
  state = { ...state, selection: validatedSelection };
5014
5198
  storySelections.set(storyTargetKey(activeStory), state.selection);
5015
5199
  }
5200
+ perfCounters.increment("commit.selectionValidation.us", Math.round((performance.now() - tValidation0) * 1000));
5016
5201
 
5017
5202
  const tRefresh0 = performance.now();
5018
- cachedRenderSnapshot = refreshRenderSnapshot();
5203
+ cachedRenderSnapshot = useLocalTextCommitSnapshot
5204
+ ? refreshLocalTextCommitSnapshot(surfaceForValidation, transaction.mapping)
5205
+ : refreshRenderSnapshot();
5019
5206
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
5020
5207
 
5021
5208
  const tNotify0 = performance.now();
5022
- notify(previous, state, {
5023
- ...transaction,
5024
- effects: {
5025
- ...transaction.effects,
5026
- warningsAdded: [
5027
- ...transaction.effects.warningsAdded,
5028
- ...detachedWorkflowScopeWarnings.added,
5029
- ],
5030
- warningsCleared: [
5031
- ...transaction.effects.warningsCleared,
5032
- ...detachedWorkflowScopeWarnings.cleared,
5033
- ],
5034
- },
5035
- });
5209
+ deferNextContextAnalyticsEmit = useLocalTextCommitSnapshot;
5210
+ try {
5211
+ notify(previous, state, {
5212
+ ...transaction,
5213
+ effects: notifyEffects,
5214
+ });
5215
+ } finally {
5216
+ deferNextContextAnalyticsEmit = false;
5217
+ }
5036
5218
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
5037
5219
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
5038
5220
  emitStageToken(telemetryBus, "commit", "commit.apply.complete", {
@@ -5319,6 +5501,7 @@ export function createDocumentRuntime(
5319
5501
  });
5320
5502
  return completeDispatch({
5321
5503
  kind: "rejected",
5504
+ refreshClass: "blocked",
5322
5505
  opId,
5323
5506
  newRevisionToken: "",
5324
5507
  blockedReasons: [{ code: "suggesting_unsupported", message }],
@@ -5335,6 +5518,7 @@ export function createDocumentRuntime(
5335
5518
  });
5336
5519
  return completeDispatch({
5337
5520
  kind: "rejected",
5521
+ refreshClass: "blocked",
5338
5522
  opId,
5339
5523
  newRevisionToken: "",
5340
5524
  blockedReasons: blockedReasons.map((r) => ({ code: r.code, message: r.message })),
@@ -5408,6 +5592,7 @@ export function createDocumentRuntime(
5408
5592
  });
5409
5593
  return completeDispatch({
5410
5594
  kind: "equivalent",
5595
+ refreshClass: "local-text-equivalent",
5411
5596
  opId,
5412
5597
  newRevisionToken: state.revisionToken,
5413
5598
  });
@@ -5475,6 +5660,7 @@ export function createDocumentRuntime(
5475
5660
  if (meta.invalidatesStructures) {
5476
5661
  return {
5477
5662
  kind: "structural-divergence",
5663
+ refreshClass: "full-projection",
5478
5664
  opId,
5479
5665
  newRevisionToken,
5480
5666
  scopeTagTouches: touches,
@@ -5499,6 +5685,7 @@ export function createDocumentRuntime(
5499
5685
  const adjustedRange = computeAdjustedRange(priorState, transaction);
5500
5686
  return {
5501
5687
  kind: "adjusted",
5688
+ refreshClass: "surface-only",
5502
5689
  opId,
5503
5690
  newRevisionToken,
5504
5691
  adjustedRange,
@@ -5508,6 +5695,7 @@ export function createDocumentRuntime(
5508
5695
 
5509
5696
  return {
5510
5697
  kind: "equivalent",
5698
+ refreshClass: "local-text-equivalent",
5511
5699
  opId,
5512
5700
  newRevisionToken,
5513
5701
  scopeTagTouches: touches,
@@ -5600,23 +5788,58 @@ export function createDocumentRuntime(
5600
5788
  // synchronous call stack — one per commit. (Flag declared above near
5601
5789
  // perfCounters so it is initialized before construction-time emits run.)
5602
5790
  function scheduleContextAnalyticsEmit(): void {
5791
+ const shouldDefer = deferNextContextAnalyticsEmit;
5792
+ deferNextContextAnalyticsEmit = false;
5603
5793
  if (analyticsEmitScheduled) {
5794
+ if (shouldDefer && analyticsEmitScheduleMode === "microtask") {
5795
+ analyticsEmitScheduleMode = "idle";
5796
+ perfCounters.increment("emit.contextAnalytics.deferred");
5797
+ return;
5798
+ }
5604
5799
  perfCounters.increment("emit.contextAnalytics.coalesced");
5605
5800
  return;
5606
5801
  }
5607
5802
  analyticsEmitScheduled = true;
5608
- queueMicrotask(() => {
5803
+ analyticsEmitScheduleMode = shouldDefer ? "idle" : "microtask";
5804
+ const run = () => {
5609
5805
  // Reset BEFORE the emit so any synchronous re-entrant emits triggered
5610
5806
  // by listener callbacks schedule a fresh second microtask (no lost
5611
5807
  // updates). Reversing this order would drop bursts originating in
5612
5808
  // listeners.
5613
5809
  analyticsEmitScheduled = false;
5810
+ analyticsEmitScheduleMode = "none";
5614
5811
  const t = performance.now();
5615
5812
  emitContextAnalyticsChanged();
5616
5813
  perfCounters.increment("emit.contextAnalytics.us", Math.round((performance.now() - t) * 1000));
5814
+ };
5815
+ if (shouldDefer) {
5816
+ perfCounters.increment("emit.contextAnalytics.deferred");
5817
+ scheduleIdleContextAnalytics(run);
5818
+ return;
5819
+ }
5820
+ queueMicrotask(() => {
5821
+ if (analyticsEmitScheduleMode === "idle") {
5822
+ scheduleIdleContextAnalytics(run);
5823
+ return;
5824
+ }
5825
+ run();
5617
5826
  });
5618
5827
  }
5619
5828
 
5829
+ function scheduleIdleContextAnalytics(callback: () => void): void {
5830
+ const requestIdle = (globalThis as {
5831
+ requestIdleCallback?: (
5832
+ cb: () => void,
5833
+ options?: { timeout?: number },
5834
+ ) => number;
5835
+ }).requestIdleCallback;
5836
+ if (typeof requestIdle === "function") {
5837
+ requestIdle(callback, { timeout: 250 });
5838
+ return;
5839
+ }
5840
+ setTimeout(callback, 0);
5841
+ }
5842
+
5620
5843
  // V6c — TOC auto-refresh scheduler. Mirrors scheduleContextAnalyticsEmit's
5621
5844
  // microtask-coalesce shape. Bursts of heading edits within one synchronous
5622
5845
  // call stack collapse to a single rebuild + a single toc_auto_refreshed
@@ -5685,6 +5908,10 @@ export function createDocumentRuntime(
5685
5908
  emit({
5686
5909
  type: "toc_auto_refreshed",
5687
5910
  documentId: state.documentId,
5911
+ ...(refreshed.result.tocId ? { tocId: refreshed.result.tocId } : {}),
5912
+ ...(refreshed.document.fieldRegistry?.tocRegions
5913
+ ? { regionCount: refreshed.document.fieldRegistry.tocRegions.length }
5914
+ : {}),
5688
5915
  entryCount: refreshed.result.entryCount,
5689
5916
  trigger: flushedTrigger,
5690
5917
  });
@@ -7161,6 +7388,29 @@ function refreshDocumentTableOfContents(
7161
7388
  changed: boolean;
7162
7389
  protectionSelection?: import("../core/state/editor-state.ts").SelectionSnapshot;
7163
7390
  } {
7391
+ const selectedRegion = selectTocRegion(document.fieldRegistry?.tocRegions, options?.tocId);
7392
+ const refreshMode = options?.mode ?? "regenerate";
7393
+ if (refreshMode === "preserveCached") {
7394
+ const cachedEntries = selectedRegion?.cachedEntries ?? [];
7395
+ return {
7396
+ document,
7397
+ result: {
7398
+ ...(selectedRegion ? { tocId: selectedRegion.tocId } : {}),
7399
+ mode: "preserveCached",
7400
+ status: selectedRegion?.status ?? (cachedEntries.length > 0 ? "stale" : "missing"),
7401
+ entryCount: cachedEntries.length,
7402
+ entries: cachedEntries.map((entry) => ({
7403
+ level: entry.level,
7404
+ text: entry.text,
7405
+ pageIndex: tocPageTextToPageIndex(entry.pageText),
7406
+ ...(entry.pageText ? { pageText: entry.pageText } : {}),
7407
+ source: "cached",
7408
+ })),
7409
+ },
7410
+ changed: false,
7411
+ };
7412
+ }
7413
+
7164
7414
  const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
7165
7415
  // Build a single O(N) map from paragraph offset → bookmark name so the
7166
7416
  // per-heading lookup below is O(1) instead of O(N) per heading.
@@ -7189,16 +7439,16 @@ function refreshDocumentTableOfContents(
7189
7439
  }
7190
7440
  }
7191
7441
  let changed = false;
7192
- let resultEntries: Array<{ level: number; text: string; pageIndex: number; bookmarkName?: string }> = [];
7442
+ let resultEntries: RuntimeTocEntry[] = [];
7193
7443
  let changedFrom: number | undefined;
7194
7444
  let changedTo: number | undefined;
7195
- const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
7196
- if (field.fieldFamily !== "TOC") {
7197
- return field;
7198
- }
7445
+
7446
+ const buildEntriesForField = (field: FieldNode): RuntimeTocEntry[] => {
7199
7447
  const levelRange = options?.maxLevel
7200
7448
  ? { from: 1, to: options.maxLevel }
7201
- : parseTocLevelRange(field.instruction);
7449
+ : selectedRegion && normalizeTocInstruction(selectedRegion.instruction.raw) === normalizeTocInstruction(field.instruction)
7450
+ ? selectedRegion.instruction.outlineRange
7451
+ : parseTocLevelRange(field.instruction);
7202
7452
  const entries = navigation.headings
7203
7453
  .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
7204
7454
  .map((heading) => {
@@ -7213,6 +7463,30 @@ function refreshDocumentTableOfContents(
7213
7463
  if (resultEntries.length === 0) {
7214
7464
  resultEntries = entries;
7215
7465
  }
7466
+ return entries;
7467
+ };
7468
+
7469
+ const tocRangeRefresh = refreshTocBlockRanges(
7470
+ document.content.children,
7471
+ buildEntriesForField,
7472
+ resolveDisplayPageNumber,
7473
+ selectedRegion,
7474
+ );
7475
+ if (tocRangeRefresh.changed) {
7476
+ changed = true;
7477
+ }
7478
+
7479
+ const nextChildren = refreshBlocksWithCursor(tocRangeRefresh.blocks, (field, range) => {
7480
+ if (field.fieldFamily !== "TOC") {
7481
+ return field;
7482
+ }
7483
+ if (selectedRegion && normalizeTocInstruction(field.instruction) !== normalizeTocInstruction(selectedRegion.instruction.raw)) {
7484
+ return field;
7485
+ }
7486
+ if (field.refreshStatus === "current" && field.children.length === 0) {
7487
+ return field;
7488
+ }
7489
+ const entries = buildEntriesForField(field);
7216
7490
  const nextField: FieldNode = {
7217
7491
  ...field,
7218
7492
  children: buildTocInlineNodes(entries, resolveDisplayPageNumber),
@@ -7228,7 +7502,7 @@ function refreshDocumentTableOfContents(
7228
7502
  if (!changed) {
7229
7503
  return {
7230
7504
  document,
7231
- result: { entryCount: resultEntries.length, entries: resultEntries },
7505
+ result: buildTocRefreshResult(selectedRegion, "regenerate", resultEntries, "current"),
7232
7506
  changed: false,
7233
7507
  };
7234
7508
  }
@@ -7245,13 +7519,24 @@ function refreshDocumentTableOfContents(
7245
7519
  styles: nextDocument.styles,
7246
7520
  subParts: nextDocument.subParts,
7247
7521
  });
7248
- nextDocument.fieldRegistry = nextRegistry.tocStructure
7522
+ const nextTocRegions = nextRegistry.tocRegions?.map((region) => {
7523
+ if (!selectedRegion || normalizeTocInstruction(region.instruction.raw) === normalizeTocInstruction(selectedRegion.instruction.raw)) {
7524
+ return { ...region, status: "current" as const };
7525
+ }
7526
+ return region;
7527
+ });
7528
+ nextDocument.fieldRegistry = nextRegistry.tocStructure || nextTocRegions
7249
7529
  ? {
7250
7530
  ...nextRegistry,
7251
- tocStructure: {
7252
- ...nextRegistry.tocStructure,
7253
- status: "current",
7254
- },
7531
+ ...(nextTocRegions ? { tocRegions: nextTocRegions } : {}),
7532
+ ...(nextRegistry.tocStructure
7533
+ ? {
7534
+ tocStructure: {
7535
+ ...nextRegistry.tocStructure,
7536
+ status: "current",
7537
+ },
7538
+ }
7539
+ : {}),
7255
7540
  }
7256
7541
  : nextRegistry;
7257
7542
  let protectionSelection:
@@ -7263,12 +7548,267 @@ function refreshDocumentTableOfContents(
7263
7548
 
7264
7549
  return {
7265
7550
  document: nextDocument,
7266
- result: { entryCount: resultEntries.length, entries: resultEntries },
7551
+ result: buildTocRefreshResult(selectedRegion, "regenerate", resultEntries, "current"),
7267
7552
  changed: true,
7268
7553
  ...(protectionSelection ? { protectionSelection } : {}),
7269
7554
  };
7270
7555
  }
7271
7556
 
7557
+ type RuntimeTocEntry = {
7558
+ level: number;
7559
+ text: string;
7560
+ pageIndex: number;
7561
+ bookmarkName?: string;
7562
+ };
7563
+
7564
+ function selectTocRegion(
7565
+ regions: readonly TocRegion[] | undefined,
7566
+ tocId: string | undefined,
7567
+ ): TocRegion | undefined {
7568
+ if (!regions || regions.length === 0) {
7569
+ return undefined;
7570
+ }
7571
+ if (!tocId) {
7572
+ return regions[0];
7573
+ }
7574
+ return regions.find((region) => region.tocId === tocId);
7575
+ }
7576
+
7577
+ function buildTocRefreshResult(
7578
+ region: TocRegion | undefined,
7579
+ mode: "preserveCached" | "regenerate",
7580
+ entries: readonly RuntimeTocEntry[],
7581
+ status: "current" | "stale" | "missing",
7582
+ ): TocRefreshResult {
7583
+ return {
7584
+ ...(region ? { tocId: region.tocId } : {}),
7585
+ mode,
7586
+ status,
7587
+ entryCount: entries.length,
7588
+ entries: entries.map((entry) => ({
7589
+ level: entry.level,
7590
+ text: entry.text,
7591
+ pageIndex: entry.pageIndex,
7592
+ source: "generated",
7593
+ })),
7594
+ };
7595
+ }
7596
+
7597
+ function tocPageTextToPageIndex(pageText: string | undefined): number {
7598
+ const value = Number.parseInt(pageText ?? "", 10);
7599
+ return Number.isFinite(value) && value > 0 ? value - 1 : 0;
7600
+ }
7601
+
7602
+ function normalizeTocInstruction(instruction: string): string {
7603
+ return instruction.replace(/\s+/gu, " ").trim();
7604
+ }
7605
+
7606
+ function refreshTocBlockRanges(
7607
+ blocks: readonly BlockNode[],
7608
+ buildEntriesForField: (field: FieldNode) => RuntimeTocEntry[],
7609
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7610
+ selectedRegion?: TocRegion,
7611
+ ): { blocks: BlockNode[]; changed: boolean } {
7612
+ const nextBlocks: BlockNode[] = [];
7613
+ let changed = false;
7614
+
7615
+ for (let index = 0; index < blocks.length; index += 1) {
7616
+ const block = blocks[index];
7617
+
7618
+ if (block.type === "paragraph") {
7619
+ const tocField = findTocFieldNode(block);
7620
+ if (
7621
+ tocField &&
7622
+ (!selectedRegion || normalizeTocInstruction(tocField.instruction) === normalizeTocInstruction(selectedRegion.instruction.raw)) &&
7623
+ startsTocParagraphRegion(blocks, index)
7624
+ ) {
7625
+ const endIndex = findTocParagraphRegionEnd(blocks, index);
7626
+ const region = blocks.slice(index, endIndex + 1).filter(
7627
+ (candidate): candidate is ParagraphNode => candidate.type === "paragraph",
7628
+ );
7629
+ const replacement = buildTocRegionParagraphs(
7630
+ region,
7631
+ tocField,
7632
+ buildEntriesForField(tocField),
7633
+ resolveDisplayPageNumber,
7634
+ );
7635
+ if (tocRegionSignature(region) !== tocRegionSignature(replacement)) {
7636
+ changed = true;
7637
+ }
7638
+ nextBlocks.push(...replacement);
7639
+ index = endIndex;
7640
+ continue;
7641
+ }
7642
+ nextBlocks.push(block);
7643
+ continue;
7644
+ }
7645
+
7646
+ if (block.type === "sdt" || block.type === "custom_xml") {
7647
+ const refreshed = refreshTocBlockRanges(
7648
+ block.children,
7649
+ buildEntriesForField,
7650
+ resolveDisplayPageNumber,
7651
+ selectedRegion,
7652
+ );
7653
+ if (refreshed.changed) {
7654
+ changed = true;
7655
+ nextBlocks.push({ ...block, children: refreshed.blocks });
7656
+ } else {
7657
+ nextBlocks.push(block);
7658
+ }
7659
+ continue;
7660
+ }
7661
+
7662
+ if (block.type === "table") {
7663
+ let tableChanged = false;
7664
+ const rows = block.rows.map((row) => {
7665
+ let rowChanged = false;
7666
+ const cells = row.cells.map((cell) => {
7667
+ const refreshed = refreshTocBlockRanges(
7668
+ cell.children,
7669
+ buildEntriesForField,
7670
+ resolveDisplayPageNumber,
7671
+ selectedRegion,
7672
+ );
7673
+ if (!refreshed.changed) {
7674
+ return cell;
7675
+ }
7676
+ tableChanged = true;
7677
+ rowChanged = true;
7678
+ return { ...cell, children: refreshed.blocks };
7679
+ });
7680
+ return rowChanged ? { ...row, cells } : row;
7681
+ });
7682
+ nextBlocks.push(tableChanged ? { ...block, rows } : block);
7683
+ changed = changed || tableChanged;
7684
+ continue;
7685
+ }
7686
+
7687
+ nextBlocks.push(block);
7688
+ }
7689
+
7690
+ return { blocks: nextBlocks, changed };
7691
+ }
7692
+
7693
+ function startsTocParagraphRegion(blocks: readonly BlockNode[], index: number): boolean {
7694
+ const current = blocks[index];
7695
+ if (current?.type === "paragraph" && isTocParagraphStyle(current.styleId)) {
7696
+ return true;
7697
+ }
7698
+ const next = blocks[index + 1];
7699
+ return next?.type === "paragraph" && isTocParagraphStyle(next.styleId);
7700
+ }
7701
+
7702
+ function findTocParagraphRegionEnd(blocks: readonly BlockNode[], startIndex: number): number {
7703
+ let endIndex = startIndex;
7704
+ while (endIndex + 1 < blocks.length) {
7705
+ const next = blocks[endIndex + 1];
7706
+ if (next?.type !== "paragraph" || !isTocParagraphStyle(next.styleId)) {
7707
+ break;
7708
+ }
7709
+ endIndex += 1;
7710
+ }
7711
+ return endIndex;
7712
+ }
7713
+
7714
+ function isTocParagraphStyle(styleId: string | undefined): boolean {
7715
+ return /^TOC\d+$/u.test(styleId ?? "");
7716
+ }
7717
+
7718
+ function findTocFieldNode(paragraph: ParagraphNode): FieldNode | undefined {
7719
+ return paragraph.children.find(
7720
+ (child): child is FieldNode => child.type === "field" && child.fieldFamily === "TOC",
7721
+ );
7722
+ }
7723
+
7724
+ function buildTocRegionParagraphs(
7725
+ existingRegion: readonly ParagraphNode[],
7726
+ tocField: FieldNode,
7727
+ entries: readonly RuntimeTocEntry[],
7728
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7729
+ ): ParagraphNode[] {
7730
+ const firstTemplate = existingRegion[0];
7731
+ if (!firstTemplate) {
7732
+ return [];
7733
+ }
7734
+ const templateByStyle = new Map<string, ParagraphNode>();
7735
+ for (const paragraph of existingRegion) {
7736
+ if (paragraph.styleId && !templateByStyle.has(paragraph.styleId)) {
7737
+ templateByStyle.set(paragraph.styleId, paragraph);
7738
+ }
7739
+ }
7740
+ const refreshedField: FieldNode = {
7741
+ ...tocField,
7742
+ children: [],
7743
+ refreshStatus: "current",
7744
+ };
7745
+
7746
+ if (entries.length === 0) {
7747
+ return [{ ...firstTemplate, children: [refreshedField] }];
7748
+ }
7749
+
7750
+ return entries.map((entry, index) => {
7751
+ const styleId = `TOC${Math.max(1, Math.min(9, entry.level))}`;
7752
+ const template = templateByStyle.get(styleId) ?? firstTemplate;
7753
+ const children = buildTocEntryInlineNodes(entry, resolveDisplayPageNumber);
7754
+ return {
7755
+ ...template,
7756
+ styleId,
7757
+ children: index === 0 ? [refreshedField, ...children] : children,
7758
+ };
7759
+ });
7760
+ }
7761
+
7762
+ function buildTocEntryInlineNodes(
7763
+ entry: RuntimeTocEntry,
7764
+ resolveDisplayPageNumber?: (pageIndex: number) => number | null,
7765
+ ): InlineNode[] {
7766
+ const children: InlineNode[] = [];
7767
+ if (entry.bookmarkName) {
7768
+ children.push({
7769
+ type: "hyperlink",
7770
+ href: `#${entry.bookmarkName}`,
7771
+ children: [{ type: "text", text: entry.text }],
7772
+ });
7773
+ } else {
7774
+ children.push({ type: "text", text: entry.text });
7775
+ }
7776
+ children.push({ type: "tab" });
7777
+ const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
7778
+ children.push({
7779
+ type: "text",
7780
+ text: String(displayed ?? entry.pageIndex + 1),
7781
+ });
7782
+ return children;
7783
+ }
7784
+
7785
+ function tocRegionSignature(paragraphs: readonly ParagraphNode[]): string {
7786
+ return paragraphs
7787
+ .map((paragraph) => `${paragraph.styleId ?? ""}\u0001${tocInlineSignature(paragraph.children)}`)
7788
+ .join("\u0002");
7789
+ }
7790
+
7791
+ function tocInlineSignature(children: readonly InlineNode[]): string {
7792
+ return children
7793
+ .map((child) => {
7794
+ switch (child.type) {
7795
+ case "text":
7796
+ return `t:${child.text}`;
7797
+ case "tab":
7798
+ return "tab";
7799
+ case "hard_break":
7800
+ return "br";
7801
+ case "hyperlink":
7802
+ return `link:${child.href}:${tocInlineSignature(child.children)}`;
7803
+ case "field":
7804
+ return `field:${child.fieldFamily ?? ""}:${child.refreshStatus ?? ""}:${child.instruction}:${tocInlineSignature(child.children)}`;
7805
+ default:
7806
+ return child.type;
7807
+ }
7808
+ })
7809
+ .join("\u0001");
7810
+ }
7811
+
7272
7812
  function refreshBlocksWithCursor(
7273
7813
  blocks: readonly BlockNode[],
7274
7814
  visitField: (field: FieldNode, range: { from: number; to: number }) => FieldNode,