@beyondwork/docx-react-component 1.0.53 → 1.0.55

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 (99) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +125 -7
  3. package/src/index.ts +5 -0
  4. package/src/io/docx-session.ts +27 -3
  5. package/src/io/normalize/normalize-text.ts +1 -0
  6. package/src/io/ooxml/parse-field-switches.ts +134 -0
  7. package/src/io/ooxml/parse-fields.ts +28 -2
  8. package/src/model/canonical-document.ts +13 -2
  9. package/src/runtime/chart/chart-model-store.ts +88 -0
  10. package/src/runtime/chart/chart-snapshot.ts +239 -0
  11. package/src/runtime/collab/checkpoint-store.ts +1 -1
  12. package/src/runtime/collab/event-types.ts +4 -0
  13. package/src/runtime/collab/runtime-collab-sync.ts +1 -2
  14. package/src/runtime/document-runtime.ts +115 -13
  15. package/src/runtime/layout/inert-layout-facet.ts +1 -0
  16. package/src/runtime/layout/layout-engine-version.ts +58 -1
  17. package/src/runtime/layout/layout-invalidation.ts +150 -30
  18. package/src/runtime/layout/page-graph.ts +19 -0
  19. package/src/runtime/layout/paginated-layout-engine.ts +128 -19
  20. package/src/runtime/layout/project-block-fragments.ts +27 -0
  21. package/src/runtime/layout/public-facet.ts +27 -0
  22. package/src/runtime/page-number-format.ts +207 -0
  23. package/src/runtime/render/render-frame-diff.ts +38 -2
  24. package/src/runtime/surface-projection.ts +32 -3
  25. package/src/ui/WordReviewEditor.tsx +57 -3
  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/chart-node-view.tsx +90 -0
  76. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +20 -7
  77. package/src/ui-tailwind/editor-surface/pm-schema.ts +4 -0
  78. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +14 -0
  79. package/src/ui-tailwind/editor-surface/scroll-anchor.ts +93 -0
  80. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +2 -1
  81. package/src/ui-tailwind/index.ts +11 -0
  82. package/src/ui-tailwind/page-stack/tw-page-chrome-entry.tsx +52 -2
  83. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +13 -0
  84. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +13 -0
  85. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +8 -0
  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/editor-theme.css +249 -22
  94. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +14 -1
  95. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +73 -32
  96. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +49 -9
  97. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +178 -14
  98. package/src/ui-tailwind/tw-review-workspace.tsx +39 -6
  99. 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;
@@ -1255,6 +1264,24 @@ export function createLayoutFacet(
1255
1264
  });
1256
1265
  },
1257
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
+
1258
1285
  getDirtyFieldFamilies() {
1259
1286
  return engine.getDirtyFieldFamilies();
1260
1287
  },