@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.
- package/package.json +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- 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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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(
|
|
647
|
-
|
|
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
|
-
*
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|