@beyondwork/docx-react-component 1.0.52 → 1.0.54

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 (103) hide show
  1. package/package.json +31 -40
  2. package/src/api/public-types.ts +67 -7
  3. package/src/io/chart-preview-resolver.ts +41 -0
  4. package/src/io/docx-session.ts +217 -23
  5. package/src/runtime/collab/checkpoint-store.ts +1 -1
  6. package/src/runtime/collab/event-types.ts +4 -0
  7. package/src/runtime/collab/runtime-collab-sync.ts +88 -8
  8. package/src/runtime/document-runtime.ts +182 -9
  9. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  10. package/src/runtime/layout/layout-engine-version.ts +97 -2
  11. package/src/runtime/layout/layout-invalidation.ts +150 -30
  12. package/src/runtime/layout/page-graph.ts +19 -0
  13. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  14. package/src/runtime/layout/project-block-fragments.ts +27 -0
  15. package/src/runtime/layout/public-facet.ts +70 -1
  16. package/src/runtime/prerender/cache-envelope.ts +30 -0
  17. package/src/runtime/prerender/customxml-cache.ts +17 -3
  18. package/src/runtime/prerender/prerender-document.ts +17 -1
  19. package/src/runtime/render/render-frame-diff.ts +38 -2
  20. package/src/runtime/render/render-kernel.ts +67 -19
  21. package/src/runtime/surface-projection.ts +28 -0
  22. package/src/runtime/table-schema.ts +27 -0
  23. package/src/runtime/table-style-resolver.ts +51 -0
  24. package/src/ui/WordReviewEditor.tsx +6 -3
  25. package/src/ui/editor-runtime-boundary.ts +39 -2
  26. package/src/ui/headless/comment-decoration-model.ts +60 -5
  27. package/src/ui/headless/revision-decoration-model.ts +94 -6
  28. package/src/ui/shared/revision-filters.ts +16 -6
  29. package/src/ui-tailwind/chart/ChartSurface.tsx +236 -0
  30. package/src/ui-tailwind/chart/layout/axis-layout.ts +17 -9
  31. package/src/ui-tailwind/chart/layout/legend-layout.ts +231 -0
  32. package/src/ui-tailwind/chart/layout/plot-area.ts +152 -59
  33. package/src/ui-tailwind/chart/layout/title-layout.ts +184 -0
  34. package/src/ui-tailwind/chart/render/area.tsx +277 -0
  35. package/src/ui-tailwind/chart/render/bar-column.tsx +356 -0
  36. package/src/ui-tailwind/chart/render/bubble.tsx +134 -0
  37. package/src/ui-tailwind/chart/render/combo.tsx +85 -0
  38. package/src/ui-tailwind/chart/render/data-labels.tsx +513 -0
  39. package/src/ui-tailwind/chart/render/font-metrics.ts +298 -0
  40. package/src/ui-tailwind/chart/render/gridlines.ts +228 -0
  41. package/src/ui-tailwind/chart/render/line.tsx +363 -0
  42. package/src/ui-tailwind/chart/render/number-format.ts +120 -16
  43. package/src/ui-tailwind/chart/render/pie.tsx +275 -0
  44. package/src/ui-tailwind/chart/render/progressive-render.ts +103 -0
  45. package/src/ui-tailwind/chart/render/scatter.tsx +228 -0
  46. package/src/ui-tailwind/chart/render/smooth-curve.ts +101 -0
  47. package/src/ui-tailwind/chart/render/svg-primitives.ts +378 -0
  48. package/src/ui-tailwind/chart/render/unsupported.tsx +126 -0
  49. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +11 -0
  50. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +44 -18
  51. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +68 -7
  52. package/src/ui-tailwind/chrome/collab-role-chip.tsx +21 -2
  53. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +20 -3
  54. package/src/ui-tailwind/chrome/tw-alert-banner.tsx +102 -37
  55. package/src/ui-tailwind/chrome/tw-command-palette.tsx +358 -0
  56. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +108 -0
  57. package/src/ui-tailwind/chrome/tw-context-menu.tsx +227 -0
  58. package/src/ui-tailwind/chrome/tw-display-mode-selector.tsx +136 -0
  59. package/src/ui-tailwind/chrome/tw-empty-state.tsx +76 -0
  60. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +30 -16
  61. package/src/ui-tailwind/chrome/tw-object-context-toolbar.tsx +23 -4
  62. package/src/ui-tailwind/chrome/tw-paste-drop-toast.tsx +113 -0
  63. package/src/ui-tailwind/chrome/tw-revision-hover-preview.tsx +150 -0
  64. package/src/ui-tailwind/chrome/tw-selection-tool-formatting.tsx +2 -0
  65. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +38 -2
  66. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +15 -3
  67. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +32 -20
  68. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +68 -0
  69. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +10 -10
  70. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +26 -5
  71. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +29 -22
  72. package/src/ui-tailwind/chrome/tw-unsaved-modal.tsx +72 -10
  73. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +33 -18
  74. package/src/ui-tailwind/chrome-overlay/tw-table-continuation-header.tsx +94 -0
  75. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +54 -0
  78. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  79. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +107 -3
  80. package/src/ui-tailwind/index.ts +11 -0
  81. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +2 -2
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +274 -0
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +15 -2
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +15 -2
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +19 -147
  86. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +83 -32
  87. package/src/ui-tailwind/review/tw-health-panel.tsx +174 -109
  88. package/src/ui-tailwind/review/tw-rail-card.tsx +9 -1
  89. package/src/ui-tailwind/review/tw-review-rail.tsx +36 -42
  90. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +189 -101
  91. package/src/ui-tailwind/review/tw-workflow-tab.tsx +11 -1
  92. package/src/ui-tailwind/status/tw-status-bar.tsx +114 -46
  93. package/src/ui-tailwind/theme/chart-palette-adapter.ts +57 -0
  94. package/src/ui-tailwind/theme/editor-theme.css +275 -46
  95. package/src/ui-tailwind/theme/tokens.css +345 -0
  96. package/src/ui-tailwind/theme/tokens.ts +313 -0
  97. package/src/ui-tailwind/theme/use-density.ts +60 -0
  98. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  99. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  100. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  101. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  102. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  103. package/src/ui-tailwind/chrome/review-queue-bar.tsx +0 -85
