@beyondwork/docx-react-component 1.0.40 → 1.0.42

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 (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -63,6 +63,12 @@ import {
63
63
  import { analyzeInvalidation as analyzeInvalidationFn } from "./layout-invalidation.ts";
64
64
  import type { LayoutMeasurementProvider } from "./layout-measurement-provider.ts";
65
65
  import { paginateParagraphLines } from "./paginate-paragraph-lines.ts";
66
+ import {
67
+ computeRepeatedHeaderHeight,
68
+ extractRowFlags,
69
+ findTableRowSplit,
70
+ measureTableRowHeights,
71
+ } from "./table-row-split.ts";
66
72
 
67
73
  // ---------------------------------------------------------------------------
68
74
  // Types
@@ -177,6 +183,11 @@ export function buildPageStackWithSplits(
177
183
  const pages: DocumentPageSnapshot[] = [];
178
184
  const splitsByBlock = new Map<string, ParagraphLineSlice[]>();
179
185
  let globalPageIndex = 0;
186
+ // A single cache lives for the whole pagination pass so cross-section
187
+ // re-measurement (rare but possible through keepNext heuristics) still
188
+ // reuses heights. The WeakMap frees memory automatically when the block
189
+ // snapshots go out of scope at the end of the call.
190
+ const cache = createMeasurementCache();
180
191
 
181
192
  for (let sectionIdx = 0; sectionIdx < sections.length; sectionIdx++) {
182
193
  const section = sections[sectionIdx]!;
@@ -234,6 +245,7 @@ export function buildPageStackWithSplits(
234
245
  layout,
235
246
  document.subParts?.footnoteCollection,
236
247
  measurementProvider,
248
+ cache,
237
249
  );
238
250
  const paginated = paginatedResult.pages;
239
251
 
@@ -560,6 +572,59 @@ export function requiresFullRecompute(reason: LayoutInvalidationReason): boolean
560
572
  const MIN_BLOCK_HEIGHT_TWIPS = 240;
561
573
  const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
562
574
 
575
+ /**
576
+ * Per-invocation measurement cache keyed by `(block, columnWidth)`.
577
+ *
578
+ * Pagination re-measures the same block more than once in the hot path:
579
+ * - `keepNext` checks both the current and the next block's height
580
+ * - Intra-paragraph splits re-ask for `measureParagraphLineCount`
581
+ * - Multi-pass pagination (splitRule, column advance) may loop over the
582
+ * same block with the same column width
583
+ *
584
+ * The cache is scoped to a single `buildPageStackWithSplits` call. Block
585
+ * references are stable during one pagination pass (the `blocks` array is
586
+ * frozen for the run), so a `WeakMap<Block, Map<columnWidth, height>>` is
587
+ * cheap and never outlives the call.
588
+ *
589
+ * Canvas-backed measurement is the expensive case; the empirical backend
590
+ * does its own work inline but the cache still saves redundant iteration.
591
+ */
592
+ interface MeasurementCache {
593
+ getHeight(block: SurfaceBlockSnapshot, columnWidth: number): number | undefined;
594
+ setHeight(block: SurfaceBlockSnapshot, columnWidth: number, heightTwips: number): void;
595
+ getLineCount(block: SurfaceBlockSnapshot, columnWidth: number): number | undefined;
596
+ setLineCount(block: SurfaceBlockSnapshot, columnWidth: number, lineCount: number): void;
597
+ }
598
+
599
+ function createMeasurementCache(): MeasurementCache {
600
+ const heightByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
601
+ const lineCountByBlock = new WeakMap<SurfaceBlockSnapshot, Map<number, number>>();
602
+ return {
603
+ getHeight(block, columnWidth) {
604
+ return heightByBlock.get(block)?.get(columnWidth);
605
+ },
606
+ setHeight(block, columnWidth, heightTwips) {
607
+ let map = heightByBlock.get(block);
608
+ if (!map) {
609
+ map = new Map();
610
+ heightByBlock.set(block, map);
611
+ }
612
+ map.set(columnWidth, heightTwips);
613
+ },
614
+ getLineCount(block, columnWidth) {
615
+ return lineCountByBlock.get(block)?.get(columnWidth);
616
+ },
617
+ setLineCount(block, columnWidth, lineCount) {
618
+ let map = lineCountByBlock.get(block);
619
+ if (!map) {
620
+ map = new Map();
621
+ lineCountByBlock.set(block, map);
622
+ }
623
+ map.set(columnWidth, lineCount);
624
+ },
625
+ };
626
+ }
627
+
563
628
  /**
564
629
  * Compute block height using resolved formatting when available.
565
630
  * Uses improved table measurement for legal contracts.
@@ -567,42 +632,76 @@ const FOOTNOTE_REFERENCE_RESERVATION_TWIPS = 180;
567
632
  * When `measurementProvider` is supplied, paragraph line counts are produced
568
633
  * by `provider.measureLineFragments(...)`; otherwise the inline empirical
569
634
  * path runs (which matches the empirical backend numerically).
635
+ *
636
+ * When `cache` is supplied, repeated measurements of the same
637
+ * `(block, columnWidth)` pair short-circuit to the cached value.
570
638
  */
571
639
  function measureBlockHeight(
572
640
  block: SurfaceBlockSnapshot | undefined,
573
641
  columnWidth: number,
574
642
  measurementProvider?: LayoutMeasurementProvider,
643
+ cache?: MeasurementCache,
575
644
  ): number {
576
645
  if (!block) return 0;
577
646
 
578
- switch (block.kind) {
579
- case "paragraph": {
580
- const formatting = resolveBlockFormatting(block);
581
- if (formatting) {
582
- const lineCount = measureParagraphLineCount(
583
- block,
584
- formatting,
585
- columnWidth,
586
- measurementProvider,
587
- );
588
- return calculateParagraphHeight(formatting, lineCount);
647
+ const cached = cache?.getHeight(block, columnWidth);
648
+ if (cached !== undefined) return cached;
649
+
650
+ const compute = (): number => {
651
+ switch (block.kind) {
652
+ case "paragraph": {
653
+ const formatting = resolveBlockFormatting(block);
654
+ if (formatting) {
655
+ // Provider path: sum per-line heights so canvas-backed measurements
656
+ // that emit variable line heights (mixed inline font sizes, etc.)
657
+ // do not collapse to `lineCount * flatLineHeight`.
658
+ if (measurementProvider) {
659
+ const measured = measurementProvider.measureLineFragments({
660
+ block,
661
+ formatting,
662
+ runs: new Map(),
663
+ columnWidth,
664
+ });
665
+ cache?.setLineCount(block, columnWidth, measured.lineCount);
666
+ const contentHeight = measured.lineHeights.reduce(
667
+ (total, lineHeight) => total + lineHeight,
668
+ 0,
669
+ );
670
+ const paragraphHeight =
671
+ contentHeight + formatting.spacingBefore + formatting.spacingAfter;
672
+ return Math.max(MIN_BLOCK_HEIGHT_TWIPS, paragraphHeight);
673
+ }
674
+ // Empirical-fallback path: flat per-line height × count.
675
+ const lineCount = measureParagraphLineCount(
676
+ block,
677
+ formatting,
678
+ columnWidth,
679
+ undefined,
680
+ );
681
+ return calculateParagraphHeight(formatting, lineCount);
682
+ }
683
+ return estimateBlockHeight(block, columnWidth);
589
684
  }
590
- return estimateBlockHeight(block, columnWidth);
685
+ case "table":
686
+ return measureTableHeight(block, columnWidth, measurementProvider, cache);
687
+ case "sdt_block":
688
+ return Math.max(
689
+ MIN_BLOCK_HEIGHT_TWIPS,
690
+ block.children.reduce(
691
+ (total, child) =>
692
+ total +
693
+ measureBlockHeight(child, columnWidth, measurementProvider, cache),
694
+ 0,
695
+ ),
696
+ );
697
+ case "opaque_block":
698
+ return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
591
699
  }
592
- case "table":
593
- return measureTableHeight(block, columnWidth, measurementProvider);
594
- case "sdt_block":
595
- return Math.max(
596
- MIN_BLOCK_HEIGHT_TWIPS,
597
- block.children.reduce(
598
- (total, child) =>
599
- total + measureBlockHeight(child, columnWidth, measurementProvider),
600
- 0,
601
- ),
602
- );
603
- case "opaque_block":
604
- return block.label === "Section break" ? 0 : MIN_BLOCK_HEIGHT_TWIPS;
605
- }
700
+ };
701
+
702
+ const height = compute();
703
+ cache?.setHeight(block, columnWidth, height);
704
+ return height;
606
705
  }
607
706
 
608
707
  /**
@@ -621,6 +720,7 @@ function measureTableHeight(
621
720
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
622
721
  columnWidth: number,
623
722
  measurementProvider?: LayoutMeasurementProvider,
723
+ cache?: MeasurementCache,
624
724
  ): number {
625
725
  const TABLE_ROW_PADDING_TWIPS = 120;
626
726
  let totalHeight = 0;
@@ -665,6 +765,7 @@ function measureTableHeight(
665
765
  child,
666
766
  cellWidth,
667
767
  measurementProvider,
768
+ cache,
668
769
  );
669
770
  }
670
771
  contentHeight = Math.max(contentHeight, cellContentHeight + TABLE_ROW_PADDING_TWIPS);
@@ -896,6 +997,7 @@ function paginateSectionBlocks(
896
997
  layout: DocumentPageSnapshot["layout"],
897
998
  footnotes: FootnoteCollection | undefined,
898
999
  measurementProvider?: LayoutMeasurementProvider,
1000
+ cache?: MeasurementCache,
899
1001
  ): Omit<DocumentPageSnapshot, "pageIndex">[] {
900
1002
  return paginateSectionBlocksWithSplits(
901
1003
  section,
@@ -903,6 +1005,7 @@ function paginateSectionBlocks(
903
1005
  layout,
904
1006
  footnotes,
905
1007
  measurementProvider,
1008
+ cache,
906
1009
  ).pages;
907
1010
  }
908
1011
 
@@ -912,6 +1015,7 @@ function paginateSectionBlocksWithSplits(
912
1015
  layout: DocumentPageSnapshot["layout"],
913
1016
  footnotes: FootnoteCollection | undefined,
914
1017
  measurementProvider?: LayoutMeasurementProvider,
1018
+ cache?: MeasurementCache,
915
1019
  ): SectionPaginationResult {
916
1020
  if (blocks.length === 0) {
917
1021
  return {
@@ -939,6 +1043,10 @@ function paginateSectionBlocksWithSplits(
939
1043
  let pageInSection = 0;
940
1044
  let reservedNoteHeight = 0;
941
1045
  const reservedNotes = new Set<string>();
1046
+ // P6.c: per-table progress when a table is being split row-by-row
1047
+ // across pages. Map<blockId, nextRowIndexToPlace>. Cleared once a
1048
+ // table is fully placed.
1049
+ const tableProgress = new Map<string, number>();
942
1050
 
943
1051
  const pushPage = (endOffset: number): void => {
944
1052
  const boundedEnd = Math.max(pageStart, Math.min(endOffset, section.end));
@@ -967,13 +1075,13 @@ function paginateSectionBlocksWithSplits(
967
1075
  const columnWidth =
968
1076
  columnMetrics[Math.min(columnIndex, columnMetrics.length - 1)]?.width ??
969
1077
  getUsableColumnWidth(layout);
970
- const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider);
1078
+ const baseHeight = measureBlockHeight(block, columnWidth, measurementProvider, cache);
971
1079
 
972
1080
  // keepNext: this paragraph must stay with the next one on the same page
973
1081
  const keepWithNextHeight =
974
1082
  block.kind === "paragraph" && block.keepNext
975
1083
  ? baseHeight +
976
- measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider)
1084
+ measureBlockHeight(blocks[index + 1], columnWidth, measurementProvider, cache)
977
1085
  : baseHeight;
978
1086
 
979
1087
  // keepLines: the entire paragraph must fit on one page.
@@ -990,6 +1098,88 @@ function paginateSectionBlocksWithSplits(
990
1098
  continue;
991
1099
  }
992
1100
 
1101
+ // P6.c: row-boundary split for tables that overflow the remaining
1102
+ // page space (or that, on a fresh page, exceed one full page).
1103
+ // Replaces the generic overflow path for table blocks. Tables fall
1104
+ // into one of four cases:
1105
+ //
1106
+ // 1. Remainder fits → place atomically, clear progress, break.
1107
+ // 2. Remainder overflows AND splittable → place rows that fit,
1108
+ // push at the row-boundary offset, continue (next iteration
1109
+ // resumes from `splitRowIndex`).
1110
+ // 3. Remainder overflows AND can't split AND has prior content →
1111
+ // push the whole remainder to the next page.
1112
+ // 4. Remainder overflows AND can't split AND on fresh page →
1113
+ // degrade to atomic placement (visual overflow, but offset
1114
+ // ranges stay clean — same as pre-P6.c behavior).
1115
+ if (block.kind === "table") {
1116
+ const startRow = tableProgress.get(block.blockId) ?? 0;
1117
+ const remainingForTable =
1118
+ usableHeight - columnHeight - reservedNoteHeight;
1119
+ const rowHeights = measureTableRowHeights({
1120
+ block,
1121
+ columnWidth,
1122
+ measurementProvider,
1123
+ });
1124
+ const { cantSplitFlags, isHeaderFlags } = extractRowFlags(block);
1125
+ const repeatedHeaderHeightTwips = computeRepeatedHeaderHeight(
1126
+ rowHeights,
1127
+ isHeaderFlags,
1128
+ );
1129
+ const headerReservation =
1130
+ startRow > 0 ? repeatedHeaderHeightTwips : 0;
1131
+ let remainderHeight = headerReservation;
1132
+ for (let r = startRow; r < rowHeights.length; r += 1) {
1133
+ remainderHeight += rowHeights[r] ?? 0;
1134
+ }
1135
+
1136
+ // Helper: best-effort offset for the start of row K. Falls back
1137
+ // to the table block's `from` when the row has no inner block.
1138
+ const rowOffset = (rowIndex: number): number => {
1139
+ const row = block.rows[rowIndex];
1140
+ const firstChild = row?.cells[0]?.content[0];
1141
+ return firstChild?.from ?? block.from;
1142
+ };
1143
+
1144
+ // Case 1: remainder fits — place and break.
1145
+ if (remainderHeight <= remainingForTable) {
1146
+ columnHeight += startRow > 0 ? remainderHeight : baseHeight;
1147
+ if (startRow > 0) tableProgress.delete(block.blockId);
1148
+ if (index === blocks.length - 1) pushPage(section.end);
1149
+ break;
1150
+ }
1151
+
1152
+ // Case 2: try a row-boundary split.
1153
+ const decision = findTableRowSplit({
1154
+ rowHeights,
1155
+ cantSplitFlags,
1156
+ isHeaderFlags,
1157
+ remainingHeightTwips: remainingForTable,
1158
+ repeatedHeaderHeightTwips,
1159
+ startRow,
1160
+ });
1161
+ if (decision.rowsOnCurrentPage > 0) {
1162
+ tableProgress.set(block.blockId, decision.splitRowIndex);
1163
+ pushPage(rowOffset(decision.splitRowIndex));
1164
+ continue;
1165
+ }
1166
+
1167
+ // Case 3: can't split here. If there's content above (or we're
1168
+ // resuming), push everything from the resume point to the next
1169
+ // page so the next iteration starts fresh.
1170
+ if (columnHeight > 0 || startRow > 0) {
1171
+ pushPage(startRow > 0 ? rowOffset(startRow) : block.from);
1172
+ continue;
1173
+ }
1174
+
1175
+ // Case 4: degraded atomic placement (table on a fresh page that
1176
+ // it doesn't fit on, AND the first row alone exceeds page
1177
+ // height). Preserve pre-P6.c semantics so offsets stay clean.
1178
+ columnHeight += baseHeight;
1179
+ if (index === blocks.length - 1) pushPage(section.end);
1180
+ break;
1181
+ }
1182
+
993
1183
  // Overflow check — paragraph doesn't fit on current page
994
1184
  if (projectedHeight > usableHeight && pageStart < block.from) {
995
1185
  if (columnIndex < maxColumns - 1) {
@@ -1017,12 +1207,18 @@ function paginateSectionBlocksWithSplits(
1017
1207
  !block.keepNext
1018
1208
  ) {
1019
1209
  const availableHeight = usableHeight - columnHeight - reservedNoteHeight;
1020
- const totalLines = measureParagraphLineCount(
1021
- block,
1022
- formatting,
1023
- columnWidth,
1024
- measurementProvider,
1025
- );
1210
+ const cachedLineCount = cache?.getLineCount(block, columnWidth);
1211
+ const totalLines =
1212
+ cachedLineCount ??
1213
+ measureParagraphLineCount(
1214
+ block,
1215
+ formatting,
1216
+ columnWidth,
1217
+ measurementProvider,
1218
+ );
1219
+ if (cachedLineCount === undefined) {
1220
+ cache?.setLineCount(block, columnWidth, totalLines);
1221
+ }
1026
1222
  const availableLines =
1027
1223
  formatting.lineHeight > 0
1028
1224
  ? Math.max(0, Math.floor(availableHeight / formatting.lineHeight))
@@ -276,6 +276,32 @@ export type LayoutFacetEvent =
276
276
  kind: "zoom_changed";
277
277
  revision: number;
278
278
  zoom: RenderZoomSummary;
279
+ }
280
+ | {
281
+ /**
282
+ * P14.b — coalesced commit event. Fires exactly once per
283
+ * `applyPatch`-driven layout build (full or incremental) AFTER
284
+ * the granular events. Subscribers that only need to react
285
+ * to "the engine just finished a build" can listen here and
286
+ * skip the multi-event subscription pattern that triggered N
287
+ * React re-renders per applyPatch.
288
+ *
289
+ * Carries the union of: dirty-field families (TOC / PAGE /
290
+ * NUMPAGES etc.), page-count delta when the total changed, and
291
+ * the page range when the build was a bounded incremental
292
+ * relayout (absent for full rebuilds). The granular events
293
+ * (`layout_recomputed`, `incremental_relayout`,
294
+ * `page_count_changed`, `page_field_dirtied`) still fire for
295
+ * backward-compat with consumers that care about specific
296
+ * kinds (e.g., `TwStatusBar` measurement-fidelity badge keys
297
+ * off `measurement_backend_ready`).
298
+ */
299
+ kind: "layout_committed";
300
+ revision: number;
301
+ reason?: LayoutFacetInvalidationReason;
302
+ dirtyFieldFamilies?: readonly string[];
303
+ pageCountDelta?: { previous: number; current: number };
304
+ pageRange?: { fromPageIndex: number; toPageIndex: number };
279
305
  };
280
306
 
281
307
  /**
@@ -338,6 +364,16 @@ export interface WordReviewEditorLayoutFacet {
338
364
  region: PublicPageRegion["kind"],
339
365
  options?: { columnIndex?: number },
340
366
  ): readonly PublicLineBox[];
367
+ /**
368
+ * P4 — every region present on the given page, in render order
369
+ * (header, body, columns…, footer, footnote-area). Each entry
370
+ * carries the region kind, twip dimensions, and fragment count.
371
+ * Consumers iterate this to enumerate which regions exist before
372
+ * calling `getLineBoxesForRegion(pageIndex, kind)` to fetch
373
+ * their per-line breakdowns. Returns an empty array when the
374
+ * pageIndex is out of range.
375
+ */
376
+ getStoryRegionsOnPage(pageIndex: number): readonly PublicPageRegion[];
341
377
  getFragmentsForPage(pageIndex: number): PublicBlockFragment[];
342
378
 
343
379
  // Page-format catalog --------------------------------------------------
@@ -414,6 +450,14 @@ export interface WordReviewEditorLayoutFacet {
414
450
  getMeasurementFidelity(): PublicMeasurementFidelity;
415
451
  whenMeasurementReady(): Promise<void>;
416
452
  swapMeasurementProvider(provider: LayoutMeasurementProvider): void;
453
+ /**
454
+ * Clear the active measurement provider's internal glyph / run-width
455
+ * cache AND the engine's cached page graph. Call after
456
+ * `docxFontLoader.refresh(...)` so the canvas backend re-reads the
457
+ * newly-registered `FontFace` metrics and pagination re-runs with
458
+ * the fresh widths. No-op on the inert facet.
459
+ */
460
+ invalidateMeasurementCache(): void;
417
461
 
418
462
  // Table render plan (P3e consumed by the render kernel, P4) ------------
419
463
  /**
@@ -497,6 +541,15 @@ export interface CreateLayoutFacetInput {
497
541
  | readonly import("../../api/public-types.ts").WorkflowMetadataMarkup[]
498
542
  | null
499
543
  | undefined;
544
+ /**
545
+ * R3 — optional suggestions snapshot accessor. Used by
546
+ * `getAllScopeCardModels` to attach `SuggestionGroup` entries whose
547
+ * `issueId` matches the scope's issue. Omit to skip group wiring.
548
+ */
549
+ getSuggestionsSnapshot?: () =>
550
+ | import("../../api/public-types.ts").SuggestionsSnapshot
551
+ | null
552
+ | undefined;
500
553
  }
501
554
 
502
555
  export function createLayoutFacet(
@@ -635,6 +688,7 @@ export function createLayoutFacet(
635
688
  return collectLineBoxesForRegion(
636
689
  node,
637
690
  region,
691
+ graph,
638
692
  options?.columnIndex,
639
693
  ).map((box) => toPublicLineBox(box));
640
694
  },
@@ -643,9 +697,39 @@ export function createLayoutFacet(
643
697
  const graph = currentGraph();
644
698
  const node = graph.pages[pageIndex];
645
699
  if (!node) return [];
646
- return collectLineBoxesForRegion(node, region, options?.columnIndex).map(
647
- (box) => toPublicLineBox(box),
648
- );
700
+ return collectLineBoxesForRegion(
701
+ node,
702
+ region,
703
+ graph,
704
+ options?.columnIndex,
705
+ ).map((box) => toPublicLineBox(box));
706
+ },
707
+
708
+ getStoryRegionsOnPage(pageIndex) {
709
+ const graph = currentGraph();
710
+ const node = graph.pages[pageIndex];
711
+ if (!node) return [];
712
+ const result: PublicPageRegion[] = [];
713
+ // Render order: header → body → columns → footer → footnote-area.
714
+ if (node.regions.header) {
715
+ result.push(toPublicPageRegion(node.regions.header));
716
+ }
717
+ result.push(toPublicPageRegion(node.regions.body));
718
+ if (node.regions.columns) {
719
+ for (const column of node.regions.columns) {
720
+ result.push(toPublicPageRegion(column));
721
+ }
722
+ }
723
+ if (node.regions.footer) {
724
+ result.push(toPublicPageRegion(node.regions.footer));
725
+ }
726
+ const footnoteRegions = node.regions.footnotes;
727
+ if (footnoteRegions) {
728
+ for (const footnote of footnoteRegions) {
729
+ result.push(toPublicPageRegion(footnote));
730
+ }
731
+ }
732
+ return result;
649
733
  },
650
734
 
651
735
  getPageFormatCatalog() {
@@ -744,11 +828,18 @@ export function createLayoutFacet(
744
828
  const kernel = input.renderKernel?.();
745
829
  const anchorIndex = kernel?.getRenderFrame?.()?.anchorIndex ?? null;
746
830
  const metadata = input.getWorkflowMarkupMetadata?.();
831
+ const suggestions = input.getSuggestionsSnapshot?.() ?? null;
832
+ // The workflow-markup entries carry BOTH issue and review-action
833
+ // metadata. The projector filters to REVIEW_ACTION_METADATA_ID
834
+ // internally, so we can just pass the whole list.
747
835
  return attachScopeCardModel({
748
836
  segments,
749
837
  scopes: railInput?.scopes ?? [],
750
838
  metadata: metadata ?? undefined,
751
839
  anchorIndex,
840
+ suggestions,
841
+ reviewActionMetadata: metadata ?? undefined,
842
+ candidates: railInput?.candidates ?? undefined,
752
843
  });
753
844
  },
754
845
 
@@ -816,6 +907,10 @@ export function createLayoutFacet(
816
907
  engine.swapMeasurementProvider(provider);
817
908
  },
818
909
 
910
+ invalidateMeasurementCache() {
911
+ engine.invalidateMeasurementCache();
912
+ },
913
+
819
914
  getTableRenderPlan(blockId, pageIndex) {
820
915
  const graph = currentGraph();
821
916
  const fragment = graph.fragments.find((f) => f.blockId === blockId);
@@ -1077,6 +1172,17 @@ function toFacetEvent(
1077
1172
  ...(event.reason ? { reason: event.reason } : {}),
1078
1173
  };
1079
1174
  }
1175
+ case "layout_committed":
1176
+ return {
1177
+ kind: "layout_committed",
1178
+ revision: event.revision,
1179
+ ...(event.reason ? { reason: event.reason } : {}),
1180
+ ...(event.dirtyFieldFamilies && event.dirtyFieldFamilies.length > 0
1181
+ ? { dirtyFieldFamilies: event.dirtyFieldFamilies }
1182
+ : {}),
1183
+ ...(event.pageCountDelta ? { pageCountDelta: event.pageCountDelta } : {}),
1184
+ ...(event.pageRange ? { pageRange: event.pageRange } : {}),
1185
+ };
1080
1186
  default:
1081
1187
  return null;
1082
1188
  }
@@ -1173,24 +1279,90 @@ function findPageForOffsetAndStory(
1173
1279
  /**
1174
1280
  * Select line boxes that belong to a given region on a page.
1175
1281
  *
1176
- * Today the engine populates body line boxes only; header/footer/column/
1177
- * footnote-area regions produce empty arrays until the render kernel lands
1178
- * (Phase R1). The region filter is now exact: `body` returns only body
1179
- * line boxes, everything else returns empty. Consumers should prefer
1180
- * `getLineBoxes(pageIndex, { region: "body" })` the other kinds are
1181
- * scaffolded so UI can start reading through the facet without special-
1182
- * casing region availability.
1282
+ * `body` returns the engine's per-page measured line boxes (one per
1283
+ * line of paginated body content).
1284
+ *
1285
+ * `header` / `footer` / `footnote-area` / `column` (P4): synthesizes
1286
+ * one line box per fragment in the region. Header/footer fragments
1287
+ * aren't paginated themselves the engine reserves space for the
1288
+ * entire header story per page — so per-line measurements are not
1289
+ * available. The synthesized line box uses each fragment's
1290
+ * `heightTwips` as the line height with cumulative `baselineTwips`
1291
+ * from the region origin. This gives consumers (page stack, P8
1292
+ * region rendering) a uniform iteration shape across regions.
1293
+ *
1294
+ * `endnote-area` always returns empty: endnote bodies are emitted at
1295
+ * the document end as a separate region whose layout sits outside
1296
+ * the per-page accounting.
1183
1297
  */
1184
1298
  function collectLineBoxesForRegion(
1185
1299
  node: RuntimePageNode,
1186
1300
  region: PublicPageRegion["kind"],
1187
- _columnIndex: number | undefined,
1301
+ graph: RuntimePageGraph,
1302
+ columnIndex: number | undefined,
1188
1303
  ): readonly RuntimeLineBoxAlias[] {
1189
- void _columnIndex;
1190
1304
  if (region === "body") {
1191
1305
  return node.lineBoxes;
1192
1306
  }
1193
- return EMPTY_LINE_BOXES;
1307
+ // P4: synthesize line boxes from the region's fragments.
1308
+ const regionEntry = resolveRegionEntry(node, region, columnIndex);
1309
+ if (!regionEntry || regionEntry.fragmentIds.length === 0) {
1310
+ return EMPTY_LINE_BOXES;
1311
+ }
1312
+ const fragmentsById = new Map(
1313
+ graph.fragments.map((f) => [f.fragmentId, f] as const),
1314
+ );
1315
+ const result: RuntimeLineBoxAlias[] = [];
1316
+ let cursorTwips = 0;
1317
+ let lineIndex = 0;
1318
+ for (const fragmentId of regionEntry.fragmentIds) {
1319
+ const fragment = fragmentsById.get(fragmentId);
1320
+ if (!fragment) continue;
1321
+ const heightTwips = Math.max(1, fragment.heightTwips);
1322
+ result.push({
1323
+ fragmentId,
1324
+ lineIndex: lineIndex++,
1325
+ baselineTwips: cursorTwips + heightTwips,
1326
+ heightTwips,
1327
+ widthTwips: regionEntry.widthTwips,
1328
+ });
1329
+ cursorTwips += heightTwips;
1330
+ }
1331
+ return result;
1332
+ }
1333
+
1334
+ function resolveRegionEntry(
1335
+ node: RuntimePageNode,
1336
+ region: PublicPageRegion["kind"],
1337
+ columnIndex: number | undefined,
1338
+ ): RuntimePageRegion | undefined {
1339
+ switch (region) {
1340
+ case "header":
1341
+ return node.regions.header;
1342
+ case "footer":
1343
+ return node.regions.footer;
1344
+ case "column": {
1345
+ const columns = node.regions.columns ?? [];
1346
+ if (columns.length === 0) return undefined;
1347
+ const idx = columnIndex ?? 0;
1348
+ return columns[idx];
1349
+ }
1350
+ case "footnote-area": {
1351
+ // Footnote area sits at the bottom of the body region per
1352
+ // OOXML. `RuntimePageRegions.footnotes` is a stable seam
1353
+ // declared by P4; the page-graph builder populates entries
1354
+ // when P8 lands. Returns the first footnote region (page
1355
+ // layouts allocate one block of footnotes per page; multi-
1356
+ // block layouts come later).
1357
+ const footnoteRegionList = node.regions.footnotes;
1358
+ if (footnoteRegionList && footnoteRegionList.length > 0) {
1359
+ return footnoteRegionList[0];
1360
+ }
1361
+ return undefined;
1362
+ }
1363
+ default:
1364
+ return undefined;
1365
+ }
1194
1366
  }
1195
1367
 
1196
1368
  // Use a shared alias so the region helper doesn't import the runtime