@beyondwork/docx-react-component 1.0.81 → 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.
- 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 +71 -1
- 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-page-markers.ts +273 -24
- package/src/ui-tailwind/theme/editor-theme.css +3 -5
- package/src/ui-tailwind/tw-review-workspace.tsx +3 -0
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Progressive-disclosure discipline (designsystem.md §2.1 principle 4):
|
|
14
14
|
* - Actions without a wired callback are hidden, not rendered
|
|
15
|
-
* disabled
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* disabled unless the registry marks them as important signposts
|
|
16
|
+
* by returning `"disabled"` from `when()`. Those rows stay visible
|
|
17
|
+
* with an explanation so users can see that the product recognizes
|
|
18
|
+
* the workflow even when the host has not wired it yet.
|
|
18
19
|
* - Mode-filtered actions (e.g. `scope-open-card` is
|
|
19
20
|
* workflow/review-only) only appear when the composition mode
|
|
20
21
|
* allows them.
|
|
@@ -33,7 +34,6 @@ import type {
|
|
|
33
34
|
CommandPaletteGroup,
|
|
34
35
|
CommandPaletteItem,
|
|
35
36
|
} from "./tw-command-palette";
|
|
36
|
-
import type { ContextMenuGroupId } from "./tw-context-menu";
|
|
37
37
|
import type { ShortcutKey } from "./tw-shortcut-hint";
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -90,43 +90,63 @@ export function formatShortcutForPalette(
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* Product-facing command palette buckets. These are intentionally not
|
|
94
|
+
* the context-menu `ContextMenuGroupId`s: right-click menus group by
|
|
95
|
+
* local action family, while the palette groups by user intent.
|
|
96
96
|
*/
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
type ProductPaletteGroupId =
|
|
98
|
+
| "commands"
|
|
99
|
+
| "search"
|
|
100
|
+
| "navigation"
|
|
101
|
+
| "mode"
|
|
102
|
+
| "diagnostics";
|
|
103
|
+
|
|
104
|
+
const GROUP_LABELS: Record<ProductPaletteGroupId, string> = {
|
|
105
|
+
commands: "Commands",
|
|
106
|
+
search: "Search",
|
|
107
|
+
navigation: "Navigation",
|
|
108
|
+
mode: "Mode",
|
|
109
|
+
diagnostics: "Diagnostics",
|
|
104
110
|
};
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
* Stable group order
|
|
108
|
-
* designsystem.md §2.1 (clipboard first because it's always available,
|
|
109
|
-
* review + comment next because they're the primary legal-review
|
|
110
|
-
* actions, table + formatting after, misc last).
|
|
113
|
+
* Stable product group order per editor-product Slice 3.
|
|
111
114
|
*/
|
|
112
|
-
const GROUP_ORDER: readonly
|
|
113
|
-
"
|
|
114
|
-
"
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"
|
|
118
|
-
"misc",
|
|
115
|
+
const GROUP_ORDER: readonly ProductPaletteGroupId[] = [
|
|
116
|
+
"commands",
|
|
117
|
+
"search",
|
|
118
|
+
"navigation",
|
|
119
|
+
"mode",
|
|
120
|
+
"diagnostics",
|
|
119
121
|
];
|
|
120
122
|
|
|
121
|
-
function
|
|
123
|
+
function actionAvailability(
|
|
122
124
|
action: EditorAction,
|
|
123
125
|
ctx: EditorActionDispatchContext,
|
|
124
|
-
):
|
|
126
|
+
): false | "enabled" | "disabled" {
|
|
125
127
|
const verdict = action.when ? action.when(ctx) : true;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
if (verdict === false) return false;
|
|
129
|
+
return verdict === "disabled" ? "disabled" : "enabled";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function paletteGroupForAction(action: EditorAction): ProductPaletteGroupId {
|
|
133
|
+
switch (action.id) {
|
|
134
|
+
case "find":
|
|
135
|
+
case "replace":
|
|
136
|
+
return "search";
|
|
137
|
+
case "go-to":
|
|
138
|
+
case "jump-to-comment-in-rail":
|
|
139
|
+
case "scope-jump-in-rail":
|
|
140
|
+
case "scope-open-card":
|
|
141
|
+
return "navigation";
|
|
142
|
+
case "scope-mark-resolved":
|
|
143
|
+
return "mode";
|
|
144
|
+
case "object-info":
|
|
145
|
+
case "print":
|
|
146
|
+
return "diagnostics";
|
|
147
|
+
default:
|
|
148
|
+
return "commands";
|
|
149
|
+
}
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
export interface BuildPaletteGroupsInput {
|
|
@@ -145,15 +165,18 @@ export function buildPaletteGroupsFromRegistry(
|
|
|
145
165
|
dismiss: input.dismiss,
|
|
146
166
|
};
|
|
147
167
|
|
|
148
|
-
const byGroup = new Map<
|
|
168
|
+
const byGroup = new Map<ProductPaletteGroupId, CommandPaletteItem[]>();
|
|
149
169
|
|
|
150
170
|
for (const action of EDITOR_ACTION_REGISTRY) {
|
|
151
171
|
if (action.modes && !action.modes.has(input.mode)) continue;
|
|
152
|
-
|
|
172
|
+
const availability = actionAvailability(action, ctx);
|
|
173
|
+
if (availability === false) continue;
|
|
153
174
|
|
|
154
175
|
const item: CommandPaletteItem = {
|
|
155
176
|
id: action.id,
|
|
156
177
|
label: action.label,
|
|
178
|
+
...(action.description ? { description: action.description } : {}),
|
|
179
|
+
...(availability === "disabled" ? { disabled: true } : {}),
|
|
157
180
|
// Chrome Closure Pass · Task 4 (designsystem.md §6.25) —
|
|
158
181
|
// preserve the registry shortcut so the palette row shows
|
|
159
182
|
// the same hint as the matching context-menu / tooltip.
|
|
@@ -163,9 +186,10 @@ export function buildPaletteGroupsFromRegistry(
|
|
|
163
186
|
onInvoke: () => action.run(ctx),
|
|
164
187
|
};
|
|
165
188
|
|
|
166
|
-
const
|
|
189
|
+
const groupId = paletteGroupForAction(action);
|
|
190
|
+
const bucket = byGroup.get(groupId) ?? [];
|
|
167
191
|
bucket.push(item);
|
|
168
|
-
byGroup.set(
|
|
192
|
+
byGroup.set(groupId, bucket);
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
const groups: CommandPaletteGroup[] = [];
|
|
@@ -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
|
-
|
|
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`,
|