@@ -11,6 +11,7 @@ import type { BlockNode } from "../../model/canonical-document.ts";
11
11
  import type {
12
12
  CommandAppliedMeta,
13
13
  DocumentRuntime,
14
+ RemoteCommandEnvelope,
14
15
  Unsubscribe,
15
16
  } from "../document-runtime.ts";
16
17
  import {
@@ -21,7 +22,7 @@ import {
21
22
  type CommandEvent,
22
23
  } from "./event-types.ts";
23
24
  import { computeBaseDocFingerprint } from "./base-doc-fingerprint.ts";
24
- import type { Checkpoint } from "./checkpoint-store.ts";
25
+ import { CHECKPOINTS_KEY, type Checkpoint } from "./checkpoint-store.ts";
25
26
  import { createWorkflowShared, type WorkflowSharedHandle } from "./workflow-shared.ts";
26
27
 
27
28
  /** Shared Y.Map key — {@link SHARED_META_MAP_KEY}. */
@@ -29,7 +30,6 @@ const SHARED_META_MAP_KEY = "meta";
29
30
  const META_BASE_DOC_HASH_KEY = "baseDocHash";
30
31
  const META_SCHEMA_VERSION_KEY = "schemaVersion";
31
32
  const META_CREATED_AT_KEY = "createdAt";
32
- const CHECKPOINTS_KEY = "checkpoints";
33
33
 
34
34
  /**
35
35
  * Lifecycle + correctness events surfaced by a
@@ -200,6 +200,30 @@ export interface RuntimeCollabSyncOptions {
200
200
  * - `"observer"`: all writes refused with `collab_observer_readonly`.
201
201
  */
202
202
  role?: "author" | "reviewer" | "observer";
203
+ /**
204
+ * R1 — coalesce inbound remote-replay events into one commit per
205
+ * animation frame. When `true` (default), N events arriving within a
206
+ * single rAF tick (or `setTimeout(0)` in non-DOM contexts) drain via
207
+ * one `runtime.applyRemoteCommandBatch(...)` call → one
208
+ * `refreshRenderSnapshot` / one `notify`. Observer semantics change:
209
+ * subscribers see one `DocumentRuntimeEvent` per burst rather than
210
+ * one per event. Hosts that depend on per-event semantics (rare —
211
+ * counted-event tests, granular per-comment-add observers) opt out
212
+ * with `false`. The whole batch is atomic: any per-command effect
213
+ * collision falls back to per-event replay automatically.
214
+ *
215
+ * Defaults to `true` in both DOM and Node contexts so tests exercise
216
+ * the coalesced path without ceremony.
217
+ */
218
+ coalesceRemoteReplay?: boolean;
219
+ /**
220
+ * R1 hook point for R3 idle-priority plumbing. Invoked once per
221
+ * scheduled drain (NOT per queued event). R3's future implementation
222
+ * will use this to abort any in-flight idle prerender task on
223
+ * `src/io/load-scheduler.ts` so the upcoming commit doesn't compete
224
+ * with idle work. Safe to omit; R1 functions identically without it.
225
+ */
226
+ onRemoteReplayScheduled?: () => void;
203
227
  }
