@beyondwork/docx-react-component 1.0.81 → 1.0.83
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 +1 -1
- package/src/api/public-types.ts +2 -1
- package/src/api/v3/_runtime-handle.ts +4 -0
- package/src/api/v3/runtime/document.ts +61 -3
- package/src/api/v3/runtime/review.ts +55 -2
- package/src/io/normalize/normalize-text.ts +4 -1
- package/src/io/ooxml/parse-drawing.ts +4 -0
- package/src/model/canonical-document.ts +2 -0
- package/src/ui/WordReviewEditor.tsx +243 -2
- package/src/ui-tailwind/chrome/editor-action-registry.ts +220 -0
- package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +59 -35
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +256 -37
- package/src/ui-tailwind/editor-surface/pm-schema.ts +54 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +31 -1
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -0
- package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +24 -5
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +35 -6
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +333 -43
- package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +32 -6
- package/src/ui-tailwind/review-workspace/use-page-markers.ts +273 -24
- package/src/ui-tailwind/theme/editor-theme.css +3 -5
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +21 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +4 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
817
|
+
null,
|
|
612
818
|
pageIds,
|
|
613
819
|
);
|
|
614
820
|
if (uiRects !== null) {
|
|
615
821
|
incrementInvalidationCounter("overlay.page.ui_api.hit");
|
|
616
|
-
|
|
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
|
-
|
|
839
|
+
null,
|
|
634
840
|
);
|
|
635
841
|
if (geometryRects !== null) {
|
|
636
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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;
|