@beyondwork/docx-react-component 1.0.80 → 1.0.82

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +12 -13
  3. package/src/api/v3/_runtime-handle.ts +4 -0
  4. package/src/api/v3/runtime/document.ts +61 -3
  5. package/src/api/v3/runtime/review.ts +55 -2
  6. package/src/api/v3/ui/chrome-composition.ts +10 -2
  7. package/src/io/normalize/normalize-text.ts +4 -1
  8. package/src/io/ooxml/parse-drawing.ts +4 -0
  9. package/src/model/canonical-document.ts +2 -0
  10. package/src/ui/WordReviewEditor.tsx +132 -3
  11. package/src/ui/editor-shell-view.tsx +1 -0
  12. package/src/ui-tailwind/chrome/editor-action-registry.ts +373 -0
  13. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +59 -35
  14. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +2 -0
  15. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  16. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +15 -10
  17. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  18. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +256 -37
  19. package/src/ui-tailwind/editor-surface/pm-schema.ts +54 -1
  20. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +31 -1
  21. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -0
  22. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +24 -5
  23. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +35 -6
  24. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +333 -43
  25. package/src/ui-tailwind/review-workspace/types.ts +1 -0
  26. package/src/ui-tailwind/review-workspace/use-page-markers.ts +273 -24
  27. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +46 -6
  28. package/src/ui-tailwind/theme/editor-theme.css +3 -5
  29. package/src/ui-tailwind/tw-review-workspace.tsx +117 -14
