@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
@@ -13,19 +13,21 @@
13
13
  * edit offsets (§analyzeContentEdit).
14
14
  * - `section-change`: bounded to the first page of the affected section
15
15
  * onward — P10 Phase C (§analyzeSectionChange).
16
- * - `numbering-change`: bounded to the whole document (dirtyPageRange
17
- * spans pages [0..end]); tightening to the earliest page referencing
18
- * the numbering instance requires per-fragment numbering metadata
19
- * on `RuntimeBlockFragment` which is not available today.
16
+ * - `styles-change` (Slice 5): when `dirtyStyleIds` is supplied, bounded
17
+ * to the first page whose fragments carry a matching `resolvedStyleChainRef`.
18
+ * Fallback to full rebuild when the payload is absent or no match found.
19
+ * - `numbering-change` (Slice 5): when `numberingInstanceId` is supplied,
20
+ * bounded to the first page whose fragments carry a matching
21
+ * `numberingInstanceId`. Fallback to full rebuild when absent or no match.
20
22
  *
21
23
  * Remaining full-rebuild triggers:
22
- * - `styles-change` / `theme-change`: without per-fragment style-chain
23
- * metadata on the graph, we cannot safely tighten these. Flagged as
24
- * follow-up for when `RuntimeBlockFragment.resolvedStyleChain` lands.
24
+ * - `theme-change`: theme token changes cascade through the entire
25
+ * style chain; no fragment-level tightening possible without knowing
26
+ * which blocks reference theme tokens.
25
27
  */
26
28
 
27
29
  import type { LayoutInvalidationReason } from "./paginated-layout-engine.ts";
28
- import type { RuntimePageGraph, RuntimePageNode } from "./page-graph.ts";
30
+ import type { RuntimeBlockFragment, RuntimePageGraph } from "./page-graph.ts";
29
31
  import type { ResolvedDocumentSection } from "../document-layout.ts";
30
32
 
31
33
  // ---------------------------------------------------------------------------