204
228
 
205
229
  export interface RuntimeCollabSyncHandle {
@@ -268,6 +292,48 @@ export function createRuntimeCollabSync(
268
292
  let readOnly = false;
269
293
  let baseDocFingerprint: string | null = null;
270
294
 
295
+ // R1 — coalesced remote-replay scheduler. Default: ON in both DOM and
296
+ // Node contexts. Falls back to setTimeout(0) when requestAnimationFrame
297
+ // is missing so tests and non-DOM hosts exercise the same drain path.
298
+ const useCoalescing = options.coalesceRemoteReplay !== false;
299
+ const replayQueue: CommandEvent[] = [];
300
+ let rafHandle: number | null = null;
301
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
302
+
303
+ function scheduleDrain(): void {
304
+ if (rafHandle !== null || timeoutHandle !== null) return;
305
+ options.onRemoteReplayScheduled?.();
306
+ if (typeof requestAnimationFrame === "function") {
307
+ rafHandle = requestAnimationFrame(() => {
308
+ rafHandle = null;
309
+ drainReplayQueue();
310
+ });
311
+ } else {
312
+ timeoutHandle = setTimeout(() => {
313
+ timeoutHandle = null;
314
+ drainReplayQueue();
315
+ }, 0);
316
+ }
317
+ }
318
+
319
+ function drainReplayQueue(): void {
320
+ if (replayQueue.length === 0) return;
321
+ const batch = replayQueue.splice(0, replayQueue.length);
322
+ const envelopes = batch.map(eventToEnvelope);
323
+ runtime.applyRemoteCommandBatch(envelopes);
324
+ }
325
+
326
+ function cancelPendingDrain(): void {
327
+ if (rafHandle !== null) {
328
+ if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(rafHandle);
329
+ rafHandle = null;
330
+ }
331
+ if (timeoutHandle !== null) {
332
+ clearTimeout(timeoutHandle);
333
+ timeoutHandle = null;
334
+ }
335
+ }
336
+
271
337
  // Events emitted before any subscriber exists are buffered and flushed
272
338
  // to the first subscriber. This lets hosts react to the attach-path
273
339
  // base-doc mismatch and schema-version mismatch for pre-existing
@@ -494,6 +560,10 @@ export function createRuntimeCollabSync(
494
560
 
495
561
  return {
496
562
  destroy() {
563
+ // R1 — drain any queued replay events synchronously before tearing
564
+ // down so callers don't lose remote events on an early unmount.
565
+ cancelPendingDrain();
566
+ drainReplayQueue();
497
567
  unsubscribeCommandApplied();
498
568
  yEvents.unobserve(onYEventsChange);
499
569
  yMeta.unobserve(checkFingerprintAgainstMeta);
@@ -607,19 +677,29 @@ export function createRuntimeCollabSync(
607
677
  return true;
608
678
  }
609
679
 
610
- function applyEventToRuntime(event: CommandEvent): void {
611
- runtime.applyRemoteCommand(
612
- event.command,
613
- {
680
+ function eventToEnvelope(event: CommandEvent): RemoteCommandEnvelope {
681
+ return {
682
+ command: event.command,
683
+ context: {
614
684
  timestamp: event.timestamp,
615
685
  documentMode: event.context.documentMode,
616
686
  defaultAuthorId: event.context.defaultAuthorId ?? event.authorId,
617
687
  },
618
- {
688
+ meta: {
619
689
  preSelection: event.context.preSelection,
620
690
  activeStory: event.context.activeStory,
621
691
  },
622
- );
692
+ };
693
+ }
694
+
695
+ function applyEventToRuntime(event: CommandEvent): void {
696
+ if (useCoalescing) {
697
+ replayQueue.push(event);
698
+ scheduleDrain();
699
+ return;
700
+ }
701
+ const env = eventToEnvelope(event);
702
+ runtime.applyRemoteCommand(env.command, env.context, env.meta);
623
703
  }
624
704
  }
625
705
 
@@ -160,6 +160,7 @@ import {
160
160
  createDocumentSectionSnapshots,
161
161
  createSectionLocations,
162
162
  createTocSnapshot,
163
+ findBookmarkNameForOffset,
163
164
  findDocumentSectionSnapshot,
164
165
  } from "./document-outline.ts";
165
166
  import {
@@ -209,6 +210,7 @@ import type {
209
210
  BlockNode,
210
211
  FieldNode,
211
212
  FieldRefreshStatus,
213
+ HyperlinkNode,
212
214
  InlineNode,
213
215
  PageMargins,
214
216
  ParagraphNode,
@@ -298,6 +300,27 @@ export interface DocumentRuntime {
298
300
  context: CommandExecutionContext,
299
301
  meta?: Partial<CommandAppliedMeta>,
300
302
  ): void;
303
+ /**
304
+ * R1 — coalesced remote-replay entry point. Applies N envelopes as ONE
305
+ * commit: per-command transactions are chained (`replayState = txN.nextState`),
306
+ * mapping steps concatenated, `markDirty` ORed, and a single `commitRemote`
307
+ * call drives `finalizeState` / `invalidate` / `refreshRenderSnapshot` /
308
+ * `notify` exactly once. Per-command `meta.activeStory` isolation is
309
+ * preserved (the remote story scopes its own replay; the local cursor
310
+ * stays put). Behavior matches `applyRemoteCommand` invoked N times for
311
+ * a single envelope; for empty arrays it's a no-op.
312
+ *
313
+ * Singular effect collisions (≥2 envelopes both emitting `commentAdded` /
314
+ * `changeAccepted` / etc.) fall back to per-envelope replay so observers
315
+ * still see every effect — typical text-burst replay (all warnings-only
316
+ * effects) takes the fast path.
317
+ *
318
+ * History commands (`history.undo` / `history.redo`) and runtime overlay
319
+ * commands (`workflow.set-overlay`, etc.) inside a batch are processed
320
+ * individually as in the per-event path; they don't participate in the
321
+ * chained commit.
322
+ */
323
+ applyRemoteCommandBatch(envelopes: ReadonlyArray<RemoteCommandEnvelope>): void;
301
324
  emitBlockedCommand(command: string, reasons: WorkflowBlockedCommandReason[]): void;
302
325
  undo(): void;
303
326
  redo(): void;
@@ -433,6 +456,17 @@ export interface CommandAppliedMeta {
433
456
  priorDocument: CanonicalDocumentEnvelope;
434
457
  }
435
458
 
459
+ /**
460
+ * Single envelope shape consumed by {@link DocumentRuntime.applyRemoteCommandBatch}.
461
+ * Mirrors the `applyRemoteCommand(command, context, meta)` signature so
462
+ * batch and per-event paths stay schema-compatible.
463
+ */
464
+ export interface RemoteCommandEnvelope {
465
+ command: EditorCommand;
466
+ context: CommandExecutionContext;
467
+ meta?: Partial<CommandAppliedMeta>;
468
+ }
469
+
436
470
  export interface CreateDocumentRuntimeOptions {
437
471
  documentId: string;
438
472
  initialSessionState?: EditorSessionState;
@@ -2264,6 +2298,133 @@ export function createDocumentRuntime(
2264
2298
  emitError(toRuntimeError(error));
2265
2299
  }
2266
2300
  },
2301
+ applyRemoteCommandBatch(envelopes) {
2302
+ if (envelopes.length === 0) return;
2303
+ if (envelopes.length === 1) {
2304
+ const e = envelopes[0]!;
2305
+ this.applyRemoteCommand(e.command, e.context, e.meta);
2306
+ return;
2307
+ }
2308
+
2309
+ try {
2310
+ // Chain per-command transactions before ONE commitRemote at the end.
2311
+ // Each command runs through executeEditorCommand on the replayed
2312
+ // state; resulting nextState becomes the input to the next command.
2313
+ // History/overlay commands are processed individually as in the
2314
+ // per-event path (they don't participate in the chained commit).
2315
+ let replayState: typeof state = state;
2316
+ const stepsAccumulator: import("../core/selection/mapping.ts").MappingStep[] = [];
2317
+ let anyDirty = false;
2318
+ let lastNextState: typeof state | null = null;
2319
+ let lastCrossStoryReplay = false;
2320
+ const warningsAdded: import("../core/state/editor-state.ts").EditorWarning[] = [];
2321
+ const warningsCleared: Array<{ warningId: string; code: import("../core/state/editor-state.ts").EditorWarning["code"] }> = [];
2322
+ const SINGULAR_EFFECT_KEYS = [
2323
+ "commentAdded",
2324
+ "commentResolved",
2325
+ "commentReopened",
2326
+ "commentReplyAdded",
2327
+ "commentBodyEdited",
2328
+ "changeAccepted",
2329
+ "changeRejected",
2330
+ "revisionAuthored",
2331
+ "commandBlocked",
2332
+ ] as const;
2333
+ const singularEffectsCounts = new Map<string, number>();
2334
+ const aggregatedSingular: Record<string, unknown> = {};
2335
+
2336
+ for (const env of envelopes) {
2337
+ const { command, context, meta } = env;
2338
+ if (command.type === "history.undo" || command.type === "history.redo") {
2339
+ // Match per-event path: silently skipped on remote replay.
2340
+ continue;
2341
+ }
2342
+ if (isRuntimeStateOverlayCommand(command)) {
2343
+ // Overlays bypass commitRemote in the per-event path; mirror that
2344
+ // here without disturbing the chained replayState.
2345
+ applyRuntimeStateOverlayCommand(command);
2346
+ continue;
2347
+ }
2348
+ const replayStory = meta?.activeStory ?? activeStory;
2349
+ const crossStoryReplay = Boolean(
2350
+ meta?.activeStory && !storyTargetsEqual(meta.activeStory, activeStory),
2351
+ );
2352
+ let stateForCommand: typeof state;
2353
+ let snapshotForCommand: typeof cachedRenderSnapshot;
2354
+ if (meta?.preSelection) {
2355
+ // Reuse the R5 scratch (avoids per-command allocation across the
2356
+ // burst). Seed from the CURRENT chain state so each command sees
2357
+ // the prior command's nextState as its starting point.
2358
+ Object.assign(r5ScratchReplayState, replayState);
2359
+ r5ScratchReplayState.selection = meta.preSelection;
2360
+ Object.assign(r5ScratchReplaySnapshot, cachedRenderSnapshot);
2361
+ r5ScratchReplaySnapshot.selection = toPublicSelectionSnapshot(meta.preSelection, replayStory);
2362
+ stateForCommand = r5ScratchReplayState;
2363
+ snapshotForCommand = r5ScratchReplaySnapshot;
2364
+ } else {
2365
+ stateForCommand = replayState;
2366
+ snapshotForCommand = cachedRenderSnapshot;
2367
+ }
2368
+ const replayContext = {
2369
+ ...context,
2370
+ renderSnapshot: snapshotForCommand,
2371
+ };
2372
+ const transaction = executeEditorCommand(stateForCommand, command, replayContext);
2373
+ replayState = transaction.nextState;
2374
+ stepsAccumulator.push(...transaction.mapping.steps);
2375
+ anyDirty = anyDirty || transaction.markDirty;
2376
+ warningsAdded.push(...transaction.effects.warningsAdded);
2377
+ warningsCleared.push(...transaction.effects.warningsCleared);
2378
+ for (const key of SINGULAR_EFFECT_KEYS) {
2379
+ const v = (transaction.effects as unknown as Record<string, unknown>)[key];
2380
+ if (v !== undefined) {
2381
+ singularEffectsCounts.set(key, (singularEffectsCounts.get(key) ?? 0) + 1);
2382
+ aggregatedSingular[key] = v;
2383
+ }
2384
+ }
2385
+ lastNextState = transaction.nextState;
2386
+ lastCrossStoryReplay = crossStoryReplay;
2387
+ }
2388
+
2389
+ // Singular-effect collision (≥2 envelopes both setting the same
2390
+ // singular field): fall back to per-envelope replay so observers
2391
+ // see every effect. Common burst (all text.insert with empty
2392
+ // effects) takes the fast path above.
2393
+ let singularCollision = false;
2394
+ for (const count of singularEffectsCounts.values()) {
2395
+ if (count > 1) {
2396
+ singularCollision = true;
2397
+ break;
2398
+ }
2399
+ }
2400
+ if (singularCollision) {
2401
+ for (const env of envelopes) {
2402
+ this.applyRemoteCommand(env.command, env.context, env.meta);
2403
+ }
2404
+ return;
2405
+ }
2406
+ if (lastNextState === null) {
2407
+ // All envelopes were skip/overlay — nothing to commit.
2408
+ return;
2409
+ }
2410
+ const consolidated = {
2411
+ nextState: lastCrossStoryReplay
2412
+ ? { ...lastNextState, selection: state.selection }
2413
+ : lastNextState,
2414
+ mapping: { steps: stepsAccumulator },
2415
+ effects: {
2416
+ warningsAdded,
2417
+ warningsCleared,
2418
+ ...aggregatedSingular,
2419
+ } as import("../core/commands/index.ts").TransactionEffects,
2420
+ historyBoundary: "skip" as const,
2421
+ markDirty: anyDirty,
2422
+ };
2423
+ commitRemote(consolidated);
2424
+ } catch (error) {
2425
+ emitError(toRuntimeError(error));
2426
+ }
2427
+ },
2267
2428
  undo() {
2268
2429
  this.dispatch({
2269
2430
  type: "history.undo",
@@ -5038,7 +5199,7 @@ function refreshDocumentTableOfContents(
5038
5199
  } {
5039
5200
  const navigation = createDocumentNavigationSnapshot(document, selectionHead, activeStory);
5040
5201
  let changed = false;
5041
- let resultEntries: Array<{ level: number; text: string; pageIndex: number }> = [];
5202
+ let resultEntries: Array<{ level: number; text: string; pageIndex: number; bookmarkName?: string }> = [];
5042
5203
  let changedFrom: number | undefined;
5043
5204
  let changedTo: number | undefined;
5044
5205
  const nextChildren = refreshBlocksWithCursor(document.content.children, (field, range) => {
@@ -5050,11 +5211,15 @@ function refreshDocumentTableOfContents(
5050
5211
  : parseTocLevelRange(field.instruction);
5051
5212
  const entries = navigation.headings
5052
5213
  .filter((heading) => heading.level >= levelRange.from && heading.level <= levelRange.to)
5053
- .map((heading) => ({
5054
- level: heading.level,
5055
- text: heading.text,
5056
- pageIndex: heading.pageIndex,
5057
- }));
5214
+ .map((heading) => {
5215
+ const bookmarkName = findBookmarkNameForOffset(document, heading.offset);
5216
+ return {
5217
+ level: heading.level,
5218
+ text: heading.text,
5219
+ pageIndex: heading.pageIndex,
5220
+ ...(bookmarkName ? { bookmarkName } : {}),
5221
+ };
5222
+ });
5058
5223
  if (resultEntries.length === 0) {
5059
5224
  resultEntries = entries;
5060
5225
  }
@@ -5251,12 +5416,20 @@ function buildInlineNodesFromDisplayText(text: string): InlineNode[] {
5251
5416
  * resolver, falls back to the raw `pageIndex + 1` (pre-P5 behavior).
5252
5417
  */
5253
5418
  function buildTocInlineNodes(
5254
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
5419
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
5255
5420
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
5256
5421
  ): InlineNode[] {
5257
5422
  const children: InlineNode[] = [];
5258
5423
  entries.forEach((entry, index) => {
5259
- children.push({ type: "text", text: entry.text });
5424
+ if (entry.bookmarkName) {
5425
+ children.push({
5426
+ type: "hyperlink",
5427
+ href: `#${entry.bookmarkName}`,
5428
+ children: [{ type: "text", text: entry.text }],
5429
+ } as HyperlinkNode);
5430
+ } else {
5431
+ children.push({ type: "text", text: entry.text });
5432
+ }
5260
5433
  children.push({ type: "tab" });
5261
5434
  const displayed = resolveDisplayPageNumber?.(entry.pageIndex);
5262
5435
  children.push({
@@ -5272,7 +5445,7 @@ function buildTocInlineNodes(
5272
5445
 
5273
5446
  /** Test-only export of `buildTocInlineNodes` (P5 unit tests). */
5274
5447
  export function __buildTocInlineNodes(
5275
- entries: ReadonlyArray<{ level: number; text: string; pageIndex: number }>,
5448
+ entries: ReadonlyArray<{ level: number; text: string; pageIndex: number; bookmarkName?: string }>,
5276
5449
  resolveDisplayPageNumber?: (pageIndex: number) => number | null,
5277
5450
  ): InlineNode[] {
5278
5451
  return buildTocInlineNodes(entries, resolveDisplayPageNumber);
@@ -59,6 +59,7 @@ export function createInertLayoutFacet(): WordReviewEditorLayoutFacet {
59
59
  swapMeasurementProvider: () => undefined,
60
60
  invalidateMeasurementCache: () => undefined,
61
61
  getTableRenderPlan: () => null,
62
+ getTableBodyYOffsetOnPage: () => null,
62
63
  getDirtyFieldFamilies: () => [],
63
64
  getFieldDirtinessReport: () => emptyReport,
64
65
  setVisibleBlockRange: () => undefined,
@@ -93,8 +93,94 @@
93
93
  * pixel-geometry change; cache envelopes from v9 invalidate
94
94
  * because page-node identity semantics on bounded splices
95
95
  * changed.
96
+ * 11 — Lane 3a P10 Phase B2. `toPublicPageNode` memoizes its
97
+ * `RuntimePageNode → PublicPageNode` projection via WeakMap so
98
+ * Phase D1's stable runtime-node references cascade into stable
99
+ * public-facet return values. `facet.getPage(pageIndex)` now
100
+ * returns reference-equal `PublicPageNode`s across bounded
101
+ * relayouts where the underlying runtime node was reused.
102
+ * Cascades into downstream consumers (React.memo-gated page
103
+ * subtrees, per-page test hooks) and closes the Phase B → D1
104
+ * stable-reference chain. No pixel-geometry change; cache
105
+ * envelopes from v10 invalidate because the public-facet
106
+ * contract changed from "fresh object per call" to "stable ref
107
+ * per underlying runtime node".
108
+ * 12 — Lane 3a R3 table row-split: `getTableRenderPlan` now derives
109
+ * `isContinuationPage` from the page graph's fragment slice
110
+ * metadata (`kind === "table-slice" && tableRowRange.from > 0`)
111
+ * and passes it to `buildTableRenderPlan`. Tables split across
112
+ * pages now carry non-empty `repeatedHeaderRows` on continuation
113
+ * pages so chrome can prepend header rows visually. No
114
+ * pixel-geometry change; cache envelopes from v11 invalidate
115
+ * because the table-render-plan contract changed.
116
+ * 13 — Lane 6d.U2 canvas-seam pill polish: the canvas-posture page-break
117
+ * widget's "N / M" badge is promoted from transparent text over the
118
+ * dotted seam to a true pill with `--radius-pill` geometry, hairline
119
+ * `--color-border-default` border, and `--shadow-soft`. Widget DOM
120
+ * shape changed (new `data-variant="pill"` attribute; additional
121
+ * inline style declarations on the badge). Cache envelopes from v12
122
+ * invalidate because the decoration's cacheable DOM shape changed.
123
+ * 13 — Lane 3a P14.c: render-kernel gains a single-slot `DecorationIndex`
124
+ * cache keyed on (revision, activeStory.kind, zoom.pxPerTwip, and
125
+ * reference equality on each decoration source). When layout
126
+ * changes but decoration sources are unchanged, `resolveDecorationIndex`
127
+ * is skipped and the prior result is reused. The frame-level cache
128
+ * already handles repeat reads; this slot targets the post-invalidate
129
+ * rebuild path (on every keystroke that triggers a layout event).
130
+ * No pixel-geometry change; cache envelopes from v12 invalidate
131
+ * because the render-kernel source changed.
132
+ * 14 — Lane 3a Slice 5: `RuntimeBlockFragment` gains `resolvedStyleChainRef`
133
+ * (block's styleId) and `numberingInstanceId` (block's list-instance id).
134
+ * `analyzeInvalidation` for `styles-change` (when `dirtyStyleIds` is
135
+ * supplied) and `numbering-change` (when `numberingInstanceId` is
136
+ * supplied) now return `scope: "bounded"` starting from the first page
137
+ * whose fragments reference the dirty style / instance. Fallback to
138
+ * `scope: "full"` when payload is absent or no match found. No
139
+ * pixel-geometry change; cache envelopes from v13 invalidate because
140
+ * the fragment shape and invalidation-scope contract changed.
141
+ * 15 — Bug fixes: `pageNodesStructurallyEqual` now compares
142
+ * `lineBoxes.length` and `noteAllocations.length` as structural
143
+ * proxies to prevent stale-node reuse when line geometry changes
144
+ * with stable fragment IDs (L1). `analyzeSectionChange` normalizes
145
+ * `dirtySectionRange` to guarantee from ≤ to for all graph states
146
+ * including empty-sections fallback (L2).
147
+ * 16 — Bug fixes: `diffRenderFrames` now flags pages whose physical frame
148
+ * changed (but block regions are stable) with `pageFrameChanged: true`
149
+ * in `changedPages` so consumers can re-project without a block-region
150
+ * signal (R1). Chrome reservation changes (`railLaneTwips`,
151
+ * `balloonLaneTwips`, `footnoteAreaTwips`, `pageFrameWidthPx`,
152
+ * `pageFrameHeightPx`) now trigger `changedPages` so overlay
153
+ * re-projection is not silently skipped (R2).
154
+ * 17 — Lane 3a Slice 2 + R4: `WordReviewEditorLayoutFacet` gains
155
+ * `getTableBodyYOffsetOnPage(blockId, pageIndex)` which returns the
156
+ * Y offset (in twips from body top) of the table's first fragment on
157
+ * a given page by summing prior body-fragment heights. Used by the
158
+ * new `TwTableContinuationHeader` chrome overlay to position repeated
159
+ * header rows on continuation pages of multi-page tables — no DOM
160
+ * measurement, layout-engine fragment heights only. No cached-geometry
161
+ * change; cache envelopes from v16 invalidate because the facet
162
+ * interface changed.
163
+ * 18 — Lane 3a Slice 6: `buildPageStackFromWithSplits` no longer discards
164
+ * `resumeAt.startOffset`. When `startOffset > 0` and no block
165
+ * straddles the dirty section boundary, only sections at and after
166
+ * the first dirty section are paginated; the resulting page indices
167
+ * are shifted by `startPageIndex` so they align with the global graph.
168
+ * Full-paginate + tail-slice fallback used when a block straddles the
169
+ * section boundary (safety guard). This eliminates re-paginating
170
+ * settled head sections on every bounded-invalidation relayout.
171
+ * No pixel-geometry change; cache envelopes from v17 invalidate
172
+ * because `buildPageStackFromWithSplits` output contract changed.
173
+ * 19 — Slice 5 bug-fix: `analyzeNumberingChange` now honors its own
174
+ * "Fallback to full rebuild when absent or no match" contract. When
175
+ * `numberingInstanceId` is supplied but no materialized fragment
176
+ * matches it, the analyzer returns `scope: "full"` +
177
+ * `requiresFullRecompute: true` instead of the prior "bounded over
178
+ * full range" shortcut, which bypassed the safety guard and could
179
+ * leak stale field-family projections. No pixel-geometry change;
180
+ * cache envelopes from v18 invalidate because the invalidation
181
+ * classifier's contract corrected.
96
182
  */
97
- export const LAYOUT_ENGINE_VERSION = 10 as const;
183
+ export const LAYOUT_ENGINE_VERSION = 19 as const;
98
184
 
99
185
  /**
100
186
  * Serialization schema version for the LayCache payload (the cache envelope
@@ -113,5 +199,14 @@ export const LAYOUT_ENGINE_VERSION = 10 as const;
113
199
  * correctly invalidates. v1 envelopes are rejected on load under v2 —
114
200
  * no corruption path exists because schemaVersion is the top-level
115
201
  * discriminator.
202
+ * 3 — L7 Phase 2 Finale C3: adds optional `compatibilityReport` field so
203
+ * the warm-Plan-B short-circuit can skip `buildCompatibilityReport`
204
+ * (~60–100 ms on extra-large). `generatedAt` inside the cached report
205
+ * is pinned to a fixed sentinel (`"__cache_normalized__"`) at
206
+ * prerender write time so two sequential prerenders produce
207
+ * byte-identical envelopes. The field is optional — v3 envelopes
208
+ * written without the report are still valid; readers fall through
209
+ * to the live `buildCompatibilityReport` call. v2 envelopes are
210
+ * rejected cleanly on v3 readers (schemaVersion mismatch).
116
211
  */
117
- export const LAYCACHE_SCHEMA_VERSION = 2 as const;
212
+ export const LAYCACHE_SCHEMA_VERSION = 3 as const;