@@ -251,6 +251,7 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
251
251
  {pageStackScrollRoot !== undefined ? (
252
252
  <TwPageStackChromeLayer
253
253
  facet={facet}
254
+ geometryFacet={geometryFacet}
254
255
  scrollRoot={pageStackScrollRoot}
255
256
  renderFrameRevision={renderFrameRevision ?? 0}
256
257
  activeStory={activeStory ?? { kind: "main" }}
@@ -94,6 +94,99 @@ export interface VisiblePageIndexRange {
94
94
  end: number;
95
95
  }
96
96
 
97
+ function pageOverlayRectsEqual(
98
+ a: readonly PageOverlayRect[],
99
+ b: readonly PageOverlayRect[],
100
+ ): boolean {
101
+ if (a === b) return true;
102
+ if (a.length !== b.length) return false;
103
+ for (let i = 0; i < a.length; i += 1) {
104
+ const left = a[i]!;
105
+ const right = b[i]!;
106
+ if (
107
+ left.pageId !== right.pageId ||
108
+ left.pageIndex !== right.pageIndex ||
109
+ left.topPx !== right.topPx ||
110
+ left.bottomPx !== right.bottomPx ||
111
+ left.heightPx !== right.heightPx
112
+ ) {
113
+ return false;
114
+ }
115
+ }
116
+ return true;
117
+ }
118
+
119
+ function pageOverlayLastBottom(rects: readonly PageOverlayRect[]): number {
120
+ let bottom = 0;
121
+ for (const rect of rects) {
122
+ if (rect.bottomPx > bottom) bottom = rect.bottomPx;
123
+ }
124
+ return bottom;
125
+ }
126
+
127
+ /**
128
+ * Page mode currently keeps the editable ProseMirror body as one natural DOM
129
+ * flow and paints page cards behind it. Layout geometry gives us the intended
130
+ * page frames, but browser-rendered PM content can exceed the final geometry
131
+ * frame (wide tables, fallback font metrics, or deliberately-degraded
132
+ * over-tall blocks). The decorative paper must follow that visible flow so
133
+ * text never appears directly on the workspace canvas.
134
+ */
135
+ export function extendFinalPageOverlayRectToFlowHeight(
136
+ rects: readonly PageOverlayRect[],
137
+ flowHeightPx: number,
138
+ ): readonly PageOverlayRect[] {
139
+ if (rects.length === 0 || !Number.isFinite(flowHeightPx)) return rects;
140
+ const last = rects[rects.length - 1]!;
141
+ if (flowHeightPx <= last.bottomPx + 1) return rects;
142
+ return [
143
+ ...rects.slice(0, -1),
144
+ {
145
+ ...last,
146
+ bottomPx: flowHeightPx,
147
+ heightPx: Math.max(0, flowHeightPx - last.topPx),
148
+ },
149
+ ];
150
+ }
151
+
152
+ export function extendPageOverlayRectsAcrossTableBoundaryGaps(
153
+ rects: readonly PageOverlayRect[],
154
+ tableBoundaryIndices: readonly number[],
155
+ ): readonly PageOverlayRect[] {
156
+ if (rects.length === 0 || tableBoundaryIndices.length === 0) return rects;
157
+ const tableBoundaries = new Set(tableBoundaryIndices);
158
+ const byPageIndex = new Map<number, PageOverlayRect>();
159
+ for (const rect of rects) {
160
+ byPageIndex.set(rect.pageIndex, rect);
161
+ }
162
+
163
+ let changed = false;
164
+ const bridged = rects.map((rect) => {
165
+ if (!tableBoundaries.has(rect.pageIndex)) return rect;
166
+ const next = byPageIndex.get(rect.pageIndex + 1);
167
+ if (!next || next.topPx <= rect.bottomPx + 1) return rect;
168
+ changed = true;
169
+ return {
170
+ ...rect,
171
+ bottomPx: next.topPx,
172
+ heightPx: Math.max(0, next.topPx - rect.topPx),
173
+ };
174
+ });
175
+ return changed ? bridged : rects;
176
+ }
177
+
178
+ function mergePageOverlayRectsByPageIndex(
179
+ baseRects: readonly PageOverlayRect[],
180
+ flowRects: readonly PageOverlayRect[],
181
+ ): readonly PageOverlayRect[] {
182
+ if (baseRects.length === 0 || flowRects.length === 0) return baseRects;
183
+ const flowByIndex = new Map<number, PageOverlayRect>();
184
+ for (const rect of flowRects) {
185
+ flowByIndex.set(rect.pageIndex, rect);
186
+ }
187
+ return baseRects.map((rect) => flowByIndex.get(rect.pageIndex) ?? rect);
188
+ }
189
+
97
190
  function normalizeVisiblePageIndexRange(
98
191
  range: VisiblePageIndexRange | null | undefined,
99
192
  pageCount: number,
@@ -126,6 +219,26 @@ function parsePageBoundaryIndex(prevPageId: string): number | undefined {
126
219
  return Number.parseInt(match[1] ?? "", 10);
127
220
  }
128
221
 
222
+ function collectTableEmbeddedBoundaryIndices(
223
+ queryRoot: Pick<HTMLElement, "querySelectorAll"> | null,
224
+ ): number[] {
225
+ if (!queryRoot) return [];
226
+ const indices: number[] = [];
227
+ const widgets = Array.from(
228
+ queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
229
+ );
230
+ for (const widget of widgets) {
231
+ const prevPageId = widget.getAttribute("data-page-frame-end");
232
+ if (!prevPageId) continue;
233
+ const boundaryIndex = parsePageBoundaryIndex(prevPageId);
234
+ if (boundaryIndex === undefined) continue;
235
+ if (widget.closest("[data-pm-table-root='true'], table")) {
236
+ indices.push(boundaryIndex);
237
+ }
238
+ }
239
+ return indices;
240
+ }
241
+
129
242
  /**
130
243
  * Pure helper: turn pre-measured page-boundary widget positions into
131
244
  * one `PageOverlayRect` per page. No DOM access — the caller supplies
@@ -407,6 +520,39 @@ function resolveOffsetHeight(widget: HTMLElement): number {
407
520
  return widget.offsetHeight ?? 0;
408
521
  }
409
522
 
523
+ function readElementFlowHeight(
524
+ element: HTMLElement | null,
525
+ options: { includeScrollHeight?: boolean } = {},
526
+ ): number {
527
+ if (!element) return 0;
528
+ let height = 0;
529
+ // geometry:allow-dom-fallback
530
+ height = Math.max(height, element.clientHeight || 0);
531
+ if (options.includeScrollHeight !== false) {
532
+ // geometry:allow-dom-fallback
533
+ height = Math.max(height, element.scrollHeight || 0);
534
+ }
535
+ if (height <= 0) {
536
+ // geometry:allow-dom-fallback
537
+ const rect = element.getBoundingClientRect();
538
+ height = Math.max(height, rect.height || 0);
539
+ }
540
+ return height;
541
+ }
542
+
543
+ function readOverlayFlowHeight(origin: HTMLElement | null): number {
544
+ if (!origin) return 0;
545
+ const parent = origin.parentElement instanceof HTMLElement
546
+ ? origin.parentElement
547
+ : null;
548
+ const parentHeight = readElementFlowHeight(parent);
549
+ if (parentHeight > 0) return parentHeight;
550
+ // Fallback only: do not read origin.scrollHeight here. The overlay's own
551
+ // absolutely-positioned paper cards can otherwise preserve a stale extended
552
+ // height after editable content shrinks.
553
+ return readElementFlowHeight(origin, { includeScrollHeight: false });
554
+ }
555
+
410
556
  // ---------------------------------------------------------------------------
411
557
  // Component
412
558
  // ---------------------------------------------------------------------------
@@ -438,6 +584,10 @@ export interface TwPageStackOverlayLayerProps {
438
584
  * time this changes so the overlays stay aligned with content.
439
585
  */
440
586
  renderFrameRevision: number;
587
+ /**
588
+ * Kept for call-site compatibility. Paper-card backgrounds intentionally
589
+ * render every page; viewport culling belongs to heavier chrome layers.
590
+ */
441
591
  visiblePageIndexRange?: VisiblePageIndexRange | null;
442
592
  /** Optional test id applied to the overlay root. */
443
593
  "data-testid"?: string;
@@ -524,7 +674,6 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
524
674
  geometryFacet,
525
675
  scrollRoot,
526
676
  renderFrameRevision,
527
- visiblePageIndexRange,
528
677
  "data-testid": testId,
529
678
  }) => {
530
679
  // DS-C2 — prefer `ui.overlays.getAnchor({ kind: "page", ... })` as
@@ -542,12 +691,21 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
542
691
  if (!geometryFacet) return [];
543
692
  const pageCount = facet.getPageCount();
544
693
  if (pageCount <= 0) return [];
694
+ // Paper-card backgrounds are cheap and must stay ahead of scroll. Heavy
695
+ // header/footer/footnote chrome still consumes `visiblePageIndexRange`;
696
+ // this decorative white-paper layer renders every card so fast scrolls
697
+ // never expose the gray canvas while the page window catches up.
545
698
  const warm = resolvePageOverlayRectsFromGeometry(
546
699
  geometryFacet,
547
700
  pageCount,
548
- visiblePageIndexRange,
701
+ null,
549
702
  );
550
- return warm ?? [];
703
+ return warm
704
+ ? extendPageOverlayRectsAcrossTableBoundaryGaps(
705
+ warm,
706
+ collectTableEmbeddedBoundaryIndices(scrollRoot),
707
+ )
708
+ : [];
551
709
  });