@@ -92,15 +94,16 @@ export function analyzeInvalidation(
92
94
 
93
95
  switch (reason.kind) {
94
96
  case "full":
95
- case "styles-change":
96
97
  case "theme-change":
97
- // These affect the entire document
98
98
  return {
99
99
  scope: "full",
100
100
  requiresFullRecompute: true,
101
101
  dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
102
102
  };
103
103
 
104
+ case "styles-change":
105
+ return analyzeStylesChange(reason, graph);
106
+
104
107
  case "content-edit":
105
108
  return analyzeContentEdit(reason, graph);
106
109
 
@@ -108,26 +111,9 @@ export function analyzeInvalidation(
108
111
  return analyzeSectionChange(reason, graph);
109
112
 
110
113
  case "numbering-change":
111
- if (!reason.numberingInstanceId) {
112
- return {
113
- scope: "full",
114
- requiresFullRecompute: true,
115
- dirtyFieldFamilies: [],
116
- };
117
- }
118
- return {
119
- scope: "bounded",
120
- requiresFullRecompute: false,
121
- dirtyPageRange: {
122
- firstPageIndex: 0,
123
- lastPageIndex: Math.max(0, graph.pages.length - 1),
124
- },
125
- dirtySectionRange: null,
126
- dirtyFieldFamilies: [],
127
- };
114
+ return analyzeNumberingChange(reason, graph);
128
115
 
129
116
  case "field-refresh":
130
- // Field refresh doesn't change layout, just field display values
131
117
  return {
132
118
  scope: "field-only",
133
119
  requiresFullRecompute: false,
@@ -277,12 +263,18 @@ function analyzeSectionChange(
277
263
  reason.sectionIndex,
278
264
  );
279
265
  if (firstPageOfSection < 0) {
266
+ // L2: Clamp `from` so the range is never backward when the affected
267
+ // section index exceeds the number of materialized sections (e.g.
268
+ // section-change on a section that hasn't rendered yet, or an empty
269
+ // graph). Without the clamp, {from:5, to:0} is a backward range that
270
+ // confuses consumers iterating [from..to].
271
+ const lastSectionIdx = Math.max(0, graph.sections.length - 1);
280
272
  return {
281
273
  scope: "full",
282
274
  requiresFullRecompute: true,
283
275
  dirtySectionRange: {
284
- from: reason.sectionIndex,
285
- to: Math.max(0, graph.sections.length - 1),
276
+ from: Math.min(reason.sectionIndex, lastSectionIdx),
277
+ to: lastSectionIdx,
286
278
  },
287
279
  dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
288
280
  };
@@ -321,3 +313,131 @@ function findFirstPageIndexForSection(
321
313
  // rendered tail. Caller treats this as a full-rebuild trigger.
322
314
  return -1;
323
315
  }
316
+
317
+ /**
318
+ * Slice 5 — narrow `styles-change` invalidation.
319
+ *
320
+ * When `reason.dirtyStyleIds` is present, scan fragments for the first
321
+ * fragment whose `resolvedStyleChainRef` matches any dirty style id.
322
+ * The page containing that fragment is the first that must rebuild.
323
+ *
324
+ * Fallback to full rebuild when `dirtyStyleIds` is absent, empty, or no
325
+ * fragment matches (e.g. graphs built before Slice 5 shipped).
326
+ */
327
+ function analyzeStylesChange(
328
+ reason: Extract<LayoutInvalidationReason, { kind: "styles-change" }>,
329
+ graph: RuntimePageGraph,
330
+ ): InvalidationResult {
331
+ if (!reason.dirtyStyleIds || reason.dirtyStyleIds.length === 0) {
332
+ return {
333
+ scope: "full",
334
+ requiresFullRecompute: true,
335
+ dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
336
+ };
337
+ }
338
+
339
+ const dirtySet = new Set(reason.dirtyStyleIds);
340
+ const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
341
+ graph,
342
+ (f) =>
343
+ f.resolvedStyleChainRef !== undefined &&
344
+ dirtySet.has(f.resolvedStyleChainRef),
345
+ );
346
+
347
+ if (firstDirtyPageIndex < 0) {
348
+ return {
349
+ scope: "full",
350
+ requiresFullRecompute: true,
351
+ dirtyFieldFamilies: ["PAGE", "NUMPAGES", "SECTIONPAGES"],
352
+ };
353
+ }
354
+
355
+ return {
356
+ scope: "bounded",
357
+ requiresFullRecompute: false,
358
+ dirtyPageRange: {
359
+ firstPageIndex: firstDirtyPageIndex,
360
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
361
+ },
362
+ dirtySectionRange: null,
363
+ dirtyFieldFamilies: [],
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Slice 5 — narrow `numbering-change` invalidation.
369
+ *
370
+ * When `reason.numberingInstanceId` is present, find the first page
371
+ * containing a fragment with matching `numberingInstanceId` and start
372
+ * the rebuild there. Fallback to full rebuild when absent or no match.
373
+ */
374
+ function analyzeNumberingChange(
375
+ reason: Extract<LayoutInvalidationReason, { kind: "numbering-change" }>,
376
+ graph: RuntimePageGraph,
377
+ ): InvalidationResult {
378
+ if (!reason.numberingInstanceId) {
379
+ return {
380
+ scope: "full",
381
+ requiresFullRecompute: true,
382
+ dirtyFieldFamilies: [],
383
+ };
384
+ }
385
+
386
+ const instanceId = reason.numberingInstanceId;
387
+ const firstDirtyPageIndex = findFirstPageIndexByFragmentPredicate(
388
+ graph,
389
+ (f) => f.numberingInstanceId === instanceId,
390
+ );
391
+
392
+ if (firstDirtyPageIndex < 0) {
393
+ // No fragment matches (e.g. graph has no fragments yet, or the
394
+ // numberingInstanceId references an instance that hasn't materialized on
395
+ // any page). Per the function's contract — "Fallback to full rebuild
396
+ // when absent or no match" — return a full rebuild so the caller
397
+ // re-paginates from scratch rather than a bounded pass over stale
398
+ // geometry.
399
+ return {
400
+ scope: "full",
401
+ requiresFullRecompute: true,
402
+ dirtyFieldFamilies: [],
403
+ };
404
+ }
405
+
406
+ return {
407
+ scope: "bounded",
408
+ requiresFullRecompute: false,
409
+ dirtyPageRange: {
410
+ firstPageIndex: firstDirtyPageIndex,
411
+ lastPageIndex: Math.max(0, graph.pages.length - 1),
412
+ },
413
+ dirtySectionRange: null,
414
+ dirtyFieldFamilies: [],
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Find the pageIndex of the first non-filler page whose fragments include
420
+ * at least one fragment satisfying `predicate`. Returns -1 if none found.
421
+ */
422
+ function findFirstPageIndexByFragmentPredicate(
423
+ graph: RuntimePageGraph,
424
+ predicate: (f: RuntimeBlockFragment) => boolean,
425
+ ): number {
426
+ const pageIdToIndex = new Map<string, number>();
427
+ for (const page of graph.pages) {
428
+ if (!page.isBlankFiller) {
429
+ pageIdToIndex.set(page.pageId, page.pageIndex);
430
+ }
431
+ }
432
+
433
+ let firstDirtyPageIndex = -1;
434
+ for (const fragment of graph.fragments) {
435
+ if (!predicate(fragment)) continue;
436
+ const pageIndex = pageIdToIndex.get(fragment.pageId);
437
+ if (pageIndex === undefined) continue;
438
+ if (firstDirtyPageIndex < 0 || pageIndex < firstDirtyPageIndex) {
439
+ firstDirtyPageIndex = pageIndex;
440
+ }
441
+ }
442
+ return firstDirtyPageIndex;
443
+ }
@@ -149,6 +149,18 @@ export interface RuntimeBlockFragment {
149
149
  to: number;
150
150
  totalRows: number;
151
151
  };
152
+ /**
153
+ * Slice 5 — opaque style-chain ref derived from the block's `styleId`.
154
+ * Used by `analyzeStylesChange` to bound invalidation to the first page
155
+ * that carries a fragment referencing a dirty style.
156
+ */
157
+ resolvedStyleChainRef?: string;
158
+ /**
159
+ * Slice 5 — numbering instance id copied from the block's `numbering`
160
+ * field. Used by `analyzeNumberingChange` to bound invalidation to the
161
+ * first page that carries a fragment from the affected list instance.
162
+ */
163
+ numberingInstanceId?: string;
152
164
  }
153
165
 
154
166
  export interface RuntimeLineBox {
@@ -629,6 +641,13 @@ function pageNodesStructurallyEqual(
629
641
  for (let i = 0; i < aFoot.length; i += 1) {
630
642
  if (!regionFragmentsEqual(aFoot[i]!, bFoot[i]!)) return false;
631
643
  }
644
+ // L1: Include line-box and note-allocation counts as structural proxies.
645
+ // Fragment IDs staying stable while line geometry changes (font-metric
646
+ // change, float-wrap) would allow stale node reuse without these guards.
647
+ // Length-only is O(1) and catches the common case; deep baseline
648
+ // comparison is deferred.
649
+ if (a.lineBoxes.length !== b.lineBoxes.length) return false;
650
+ if (a.noteAllocations.length !== b.noteAllocations.length) return false;
632
651
  return true;
633
652
  }
634
653
 
@@ -81,7 +81,7 @@ import {
81
81
  export type LayoutInvalidationReason =
82
82
  | { kind: "content-edit"; from: number; to: number }
83
83
  | { kind: "section-change"; sectionIndex: number }
84
- | { kind: "styles-change" }
84
+ | { kind: "styles-change"; dirtyStyleIds?: readonly string[] }
85
85
  | { kind: "theme-change" }
86
86
  | { kind: "numbering-change"; numberingInstanceId?: string }
87
87
  | { kind: "field-refresh"; family?: string }
@@ -416,28 +416,137 @@ export function buildPageStackFromWithSplits(
416
416
  resumeAt: { startPageIndex: number; startOffset: number },
417
417
  measurementProvider?: LayoutMeasurementProvider,
418
418
  ): PageStackResultWithSplits {
419
- void resumeAt.startOffset;
420
- const full = buildPageStackWithSplits(
421
- document,
422
- sections as ResolvedDocumentSection[],
423
- mainSurface,
424
- measurementProvider,
419
+ const startOffset = resumeAt.startOffset;
420
+ const dirtyPageNumberOffset = Math.max(0, resumeAt.startPageIndex);
421
+
422
+ // Fast path: offset at document start → full paginate is identical.
423
+ if (startOffset <= 0) {
424
+ return buildPageStackWithSplits(
425
+ document,
426
+ sections as ResolvedDocumentSection[],
427
+ mainSurface,
428
+ measurementProvider,
429
+ );
430
+ }
431
+
432
+ // Find the first section that contains or starts after startOffset.
433
+ const firstDirtySectionIdx = (sections as ResolvedDocumentSection[]).findIndex(
434
+ (s) => s.end > startOffset,
425
435
  );
426
- const startIndex = Math.max(0, resumeAt.startPageIndex);
427
- const tailPages = full.pages.slice(startIndex);
428
- const tailSplits = new Map<string, ParagraphLineSlice[]>();
429
- for (const [blockId, slices] of full.splits.byBlockId) {
430
- const tail = slices.filter((s) => s.pageIndex >= startIndex);
431
- if (tail.length > 0) tailSplits.set(blockId, tail);
436
+ if (firstDirtySectionIdx < 0) {
437
+ // All sections end before the dirty offset — nothing to re-paginate.
438
+ return buildPageStackWithSplits(
439
+ document,
440
+ sections as ResolvedDocumentSection[],
441
+ mainSurface,
442
+ measurementProvider,
443
+ );
432
444
  }
433
- const tailTableSplits = new Map<string, TableRowSlice[]>();
434
- for (const [blockId, slices] of full.splits.tablesByBlockId) {
435
- const tail = slices.filter((s) => s.pageIndex >= startIndex);
436
- if (tail.length > 0) tailTableSplits.set(blockId, tail);
445
+
446
+ // Safety guard: if any block straddles the section boundary (to > startOffset
447
+ // but from < section.start), we cannot safely skip without reconstructing
448
+ // carry-over state fall back to full paginate to avoid silent correctness loss.
449
+ const dirtySectionStart = (sections as ResolvedDocumentSection[])[firstDirtySectionIdx]!.start;
450
+ const straddling = mainSurface.blocks.some(
451
+ (b) => b.from < dirtySectionStart && b.to > startOffset,
452
+ );
453
+ // When the dirty section is section 0, there is nothing to skip — fall
454
+ // back to the full-build + tail-slice path so page startOffsets are
455
+ // correct (the section-skip path shifts only pageIndex, not startOffset,
456
+ // which would produce phantom pages when spliceGraph prepends page 0).
457
+ if (straddling || firstDirtySectionIdx === 0) {
458
+ const full = buildPageStackWithSplits(
459
+ document,
460
+ sections as ResolvedDocumentSection[],
461
+ mainSurface,
462
+ measurementProvider,
463
+ );
464
+ const tailPages = full.pages.slice(dirtyPageNumberOffset);
465
+ const tailSplits = new Map<string, ParagraphLineSlice[]>();
466
+ for (const [blockId, slices] of full.splits.byBlockId) {
467
+ const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
468
+ if (tail.length > 0) tailSplits.set(blockId, tail);
469
+ }
470
+ const tailTableSplits = new Map<string, TableRowSlice[]>();
471
+ for (const [blockId, slices] of full.splits.tablesByBlockId) {
472
+ const tail = slices.filter((s) => s.pageIndex >= dirtyPageNumberOffset);
473
+ if (tail.length > 0) tailTableSplits.set(blockId, tail);
474
+ }
475
+ return {
476
+ pages: tailPages,
477
+ splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
478
+ };
437
479
  }
480
+
481
+ // Section-level skip: paginate only the dirty sections onward (only
482
+ // reached when firstDirtySectionIdx > 0).
483
+ const dirtySections = (sections as ResolvedDocumentSection[]).slice(firstDirtySectionIdx);
484
+ const dirtyBlockSet = new Set(
485
+ mainSurface.blocks
486
+ .filter((b) => b.from >= dirtySectionStart)
487
+ .map((b) => b.blockId),
488
+ );
489
+ const dirtySurface: EditorSurfaceSnapshot = {
490
+ ...mainSurface,
491
+ blocks: mainSurface.blocks.filter((b) => dirtyBlockSet.has(b.blockId)),
492
+ };
493
+
494
+ const tailResult = buildPageStackWithSplits(
495
+ document,
496
+ dirtySections,
497
+ dirtySurface,
498
+ measurementProvider,
499
+ );
500
+
501
+ // Shift global page indices on all returned pages and splits so they align
502
+ // with the global page graph (the caller's spliceGraph prepends the head).
503
+ const shiftedPages = tailResult.pages.map((p) => ({
504
+ ...p,
505
+ pageIndex: p.pageIndex + dirtyPageNumberOffset,
506
+ }));
507
+ const shiftedSplits: BlockSplits = {
508
+ byBlockId: new Map(
509
+ Array.from(tailResult.splits.byBlockId.entries()).map(([id, slices]) => [
510
+ id,
511
+ slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
512
+ ]),
513
+ ),
514
+ tablesByBlockId: new Map(
515
+ Array.from(tailResult.splits.tablesByBlockId.entries()).map(([id, slices]) => [
516
+ id,
517
+ slices.map((s) => ({ ...s, pageIndex: s.pageIndex + dirtyPageNumberOffset })),
518
+ ]),
519
+ ),
520
+ };
521
+ // Shift note-allocation page indices if present.
522
+ const shiftedNoteAllocs =
523
+ tailResult.noteAllocationsByPageIndex && tailResult.noteAllocationsByPageIndex.size > 0
524
+ ? new Map(
525
+ Array.from(tailResult.noteAllocationsByPageIndex.entries()).map(([pi, allocs]) => [
526
+ pi + dirtyPageNumberOffset,
527
+ allocs,
528
+ ]),
529
+ )
530
+ : undefined;
531
+ const shiftedNoteFragments =
532
+ tailResult.noteFragmentsByPageIndex && tailResult.noteFragmentsByPageIndex.size > 0
533
+ ? new Map(
534
+ Array.from(tailResult.noteFragmentsByPageIndex.entries()).map(([pi, frags]) => [
535
+ pi + dirtyPageNumberOffset,
536
+ frags,
537
+ ]),
538
+ )
539
+ : undefined;
540
+
438
541
  return {
439
- pages: tailPages,
440
- splits: { byBlockId: tailSplits, tablesByBlockId: tailTableSplits },
542
+ pages: shiftedPages,
543
+ splits: shiftedSplits,
544
+ ...(shiftedNoteAllocs !== undefined
545
+ ? { noteAllocationsByPageIndex: shiftedNoteAllocs }
546
+ : {}),
547
+ ...(shiftedNoteFragments !== undefined
548
+ ? { noteFragmentsByPageIndex: shiftedNoteFragments }
549
+ : {}),
441
550
  };
442
551
  }
443
552
 
@@ -109,6 +109,7 @@ export function projectSurfaceBlocksToPageFragments(
109
109
  from: block.from,
110
110
  to: block.to,
111
111
  heightTwips: estimateBlockHeightFromSpan(block),
112
+ ...deriveStyleMetadata(block),
112
113
  kind: "whole",
113
114
  };
114
115
 
@@ -138,6 +139,7 @@ function emitSlicedParagraph(
138
139
  from: block.from,
139
140
  to: block.to,
140
141
  heightTwips: estimateSliceHeightFromLines(slice.lineRange),
142
+ ...deriveStyleMetadata(block),
141
143
  kind: "paragraph-slice",
142
144
  paragraphLineRange: slice.lineRange,
143
145
  };
@@ -178,6 +180,7 @@ function emitSlicedTable(
178
180
  from: block.from,
179
181
  to: block.to,
180
182
  heightTwips: estimateSliceHeightFromRows(slice.rowRange),
183
+ ...deriveStyleMetadata(block),
181
184
  kind: "table-slice",
182
185
  tableRowRange: slice.rowRange,
183
186
  };
@@ -223,3 +226,27 @@ function estimateBlockHeightFromSpan(block: SurfaceBlockSnapshot): number {
223
226
  const lines = Math.max(1, Math.ceil(span / 80));
224
227
  return lines * 240;
225
228
  }
229
+
230
+ /**
231
+ * Slice 5 — extract style metadata from a surface block for use in
232
+ * bounded invalidation. `resolvedStyleChainRef` is the block's styleId
233
+ * (paragraphs and tables only); `numberingInstanceId` comes from the
234
+ * block's list-instance (paragraphs only). Both fields are optional and
235
+ * absent when the block carries no style or numbering data.
236
+ */
237
+ function deriveStyleMetadata(
238
+ block: SurfaceBlockSnapshot,
239
+ ): Pick<FragmentWithoutPageId, "resolvedStyleChainRef" | "numberingInstanceId"> {
240
+ const styleId =
241
+ block.kind === "paragraph" || block.kind === "table"
242
+ ? (block.styleId ?? undefined)
243
+ : undefined;
244
+ const numberingInstanceId =
245
+ block.kind === "paragraph"
246
+ ? (block.numbering?.numberingInstanceId ?? undefined)
247
+ : undefined;
248
+ return {
249
+ ...(styleId !== undefined ? { resolvedStyleChainRef: styleId } : {}),
250
+ ...(numberingInstanceId !== undefined ? { numberingInstanceId } : {}),
251
+ };
252
+ }
@@ -560,6 +560,15 @@ export interface WordReviewEditorLayoutFacet {
560
560
  pageIndex: number,
561
561
  ): import("./table-render-plan.ts").TableRenderPlan | null;
562
562
 
563
+ /**
564
+ * Returns the Y offset (in twips from the top of the page body) where this
565
+ * table fragment starts on the given page. Computed by summing
566
+ * `heightTwips` of all body fragments with `orderInRegion` less than the
567
+ * table fragment's order. Returns `null` when the block is not found on
568
+ * the specified page.
569
+ */
570
+ getTableBodyYOffsetOnPage(blockId: string, pageIndex: number): number | null;
571
+
563
572
  // Fields ---------------------------------------------------------------
564
573
  getDirtyFieldFamilies(): readonly string[];
565
574
  getFieldDirtinessReport(): PublicFieldDirtinessReport;
@@ -1231,15 +1240,48 @@ export function createLayoutFacet(
1231
1240
  const tableHeightTwips = graph.fragments
1232
1241
  .filter((f) => f.blockId === blockId)
1233
1242
  .reduce((total, f) => total + f.heightTwips, 0);
1243
+ // Determine if this page holds a continuation slice of the table
1244
+ // (rows after the first slice). Fragment kind "table-slice" with
1245
+ // tableRowRange.from > 0 means the table was split across pages and
1246
+ // this page carries a non-first slice — header rows should repeat.
1247
+ const pageNode = graph.pages.find((p) => p.pageIndex === pageIndex);
1248
+ const sliceFragment = pageNode
1249
+ ? graph.fragments.find(
1250
+ (f) => f.blockId === blockId && f.pageId === pageNode.pageId,
1251
+ )
1252
+ : undefined;
1253
+ const isContinuationPage =
1254
+ sliceFragment?.kind === "table-slice" &&
1255
+ (sliceFragment.tableRowRange?.from ?? 0) > 0;
1256
+
1234
1257
  return buildTableRenderPlan({
1235
1258
  blockId,
1236
1259
  pageIndex,
1237
1260
  block: tableBlock,
1238
1261
  resolved,
1239
1262
  tableHeightTwips,
1263
+ isContinuationPage,
1240
1264
  });
1241
1265
  },
1242
1266
 
1267
+ getTableBodyYOffsetOnPage(blockId, pageIndex) {
1268
+ const graph = currentGraph();
1269
+ const pageNode = graph.pages.find(
1270
+ (p) => p.pageIndex === pageIndex && !p.isBlankFiller,
1271
+ );
1272
+ if (!pageNode) return null;
1273
+ const bodyFragmentIdSet = new Set(pageNode.regions.body.fragmentIds);
1274
+ const bodyFragments = graph.fragments
1275
+ .filter((f) => bodyFragmentIdSet.has(f.fragmentId))
1276
+ .sort((a, b) => a.orderInRegion - b.orderInRegion);
1277
+ let yOffset = 0;
1278
+ for (const frag of bodyFragments) {
1279
+ if (frag.blockId === blockId) return yOffset;
1280
+ yOffset += frag.heightTwips;
1281
+ }
1282
+ return null;
1283
+ },
1284
+
1243
1285
  getDirtyFieldFamilies() {
1244
1286
  return engine.getDirtyFieldFamilies();
1245
1287
  },
@@ -1274,11 +1316,36 @@ export function createLayoutFacet(
1274
1316
  // Internal: graph → public clones
1275
1317
  // ---------------------------------------------------------------------------
1276
1318
 
1319
+ /**
1320
+ * Per-runtime-node cache for `toPublicPageNode`.
1321
+ *
1322
+ * P10 Phase B2. When `spliceGraph` preserves a `RuntimePageNode`
1323
+ * reference across a bounded relayout (Phase D1 tail-convergence
1324
+ * reuse), this cache preserves the derived `PublicPageNode` reference
1325
+ * too — so downstream consumers (React.memo-gated page subtrees in
1326
+ * `TwPageStackChromeLayer`, per-page test hooks) observe stable props
1327
+ * and can skip reconciliation.
1328
+ *
1329
+ * The map is a WeakMap keyed on the runtime node. When a fresh runtime
1330
+ * node replaces a prior one (divergent-tail case), the prior cache
1331
+ * entry is garbage-collected with the prior node; no manual eviction
1332
+ * is required. Structural contract: consumers MUST NOT mutate the
1333
+ * returned `PublicPageNode`, which has always been a read-only clone.
1334
+ */
1335
+ const publicPageNodeCache = new WeakMap<RuntimePageNode, PublicPageNode>();
1336
+
1277
1337
  function toPublicPageNode(
1278
1338
  node: RuntimePageNode,
1279
1339
  graph: RuntimePageGraph,
1280
1340
  ): PublicPageNode {
1281
- return {
1341
+ const cached = publicPageNodeCache.get(node);
1342
+ // Safety guard: reuse only when the runtime node's index still
1343
+ // matches the cached projection. Under normal D1 tail-convergence the
1344
+ // index is preserved, but this belt-and-braces check guarantees we
1345
+ // never hand out a stale clone if a runtime node is ever reused at a
1346
+ // different position by a future splice strategy.
1347
+ if (cached && cached.pageIndex === node.pageIndex) return cached;
1348
+ const built: PublicPageNode = {
1282
1349
  pageId: node.pageId,
1283
1350
  pageIndex: node.pageIndex,
1284
1351
  sectionIndex: node.sectionIndex,
@@ -1295,7 +1362,9 @@ function toPublicPageNode(
1295
1362
  lineBoxCount: node.lineBoxes.length,
1296
1363
  noteAllocations: node.noteAllocations.map(toPublicNoteAllocation),
1297
1364
  };
1365
+ publicPageNodeCache.set(node, built);
1298
1366
  void graph; // reserved for future cross-page derivations
1367
+ return built;
1299
1368
  }
1300
1369
 
1301
1370
  function toPublicResolvedPageStories(
@@ -1,5 +1,6 @@
1
1
  import type { EditorSurfaceSnapshot } from "../../api/public-types";
2
2
  import type { CanonicalDocument } from "../../model/canonical-document.ts";
3
+ import type { CompatibilityReport } from "../../core/state/editor-state.ts";
3
4
  import type { RuntimePageGraph } from "../layout/page-graph.ts";
4
5
 
5
6
  /**
@@ -28,6 +29,17 @@ import type { RuntimePageGraph } from "../layout/page-graph.ts";
28
29
  * - `canonicalDocumentHash` — sha256 of sorted-keys JSON. Also the 5th
29
30
  * input to `deriveCacheKey`, so style/metadata/comment/preservation
30
31
  * mutations correctly invalidate the cache.
32
+ *
33
+ * Phase 2 Finale C3 addition (schema v3):
34
+ * - `compatibilityReport?` — pre-computed `CompatibilityReport`. The
35
+ * report is a pure function of `canonicalDocument` (plus the
36
+ * `generatedAt` timestamp, which is pinned to the fixed sentinel
37
+ * `"__cache_normalized__"` at prerender write time so envelopes are
38
+ * byte-identical across two prerender calls). When present on the
39
+ * Plan B short-circuit path, `loadDocxEditorSessionAsync` skips the
40
+ * live `buildCompatibilityReport` call (60–100 ms on extra-large).
41
+ * The field is optional so v3 writers can ship envelopes without it
42
+ * (graceful degradation to the existing live-computation path).
31
43
  */
32
44
  export interface CacheEnvelope {
33
45
  readonly schemaVersion: number;
@@ -38,4 +50,22 @@ export interface CacheEnvelope {
38
50
  readonly graph: RuntimePageGraph;
39
51
  readonly surface: EditorSurfaceSnapshot;
40
52
  readonly canonicalDocument: CanonicalDocument;
53
+ /**
54
+ * v3+. Optional even at v3 — absence means "compute live on read".
55
+ * See the Phase 2 Finale C3 banner above.
56
+ */
57
+ readonly compatibilityReport?: CompatibilityReport;
41
58
  }
59
+
60
+ /**
61
+ * Fixed sentinel used in place of `new Date().toISOString()` when
62
+ * pre-computing `compatibilityReport` for the envelope. Ensures two
63
+ * sequential `prerenderDocument` calls on identical bytes produce
64
+ * byte-identical cache envelopes.
65
+ *
66
+ * Downstream consumers of `compatibilityReport.generatedAt` are
67
+ * diagnostic-only — the field has never been user-visible in the editor
68
+ * UX. Cache-hit paths observe the sentinel; cache-miss paths observe a
69
+ * live ISO8601 timestamp.
70
+ */
71
+ export const CACHE_NORMALIZED_GENERATED_AT = "__cache_normalized__" as const;
@@ -194,7 +194,7 @@ function extractInlineCdata(rawXml: string): string | null {
194
194
  function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
195
195
  if (typeof value !== "object" || value === null) return false;
196
196
  const v = value as Record<string, unknown>;
197
- return (
197
+ const requiredFieldsOk =
198
198
  v.schemaVersion === LAYCACHE_SCHEMA_VERSION &&
199
199
  v.engineVersion === LAYOUT_ENGINE_VERSION &&
200
200
  typeof v.fontFingerprint === "string" &&
@@ -205,7 +205,21 @@ function isValidCacheEnvelope(value: unknown): value is CacheEnvelope {
205
205
  typeof v.surface === "object" &&
206
206
  v.surface !== null &&
207
207
  typeof v.canonicalDocument === "object" &&
208
- v.canonicalDocument !== null
209
- );
208
+ v.canonicalDocument !== null;
209
+ if (!requiredFieldsOk) return false;
210
+ // C3 (schema v3): `compatibilityReport` is optional — if present it must
211
+ // be a structured object with the expected shape markers. Envelopes
212
+ // without the field degrade gracefully to live-computation on read.
213
+ if (v.compatibilityReport !== undefined) {
214
+ const cr = v.compatibilityReport as Record<string, unknown> | null;
215
+ if (typeof cr !== "object" || cr === null) return false;
216
+ if (cr.reportVersion !== "compatibility-report/1") return false;
217
+ if (typeof cr.generatedAt !== "string") return false;
218
+ if (typeof cr.blockExport !== "boolean") return false;
219
+ if (!Array.isArray(cr.featureEntries)) return false;
220
+ if (!Array.isArray(cr.warnings)) return false;
221
+ if (!Array.isArray(cr.errors)) return false;
222
+ }
223
+ return true;
210
224
  }
211
225