552
710
  // P3.d fix: the overlay root acts as the **measurement origin** so
553
711
  // widget `topPx` / `bottomPx` are expressed in the exact coordinate
@@ -576,12 +734,60 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
576
734
  // tears down.
577
735
  const rafHandleRef = React.useRef<number | null>(null);
578
736
 
737
+ const setRectsIfChanged = React.useCallback(
738
+ (next: readonly PageOverlayRect[]) => {
739
+ setRects((prev) => (pageOverlayRectsEqual(prev, next) ? prev : next));
740
+ },
741
+ [],
742
+ );
743
+
744
+ const reconcilePaperRectsWithFlow = React.useCallback(
745
+ (
746
+ baseRects: readonly PageOverlayRect[],
747
+ pageCount: number,
748
+ ): readonly PageOverlayRect[] => {
749
+ if (baseRects.length === 0 || pageCount <= 0) return baseRects;
750
+ const origin = overlayRootRef.current;
751
+ const flowHeight = readOverlayFlowHeight(origin);
752
+ const bridgedBase = extendPageOverlayRectsAcrossTableBoundaryGaps(
753
+ baseRects,
754
+ collectTableEmbeddedBoundaryIndices(scrollRoot),
755
+ );
756
+ if (flowHeight <= 0) return bridgedBase;
757
+
758
+ const extendedBase = extendFinalPageOverlayRectToFlowHeight(
759
+ bridgedBase,
760
+ flowHeight,
761
+ );
762
+ if (!origin || !scrollRoot) return extendedBase;
763
+
764
+ // The common warm path has geometry and DOM flow in agreement. Avoid the
765
+ // full boundary-widget scan unless the PM flow is visibly taller than the
766
+ // geometry stack. When it is taller, the paper-card layer must follow the
767
+ // in-flow boundaries so content remains on paper instead of canvas.
768
+ if (flowHeight <= pageOverlayLastBottom(bridgedBase) + 1) {
769
+ return extendedBase;
770
+ }
771
+
772
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
773
+ pageCount,
774
+ visiblePageIndexRange: null,
775
+ });
776
+ if (widgets.length === 0) return extendedBase;
777
+
778
+ const flowRects = resolvePageOverlayRects({
779
+ widgets,
780
+ pageCount,
781
+ scrollHeight: flowHeight,
782
+ visiblePageIndexRange: null,
783
+ });
784
+ const merged = mergePageOverlayRectsByPageIndex(extendedBase, flowRects);
785
+ return extendFinalPageOverlayRectToFlowHeight(merged, flowHeight);
786
+ },
787
+ [scrollRoot],
788
+ );
789
+
579
790
  const refreshRectsNow = React.useCallback(() => {
580
- if (!scrollRoot) {
581
- setRects([]);
582
- return;
583
- }
584
- const origin = overlayRootRef.current;
585
791
  const pageCount = facet.getPageCount();
586
792
 
587
793
  // DS-C2 — first try the UI API seam so presentation code does not
@@ -608,12 +814,12 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
608
814
  const uiRects = resolvePageOverlayRectsFromUiApi(
609
815
  ui,
610
816
  pageCount,
611
- visiblePageIndexRange,
817
+ null,
612
818
  pageIds,
613
819
  );
614
820
  if (uiRects !== null) {
615
821
  incrementInvalidationCounter("overlay.page.ui_api.hit");
616
- setRects(uiRects);
822
+ setRectsIfChanged(reconcilePaperRectsWithFlow(uiRects, pageCount));
617
823
  return;
618
824
  }
619
825
  incrementInvalidationCounter("overlay.page.ui_api.fallthrough");
@@ -630,54 +836,65 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
630
836
  const geometryRects = resolvePageOverlayRectsFromGeometry(
631
837
  geometryFacet,
632
838
  pageCount,
633
- visiblePageIndexRange,
839
+ null,
634
840
  );
635
841
  if (geometryRects !== null) {
636
- setRects(geometryRects);
842
+ setRectsIfChanged(reconcilePaperRectsWithFlow(geometryRects, pageCount));
637
843
  return;
638
844
  }
639
845
  }
640
846
 
847
+ if (!scrollRoot) {
848
+ setRectsIfChanged([]);
849
+ return;
850
+ }
851
+ const origin = overlayRootRef.current;
852
+
641
853
  // Cold-open / pre-paint DOM fallback — warm path early-returned
642
854
  // above via `geometryFacet` or the UI-API resolver. Lines below
643
855
  // fire only before the first render frame.
644
856
  if (origin) {
645
857
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
646
858
  pageCount,
647
- visiblePageIndexRange,
859
+ visiblePageIndexRange: null,
648
860
  });
649
861
  // geometry:allow-dom-fallback
650
862
  const originRect = origin.getBoundingClientRect();
651
- setRects(
652
- resolvePageOverlayRects({
653
- widgets,
654
- pageCount,
655
- scrollHeight:
656
- // geometry:allow-dom-fallback
657
- origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
658
- visiblePageIndexRange,
659
- }),
660
- );
863
+ const domRects = resolvePageOverlayRects({
864
+ widgets,
865
+ pageCount,
866
+ scrollHeight:
867
+ // geometry:allow-dom-fallback
868
+ origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
869
+ visiblePageIndexRange: null,
870
+ });
871
+ setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
661
872
  } else {
662
873
  const widgets = measureWidgetsViaOffsetChain(scrollRoot, {
663
874
  pageCount,
664
- visiblePageIndexRange,
875
+ visiblePageIndexRange: null,
665
876
  });
666
- setRects(
667
- resolvePageOverlayRects({
668
- widgets,
669
- pageCount,
670
- // geometry:allow-dom-fallback
671
- scrollHeight: scrollRoot.clientHeight,
672
- visiblePageIndexRange,
673
- }),
674
- );
877
+ const domRects = resolvePageOverlayRects({
878
+ widgets,
879
+ pageCount,
880
+ // geometry:allow-dom-fallback
881
+ scrollHeight: scrollRoot.clientHeight,
882
+ visiblePageIndexRange: null,
883
+ });
884
+ setRectsIfChanged(reconcilePaperRectsWithFlow(domRects, pageCount));
675
885
  }
676
- }, [facet, geometryFacet, scrollRoot, ui, visiblePageIndexRange]);
886
+ }, [
887
+ facet,
888
+ geometryFacet,
889
+ reconcilePaperRectsWithFlow,
890
+ scrollRoot,
891
+ setRectsIfChanged,
892
+ ui,
893
+ ]);
677
894
 
678
895
  const refreshRects = React.useCallback(() => {
679
896
  if (!scrollRoot) {
680
- setRects([]);
897
+ refreshRectsNow();
681
898
  return;
682
899
  }
683
900
  const runtime = scrollRoot.ownerDocument?.defaultView as
@@ -724,6 +941,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
724
941
  // Observe scroll-root size changes so zoom, viewport resize, or font
725
942
  // loading re-trigger measurement without a full app re-render.
726
943
  React.useEffect(() => {
944
+ if (geometryFacet) return;
727
945
  if (!scrollRoot) return;
728
946
  const runtime = scrollRoot.ownerDocument?.defaultView as
729
947
  | (Window & { ResizeObserver?: typeof ResizeObserver })
@@ -732,7 +950,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
732
950
  const observer = new runtime.ResizeObserver(() => refreshRects());
733
951
  observer.observe(scrollRoot);
734
952
  return () => observer.disconnect();
735
- }, [scrollRoot, refreshRects]);
953
+ }, [geometryFacet, scrollRoot, refreshRects]);
736
954
 
737
955
  // Observe DOM mutations so PM re-renders (page-break widgets added /
738
956
  // removed on relayout) re-trigger measurement. We filter to
@@ -750,6 +968,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
750
968
  // times within one animation frame coalesces into one measurement
751
969
  // pass instead of a per-mutation re-render storm.
752
970
  React.useEffect(() => {
971
+ if (geometryFacet) return;
753
972
  if (!scrollRoot) return;
754
973
  const runtime = scrollRoot.ownerDocument?.defaultView as
755
974
  | (Window & { MutationObserver?: typeof MutationObserver })
@@ -775,7 +994,7 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
775
994
  // actually matters for the overlay's rect math.
776
995
  observer.observe(scrollRoot, { childList: true, subtree: false });
777
996
  return () => observer.disconnect();
778
- }, [scrollRoot, refreshRects]);
997
+ }, [geometryFacet, scrollRoot, refreshRects]);
779
998
 
780
999
  // Always render the overlay root so the ref resolves on first paint.
781
1000
  // Without the root element we never get a `getBoundingClientRect()`
@@ -279,6 +279,28 @@ export const editorSchema = new Schema({
279
279
  const indentHanging = node.attrs.indentHanging as number | null;
280
280
  if (indentHanging) styles.push(`text-indent: -${indentHanging / 20}pt`);
281
281
  else if (indentFirstLine) styles.push(`text-indent: ${indentFirstLine / 20}pt`);
282
+ // Numbering marker-lane outdent (coord-04 §1.21 — "legal numbering
283
+ // touches the left margin / clips into page chrome"). Word/LO model:
284
+ // a numbered paragraph occupies `[markerLane.start, textColumn.right]`;
285
+ // the marker lane lives at `[textStart - hanging, textStart]`. When
286
+ // `hanging > indentLeft` the marker starts at negative x relative to
287
+ // the paragraph's content-frame origin — a legitimate Word scenario
288
+ // (w:ind w:left="360" w:hanging="720"). The marker span's
289
+ // `margin-left: -markerWidth` (emitted below) depends on the
290
+ // paragraph's content box actually reaching that far left; without
291
+ // this outdent the marker slides off the page frame and clips into
292
+ // surrounding chrome. `margin-left` expands the paragraph's content
293
+ // box leftward while keeping `padding-left` (body-text alignment)
294
+ // untouched.
295
+ const numberingMarkerStartTwips = node.attrs.numberingMarkerStart as
296
+ | number
297
+ | null;
298
+ if (
299
+ typeof numberingMarkerStartTwips === "number" &&
300
+ numberingMarkerStartTwips < 0
301
+ ) {
302
+ styles.push(`margin-left: ${numberingMarkerStartTwips / 20}pt`);
303
+ }
282
304
  const shadingColor = safeHexColor(node.attrs.shadingFill as string | null);
283
305
  if (shadingColor) styles.push(`background-color: ${shadingColor}`);
284
306
  // Paragraph borders. Reads the PublicBorderSpec shape
@@ -378,6 +400,27 @@ export const editorSchema = new Schema({
378
400
  if (contextualSpacingAfter) {
379
401
  attrs["data-contextual-spacing-after"] = "true";
380
402
  }
403
+ // Numbering geometry debug visibility (coord-04 §1.21 Deliverable D):
404
+ // expose the resolved marker lane on the paragraph element so parity
405
+ // investigators can inspect truth-vs-render without re-deriving the
406
+ // geometry. Values are twips; consumers convert at read time.
407
+ if (typeof numberingMarkerStartTwips === "number") {
408
+ attrs["data-numbering-marker-start-twips"] = String(numberingMarkerStartTwips);
409
+ }
410
+ const _numberingMarkerWidthTwips = node.attrs.numberingMarkerWidth as
411
+ | number
412
+ | null;
413
+ if (
414
+ typeof _numberingMarkerWidthTwips === "number" &&
415
+ _numberingMarkerWidthTwips > 0
416
+ ) {
417
+ attrs["data-numbering-marker-width-twips"] = String(
418
+ _numberingMarkerWidthTwips,
419
+ );
420
+ }
421
+ if (typeof indentLeft === "number") {
422
+ attrs["data-numbering-body-start-twips"] = String(indentLeft);
423
+ }
381
424
  if (styles.length > 0) attrs.style = styles.join("; ");
382
425
  const numberingPrefix = node.attrs.numberingPrefix as string | null;
383
426
  const numberingLevel = node.attrs.numberingLevel as number | null;
@@ -453,6 +496,17 @@ export const editorSchema = new Schema({
453
496
  if (hasResolvedMarkerWidth) {
454
497
  // P13.a: emit marker geometry in pt (twips/20 == pt) so it
455
498
  // self-scales under CSS `zoom` and matches Word's intent.
499
+ //
500
+ // Marker-lane contract (coord-04 §1.21 — Word/LO model):
501
+ // the marker occupies `[markerStart, markerStart + markerWidth]`
502
+ // where `markerStart = textStart - hanging`. The negative
503
+ // `margin-left` pulls the marker leftward by its own width into
504
+ // the paragraph's padding zone (`textStart - markerWidth`, since
505
+ // `padding-left = textStart`). When `markerStart < 0` the
506
+ // paragraph itself carries a compensating negative `margin-left`
507
+ // (emitted above at the paragraph-style branch) so its content
508
+ // box reaches far enough left to hold the marker without
509
+ // clipping into page chrome.
456
510
  const markerWidthPt = Math.max(1, numberingMarkerWidth / 20);
457
511
  prefixStyles.push(
458
512
  `width: ${markerWidthPt}pt`,
@@ -462,7 +516,6 @@ export const editorSchema = new Schema({
462
516
  `margin-right: 0`,
463
517
  `overflow: visible`,
464
518
  );
465
- void numberingMarkerStart; // consumed via paragraph padding-left geometry
466
519
  } else {
467
520
  prefixStyles.push(
468
521
  `min-width: ${fallbackMinWidth}ch`,
@@ -205,6 +205,25 @@ export function buildParagraphStyle(
205
205
  if (indentHanging) style.textIndent = `-${indentHanging / 20}pt`;
206
206
  else if (indentFirstLine) style.textIndent = `${indentFirstLine / 20}pt`;
207
207
 
208
+ // Numbering marker-lane outdent (coord-04 §1.21 — "legal numbering
209
+ // touches the left margin / clips into page chrome"). Word/LO model:
210
+ // a numbered paragraph occupies `[markerLane.start, textColumn.right]`;
211
+ // the marker lane lives at `[textStart - hanging, textStart]`. When
212
+ // `hanging > indentLeft` the marker starts at negative x relative to
213
+ // the paragraph's content-frame origin — legitimate Word (e.g.
214
+ // w:ind w:left="360" w:hanging="720"). The marker span's
215
+ // `margin-left: -markerWidth` (emitted by `buildMarkerStyle`)
216
+ // depends on the paragraph's content box reaching that far left;
217
+ // without this outdent the marker slides off the page frame and
218
+ // clips into surrounding chrome. `margin-left` expands the
219
+ // paragraph's content box leftward while keeping body-text
220
+ // alignment (via `padding-left`) untouched. Mirrors the PM branch
221
+ // in `pm-schema.ts::paragraph.toDOM`.
222
+ const markerLaneStart = block.resolvedNumbering?.geometry.markerLane?.start;
223
+ if (typeof markerLaneStart === "number" && markerLaneStart < 0) {
224
+ style.marginLeft = `${markerLaneStart / 20}pt`;
225
+ }
226
+
208
227
  // Shading
209
228
  const shadingFill = block.shading?.fill;
210
229
  const shadingColor = safeHexColor(shadingFill);
@@ -341,6 +360,17 @@ export function buildMarkerStyle(
341
360
  const hasResolvedMarkerWidth = typeof markerWidth === "number" && markerWidth > 0;
342
361
  if (hasResolvedMarkerWidth) {
343
362
  // P13.a: emit marker geometry in pt so it self-scales under CSS `zoom`.
363
+ //
364
+ // Marker-lane contract (coord-04 §1.21 — Word/LO model): marker occupies
365
+ // `[markerStart, markerStart + markerWidth]` where
366
+ // `markerStart = textStart - hanging`. The negative `margin-left` pulls
367
+ // the marker leftward by its own width into the paragraph's padding
368
+ // zone. When `markerStart < 0` the paragraph carries a compensating
369
+ // negative `margin-left` via `buildParagraphStyle`, so the content box
370
+ // reaches far enough left to hold the marker without clipping into
371
+ // page chrome. `markerStart` is therefore consumed via the paragraph
372
+ // geometry — the marker span itself stays at `-markerWidth` relative
373
+ // to the paragraph's (now-outdented) content-box left.
344
374
  const markerWidthPt = Math.max(1, markerWidth! / 20);
345
375
  style.width = `${markerWidthPt}pt`;
346
376
  style.minWidth = `${markerWidthPt}pt`;
@@ -348,7 +378,7 @@ export function buildMarkerStyle(
348
378
  style.marginLeft = `-${markerWidthPt}pt`;
349
379
  style.marginRight = 0;
350
380
  style.overflow = "visible";
351
- void markerStart; // consumed via paragraph padding-left geometry
381
+ void markerStart;
352
382
  } else {
353
383
  const fallbackMinWidth = Math.min(Math.max(prefix.length + 1, 4), 14);
354
384
  const fallbackMarginRight =
@@ -126,6 +126,9 @@ function ParagraphBlock({
126
126
  "data-heading-level"?: string;
127
127
  "data-numbered"?: string;
128
128
  "data-contextual-spacing"?: string;
129
+ "data-numbering-marker-start-twips"?: string;
130
+ "data-numbering-marker-width-twips"?: string;
131
+ "data-numbering-body-start-twips"?: string;
129
132
  } = {
130
133
  className: classes.join(" "),
131
134
  style: Object.keys(pStyle).length > 0 ? pStyle : undefined,
@@ -140,6 +143,18 @@ function ParagraphBlock({
140
143
  if (block.contextualSpacing) {
141
144
  attrs["data-contextual-spacing"] = "true";
142
145
  }
146
+ // Numbering geometry debug visibility (coord-04 §1.21 Deliverable D):
147
+ // mirror the PM path so parity investigators can read the resolved
148
+ // marker lane directly off the DOM on both page 1 (PM) and pages 2+
149
+ // (static) renders. Values are twips.
150
+ const debugMarkerLane = block.resolvedNumbering?.geometry?.markerLane;
151
+ if (debugMarkerLane) {
152
+ attrs["data-numbering-marker-start-twips"] = String(debugMarkerLane.start);
153
+ if (debugMarkerLane.width > 0) {
154
+ attrs["data-numbering-marker-width-twips"] = String(debugMarkerLane.width);
155
+ }
156
+ attrs["data-numbering-body-start-twips"] = String(debugMarkerLane.textStart);
157
+ }
143
158
  if (block.bidi) {
144
159
  attrs.dir = "rtl";
145
160
  }
@@ -11,6 +11,7 @@ import type {
11
11
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection.ts";
12
12
  import {
13
13
  measureWidgetsViaBoundingRect,
14
+ resolvePageOverlayRectsFromGeometry,
14
15
  resolvePageOverlayRects,
15
16
  type PageOverlayRect,
16
17
  type VisiblePageIndexRange,
@@ -60,6 +61,19 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
60
61
  const [pageRects, setPageRects] = React.useState<readonly PageOverlayRect[]>([]);
61
62
 
62
63
  const refreshPageRectsNow = React.useCallback(() => {
64
+ const pageCount = facet.getPageCount();
65
+ if (geometryFacet) {
66
+ const geometryRects = resolvePageOverlayRectsFromGeometry(
67
+ geometryFacet,
68
+ pageCount,
69
+ visiblePageIndexRange,
70
+ );
71
+ if (geometryRects !== null) {
72
+ setPageRects(geometryRects);
73
+ return;
74
+ }
75
+ }
76
+
63
77
  if (!scrollRoot) {
64
78
  setPageRects([]);
65
79
  return;
@@ -69,7 +83,6 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
69
83
  setPageRects([]);
70
84
  return;
71
85
  }
72
- const pageCount = facet.getPageCount();
73
86
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
74
87
  pageCount,
75
88
  visiblePageIndexRange,
@@ -84,11 +97,11 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
84
97
  visiblePageIndexRange,
85
98
  }),
86
99
  );
87
- }, [facet, scrollRoot, visiblePageIndexRange]);
100
+ }, [facet, geometryFacet, scrollRoot, visiblePageIndexRange]);
88
101
 
89
102
  const refreshPageRects = React.useCallback(() => {
90
103
  if (!scrollRoot) {
91
- setPageRects([]);
104
+ refreshPageRectsNow();
92
105
  return;
93
106
  }
94
107
  const runtime = scrollRoot.ownerDocument?.defaultView as
@@ -125,6 +138,9 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
125
138
  }, [refreshPageRects, renderFrameRevision, scrollRoot]);
126
139
 
127
140
  React.useEffect(() => {
141
+ if (geometryFacet) {
142
+ return;
143
+ }
128
144
  if (!scrollRoot) {
129
145
  return;
130
146
  }
@@ -137,9 +153,12 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
137
153
  const observer = new runtime.ResizeObserver(() => refreshPageRects());
138
154
  observer.observe(scrollRoot);
139
155
  return () => observer.disconnect();
140
- }, [refreshPageRects, scrollRoot]);
156
+ }, [geometryFacet, refreshPageRects, scrollRoot]);
141
157
 
142
158
  React.useEffect(() => {
159
+ if (geometryFacet) {
160
+ return;
161
+ }
143
162
  if (!scrollRoot) {
144
163
  return;
145
164
  }
@@ -163,7 +182,7 @@ export const TwFloatingImageLayer: React.FC<TwFloatingImageLayerProps> = ({
163
182
  });
164
183
  observer.observe(scrollRoot, { childList: true, subtree: false });
165
184
  return () => observer.disconnect();
166
- }, [refreshPageRects, scrollRoot]);
185
+ }, [geometryFacet, refreshPageRects, scrollRoot]);
167
186
 
168
187
  const items = React.useMemo(() => {
169
188
  const pxPerTwip = geometryFacet?.getRenderZoom()?.pxPerTwip;