@beyondwork/docx-react-component 1.0.41 → 1.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -1
- package/src/api/awareness-identity-types.ts +35 -0
- package/src/api/comment-negotiation-types.ts +130 -0
- package/src/api/comment-presentation-types.ts +106 -0
- package/src/api/external-custody-types.ts +74 -0
- package/src/api/participants-types.ts +18 -0
- package/src/api/public-types.ts +347 -4
- package/src/api/scope-metadata-resolver-types.ts +88 -0
- package/src/core/commands/formatting-commands.ts +1 -1
- package/src/core/commands/index.ts +568 -1
- package/src/index.ts +118 -1
- package/src/io/export/escape-xml-attribute.ts +26 -0
- package/src/io/export/external-send.ts +188 -0
- package/src/io/export/serialize-comments.ts +13 -16
- package/src/io/export/serialize-footnotes.ts +17 -24
- package/src/io/export/serialize-headers-footers.ts +17 -24
- package/src/io/export/serialize-main-document.ts +59 -62
- package/src/io/export/serialize-numbering.ts +20 -27
- package/src/io/export/serialize-runtime-revisions.ts +2 -9
- package/src/io/export/serialize-tables.ts +8 -15
- package/src/io/export/table-properties-xml.ts +25 -32
- package/src/io/import/external-reimport.ts +40 -0
- package/src/io/ooxml/bw-xml.ts +244 -0
- package/src/io/ooxml/canonicalize-payload.ts +301 -0
- package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
- package/src/io/ooxml/comment-presentation-payload.ts +311 -0
- package/src/io/ooxml/external-custody-payload.ts +102 -0
- package/src/io/ooxml/participants-payload.ts +97 -0
- package/src/io/ooxml/payload-signature.ts +112 -0
- package/src/io/ooxml/workflow-payload-validator.ts +271 -0
- package/src/io/ooxml/workflow-payload.ts +146 -7
- package/src/runtime/awareness-identity.ts +173 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab-session-bridge.ts +157 -0
- package/src/runtime/collab-session-facet.ts +193 -0
- package/src/runtime/collab-session.ts +273 -0
- package/src/runtime/comment-negotiation-sync.ts +91 -0
- package/src/runtime/comment-negotiation.ts +158 -0
- package/src/runtime/comment-presentation.ts +223 -0
- package/src/runtime/document-runtime.ts +280 -93
- package/src/runtime/external-send-runtime.ts +117 -0
- package/src/runtime/layout/docx-font-loader.ts +11 -30
- package/src/runtime/layout/inert-layout-facet.ts +2 -0
- package/src/runtime/layout/layout-engine-instance.ts +122 -12
- package/src/runtime/layout/page-graph.ts +79 -7
- package/src/runtime/layout/paginated-layout-engine.ts +230 -34
- package/src/runtime/layout/public-facet.ts +185 -13
- package/src/runtime/layout/table-row-split.ts +316 -0
- package/src/runtime/markdown-sanitizer.ts +132 -0
- package/src/runtime/participants.ts +134 -0
- package/src/runtime/resign-payload.ts +120 -0
- package/src/runtime/tamper-gate.ts +157 -0
- package/src/runtime/workflow-markup.ts +9 -0
- package/src/runtime/workflow-rail-segments.ts +244 -5
- package/src/ui/WordReviewEditor.tsx +587 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +11 -0
- package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
- package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
- package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
- package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
- package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
- package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
- package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
- package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
- package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
- package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
- package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
- package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
- package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
- package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
- package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
- package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
- package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
- package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
- package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
- package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
- package/src/ui-tailwind/index.ts +32 -0
- package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
- package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
- package/src/ui-tailwind/theme/editor-theme.css +25 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwPageStackOverlayLayer — per-page paper-frame overlays for pages 2..N.
|
|
3
|
+
*
|
|
4
|
+
* Before this layer, a multi-page document rendered as ONE long page-shaped
|
|
5
|
+
* container with the `wre-page-chrome` shadow + border painted on the outer
|
|
6
|
+
* workspace `<div>`. The inter-page widgets from
|
|
7
|
+
* `pm-page-break-decorations.ts` produced footer + canvas gap + header
|
|
8
|
+
* visuals between pages, but the outer shadow spanned the whole stack — so
|
|
9
|
+
* the user perceived one long paper, not N distinct papers.
|
|
10
|
+
*
|
|
11
|
+
* This layer composes over the PM surface inside the chrome overlay and
|
|
12
|
+
* paints an additional rounded-rectangle overlay behind each page's body
|
|
13
|
+
* region. Page 1 keeps its shadow from the outer wrapper (the `wre-page-
|
|
14
|
+
* chrome` class); page 2..N get their own paper-frame overlay via this
|
|
15
|
+
* layer. The overlays are purely decorative — `pointer-events: none`,
|
|
16
|
+
* `aria-hidden="true"`, z-index underneath the PM text — so they never
|
|
17
|
+
* interfere with selection, caret, or chrome interaction.
|
|
18
|
+
*
|
|
19
|
+
* Positioning strategy
|
|
20
|
+
* --------------------
|
|
21
|
+
*
|
|
22
|
+
* PM renders the body as a single continuous flow and the P3.a
|
|
23
|
+
* `pm-page-break-decorations` widgets mark every page boundary with
|
|
24
|
+
* `data-page-frame-end={prevPageId}` / `data-page-frame-start={nextPageId}`
|
|
25
|
+
* attributes. This layer queries those markers from the overlay's owning
|
|
26
|
+
* scroll root, computes each page's Y-range in DOM coordinate space, and
|
|
27
|
+
* emits one `<div>` per page at that range.
|
|
28
|
+
*
|
|
29
|
+
* - Page 1 Y-range: from the scroll root top to the first
|
|
30
|
+
* `data-page-frame-end="page-0"` widget's offsetTop.
|
|
31
|
+
* - Page K (2..N-1) Y-range: from the bottom of the previous boundary
|
|
32
|
+
* widget (offsetTop + offsetHeight) to the top of the next boundary
|
|
33
|
+
* widget (offsetTop).
|
|
34
|
+
* - Page N Y-range: from the last boundary widget's bottom to the scroll
|
|
35
|
+
* root's bottom.
|
|
36
|
+
*
|
|
37
|
+
* Measurement is refreshed on:
|
|
38
|
+
* - mount (initial layout pass via `useLayoutEffect`)
|
|
39
|
+
* - `renderFrameRevision` tick (emitted by the layout facet after
|
|
40
|
+
* incremental relayout, zoom changes, or render-frame diffs)
|
|
41
|
+
* - scroll root resize (via `ResizeObserver`)
|
|
42
|
+
* - PM body DOM mutations (via `MutationObserver` on the scroll root)
|
|
43
|
+
*
|
|
44
|
+
* The overlay is a runtime-owned projection — it never consults canonical
|
|
45
|
+
* state or the render kernel's emit beyond the page count; it reads the
|
|
46
|
+
* DOM once per render-frame revision and otherwise stays idle.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import * as React from "react";
|
|
50
|
+
import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Measurement
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export interface PageOverlayRect {
|
|
57
|
+
/** Canonical page id from `RuntimePageGraph.pageId`. */
|
|
58
|
+
pageId: string;
|
|
59
|
+
/** 0-based page index matching `facet.getPage(pageIndex)`. */
|
|
60
|
+
pageIndex: number;
|
|
61
|
+
/** Top Y in DOM coords relative to the scroll root. */
|
|
62
|
+
topPx: number;
|
|
63
|
+
/** Bottom Y in DOM coords relative to the scroll root. */
|
|
64
|
+
bottomPx: number;
|
|
65
|
+
/** Rendered height = bottomPx - topPx. */
|
|
66
|
+
heightPx: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Raw boundary measurement — one entry per `[data-page-frame-end]`
|
|
71
|
+
* widget. Exported so DOM measurement helpers and tests can share a
|
|
72
|
+
* single shape.
|
|
73
|
+
*/
|
|
74
|
+
export interface PageBoundaryMeasurement {
|
|
75
|
+
prevPageId: string;
|
|
76
|
+
nextPageId: string;
|
|
77
|
+
/** Widget top edge = bottom of the previous page. */
|
|
78
|
+
topPx: number;
|
|
79
|
+
/** Widget bottom edge = top of the next page. */
|
|
80
|
+
bottomPx: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Pure helper: turn pre-measured page-boundary widget positions into
|
|
85
|
+
* one `PageOverlayRect` per page. No DOM access — the caller supplies
|
|
86
|
+
* `widgets` and `scrollHeight` already expressed in whatever coordinate
|
|
87
|
+
* space the overlay consumer renders in.
|
|
88
|
+
*
|
|
89
|
+
* `pageCount` is the authoritative source of how many pages exist.
|
|
90
|
+
* Partial renders (PM hasn't committed every boundary widget yet) still
|
|
91
|
+
* produce a `pageCount`-long result: any tail page without its own
|
|
92
|
+
* `boundaryBefore` falls back to `topPx = 0` and any tail page without
|
|
93
|
+
* `boundaryAfter` falls back to `bottomPx = scrollHeight`. Synthesized
|
|
94
|
+
* `page-N` ids keep React keys stable across partial→full renders.
|
|
95
|
+
*
|
|
96
|
+
* Exported for tests — unit tests drive this with inline widget data,
|
|
97
|
+
* no DOM fixture required.
|
|
98
|
+
*/
|
|
99
|
+
export function resolvePageOverlayRects(
|
|
100
|
+
input:
|
|
101
|
+
| {
|
|
102
|
+
/** Pre-measured widgets — origin-relative `topPx` / `bottomPx`. */
|
|
103
|
+
widgets: readonly PageBoundaryMeasurement[];
|
|
104
|
+
pageCount: number;
|
|
105
|
+
/** Total scroll-root height in the overlay's coordinate space. */
|
|
106
|
+
scrollHeight: number;
|
|
107
|
+
}
|
|
108
|
+
// Legacy two-arg path preserved for backward compat. Walks
|
|
109
|
+
// offsetTop chain inside the scroll-root; used by harness code
|
|
110
|
+
// paths that never integrated a proper overlay ref.
|
|
111
|
+
| [
|
|
112
|
+
scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll"> | null,
|
|
113
|
+
pageCount: number,
|
|
114
|
+
],
|
|
115
|
+
legacyPageCount?: number,
|
|
116
|
+
): readonly PageOverlayRect[] {
|
|
117
|
+
// Normalize arguments. The two supported shapes:
|
|
118
|
+
// 1. Object: `{widgets, pageCount, scrollHeight}` — purely pure.
|
|
119
|
+
// 2. Positional: `(scrollRoot, pageCount)` — backward-compat; runs
|
|
120
|
+
// `measureWidgetsViaOffsetChain` internally. Kept so existing
|
|
121
|
+
// tests pass without churn.
|
|
122
|
+
let widgets: readonly PageBoundaryMeasurement[];
|
|
123
|
+
let pageCount: number;
|
|
124
|
+
let scrollHeight: number;
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(input)) {
|
|
127
|
+
const [scrollRoot, count] = input;
|
|
128
|
+
if (!scrollRoot || count <= 0) return [];
|
|
129
|
+
widgets = measureWidgetsViaOffsetChain(scrollRoot);
|
|
130
|
+
pageCount = count;
|
|
131
|
+
scrollHeight = scrollRoot.clientHeight;
|
|
132
|
+
} else if (
|
|
133
|
+
input !== null &&
|
|
134
|
+
typeof input === "object" &&
|
|
135
|
+
"widgets" in input
|
|
136
|
+
) {
|
|
137
|
+
widgets = input.widgets;
|
|
138
|
+
pageCount = input.pageCount;
|
|
139
|
+
scrollHeight = input.scrollHeight;
|
|
140
|
+
} else if (input && legacyPageCount !== undefined) {
|
|
141
|
+
// (scrollRoot, pageCount) positional call. `input` is the scroll
|
|
142
|
+
// root, `legacyPageCount` is the count.
|
|
143
|
+
const scrollRoot = input as unknown as Pick<
|
|
144
|
+
HTMLElement,
|
|
145
|
+
"clientHeight" | "querySelectorAll"
|
|
146
|
+
>;
|
|
147
|
+
if (legacyPageCount <= 0) return [];
|
|
148
|
+
widgets = measureWidgetsViaOffsetChain(scrollRoot);
|
|
149
|
+
pageCount = legacyPageCount;
|
|
150
|
+
scrollHeight = scrollRoot.clientHeight;
|
|
151
|
+
} else {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (pageCount <= 0) return [];
|
|
156
|
+
|
|
157
|
+
// Sort boundaries by topPx so page order is stable even if the DOM
|
|
158
|
+
// emits widgets out-of-order (it doesn't today, but the cost is
|
|
159
|
+
// negligible).
|
|
160
|
+
const boundaries = [...widgets].sort((a, b) => a.topPx - b.topPx);
|
|
161
|
+
|
|
162
|
+
const rects: PageOverlayRect[] = [];
|
|
163
|
+
for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
|
|
164
|
+
const boundaryBefore = pageIndex === 0 ? null : boundaries[pageIndex - 1];
|
|
165
|
+
const boundaryAfter =
|
|
166
|
+
pageIndex === pageCount - 1 ? null : boundaries[pageIndex];
|
|
167
|
+
|
|
168
|
+
let pageId: string | null = null;
|
|
169
|
+
if (boundaryBefore) pageId = boundaryBefore.nextPageId;
|
|
170
|
+
else if (boundaryAfter) pageId = boundaryAfter.prevPageId;
|
|
171
|
+
if (!pageId) pageId = `page-${pageIndex}`;
|
|
172
|
+
|
|
173
|
+
const topPx = boundaryBefore ? boundaryBefore.bottomPx : 0;
|
|
174
|
+
const bottomPx = boundaryAfter ? boundaryAfter.topPx : scrollHeight;
|
|
175
|
+
|
|
176
|
+
if (bottomPx <= topPx) continue;
|
|
177
|
+
|
|
178
|
+
rects.push({
|
|
179
|
+
pageId,
|
|
180
|
+
pageIndex,
|
|
181
|
+
topPx,
|
|
182
|
+
bottomPx,
|
|
183
|
+
heightPx: bottomPx - topPx,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return rects;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* DOM measurement via `getBoundingClientRect()`.
|
|
192
|
+
*
|
|
193
|
+
* `originElement` defines the coordinate space the returned
|
|
194
|
+
* `topPx` / `bottomPx` values live in. When the overlay consumer is a
|
|
195
|
+
* React component, `originElement` is typically the component's own
|
|
196
|
+
* root `<div>` (ref via `useRef`). That keeps widget coordinates
|
|
197
|
+
* perfectly aligned with the overlay's own coordinate space regardless
|
|
198
|
+
* of how many positioned ancestors sit between the scroll root and
|
|
199
|
+
* the overlay — the problem P3.b's offset-chain walk silently
|
|
200
|
+
* produced wrong coordinates for.
|
|
201
|
+
*
|
|
202
|
+
* `queryRoot` is the DOM subtree to search for `[data-page-frame-end]`
|
|
203
|
+
* widgets. It can be the same element as `originElement` or a broader
|
|
204
|
+
* ancestor.
|
|
205
|
+
*/
|
|
206
|
+
export function measureWidgetsViaBoundingRect(
|
|
207
|
+
queryRoot: Pick<HTMLElement, "querySelectorAll"> | null,
|
|
208
|
+
originElement: HTMLElement | null,
|
|
209
|
+
): PageBoundaryMeasurement[] {
|
|
210
|
+
if (!queryRoot || !originElement) return [];
|
|
211
|
+
const originRect = originElement.getBoundingClientRect();
|
|
212
|
+
const widgets = Array.from(
|
|
213
|
+
queryRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
|
|
214
|
+
);
|
|
215
|
+
const out: PageBoundaryMeasurement[] = [];
|
|
216
|
+
for (const widget of widgets) {
|
|
217
|
+
const prevPageId = widget.getAttribute("data-page-frame-end");
|
|
218
|
+
const nextPageId = widget.getAttribute("data-page-frame-start");
|
|
219
|
+
if (!prevPageId || !nextPageId) continue;
|
|
220
|
+
const rect = widget.getBoundingClientRect();
|
|
221
|
+
out.push({
|
|
222
|
+
prevPageId,
|
|
223
|
+
nextPageId,
|
|
224
|
+
topPx: rect.top - originRect.top,
|
|
225
|
+
bottomPx: rect.bottom - originRect.top,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Legacy DOM measurement via the `offsetTop` / `offsetParent` chain.
|
|
233
|
+
* Returns offsets in scroll-root-relative coordinates. Retained for
|
|
234
|
+
* the positional `resolvePageOverlayRects(scrollRoot, pageCount)` call
|
|
235
|
+
* signature that predates the `getBoundingClientRect()` path; new code
|
|
236
|
+
* should prefer `measureWidgetsViaBoundingRect(queryRoot, origin)` so
|
|
237
|
+
* widget coordinates align with the overlay's own rendering origin.
|
|
238
|
+
*
|
|
239
|
+
* The walk stops at `scrollRoot` (never-null parent is an escape
|
|
240
|
+
* hatch). Nested positioned ancestors between the widget and the
|
|
241
|
+
* scroll root are summed correctly; the deprecation here is about
|
|
242
|
+
* "scroll root is often not the right origin", not about arithmetic.
|
|
243
|
+
*/
|
|
244
|
+
export function measureWidgetsViaOffsetChain(
|
|
245
|
+
scrollRoot: Pick<HTMLElement, "clientHeight" | "querySelectorAll">,
|
|
246
|
+
): PageBoundaryMeasurement[] {
|
|
247
|
+
const widgets = Array.from(
|
|
248
|
+
scrollRoot.querySelectorAll<HTMLElement>("[data-page-frame-end]"),
|
|
249
|
+
);
|
|
250
|
+
const out: PageBoundaryMeasurement[] = [];
|
|
251
|
+
for (const widget of widgets) {
|
|
252
|
+
const prevPageId = widget.getAttribute("data-page-frame-end");
|
|
253
|
+
const nextPageId = widget.getAttribute("data-page-frame-start");
|
|
254
|
+
if (!prevPageId || !nextPageId) continue;
|
|
255
|
+
const topPx = resolveOffsetTop(widget, scrollRoot);
|
|
256
|
+
const bottomPx = topPx + resolveOffsetHeight(widget);
|
|
257
|
+
out.push({ prevPageId, nextPageId, topPx, bottomPx });
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resolveOffsetTop(
|
|
263
|
+
widget: HTMLElement,
|
|
264
|
+
scrollRoot: Pick<HTMLElement, "clientHeight">,
|
|
265
|
+
): number {
|
|
266
|
+
// jsdom doesn't populate `offsetTop` from layout, but the property is
|
|
267
|
+
// still defined and defaults to 0. Browsers set it relative to the
|
|
268
|
+
// offsetParent. We walk up the offset chain until we reach the scroll
|
|
269
|
+
// root (or exit the document) so the result is scroll-root-relative.
|
|
270
|
+
let node: HTMLElement | null = widget;
|
|
271
|
+
let top = 0;
|
|
272
|
+
while (node) {
|
|
273
|
+
top += node.offsetTop ?? 0;
|
|
274
|
+
const parent = node.offsetParent as HTMLElement | null;
|
|
275
|
+
if (parent === scrollRoot || parent === null) break;
|
|
276
|
+
node = parent;
|
|
277
|
+
}
|
|
278
|
+
return top;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveOffsetHeight(widget: HTMLElement): number {
|
|
282
|
+
return widget.offsetHeight ?? 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Component
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
export interface TwPageStackOverlayLayerProps {
|
|
290
|
+
/** Layout facet — source of the page count. */
|
|
291
|
+
facet: WordReviewEditorLayoutFacet;
|
|
292
|
+
/** Scroll root element whose page-boundary widgets drive measurement. */
|
|
293
|
+
scrollRoot: HTMLElement | null;
|
|
294
|
+
/**
|
|
295
|
+
* Revision tick incremented on `layout_recomputed`, `incremental_relayout`,
|
|
296
|
+
* `render_frame_ready`, or `zoom_changed`. The layer re-measures every
|
|
297
|
+
* time this changes so the overlays stay aligned with content.
|
|
298
|
+
*/
|
|
299
|
+
renderFrameRevision: number;
|
|
300
|
+
/** Optional test id applied to the overlay root. */
|
|
301
|
+
"data-testid"?: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The layer itself. Renders one decorative `<div>` per page with the
|
|
306
|
+
* same card-shadow + rounded border treatment that `.wre-page-chrome`
|
|
307
|
+
* applies to the outer frame today. Page 1's overlay sits flush with
|
|
308
|
+
* the outer frame (effectively redundant — the outer card is already
|
|
309
|
+
* visible); pages 2..N gain their own paper-frame treatment that the
|
|
310
|
+
* outer wrapper cannot draw on its own.
|
|
311
|
+
*/
|
|
312
|
+
export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = ({
|
|
313
|
+
facet,
|
|
314
|
+
scrollRoot,
|
|
315
|
+
renderFrameRevision,
|
|
316
|
+
"data-testid": testId,
|
|
317
|
+
}) => {
|
|
318
|
+
const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
|
|
319
|
+
// P3.d fix: the overlay root acts as the **measurement origin** so
|
|
320
|
+
// widget `topPx` / `bottomPx` are expressed in the exact coordinate
|
|
321
|
+
// space the overlay paints in. Using `scrollRoot` as origin (P3.b
|
|
322
|
+
// default) silently produced wrong coordinates when the overlay
|
|
323
|
+
// rendered inside nested positioned ancestors (pm-body-wrapper inside
|
|
324
|
+
// frame-div inside content-inset inside scroll root).
|
|
325
|
+
const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
|
|
326
|
+
|
|
327
|
+
// P14 hardening: coalesce refresh calls via requestAnimationFrame.
|
|
328
|
+
// Pre-P14 every ResizeObserver / MutationObserver / renderFrameRevision
|
|
329
|
+
// tick fired `refreshRects` synchronously — which does O(N)
|
|
330
|
+
// getBoundingClientRect calls (one per page-break widget plus the
|
|
331
|
+
// overlay-root rect), and every `getBoundingClientRect()` forces the
|
|
332
|
+
// browser to flush pending style + layout calculations. For a
|
|
333
|
+
// 38-page CCEP doc that's 38 forced layouts per keystroke, multiplied
|
|
334
|
+
// by 2–3 refresh triggers per edit (layout_recomputed +
|
|
335
|
+
// incremental_relayout + MutationObserver + maybe page_count_changed)
|
|
336
|
+
// = ~150 forced layouts per keystroke. The editor appeared to lock
|
|
337
|
+
// up during typing on large docs.
|
|
338
|
+
//
|
|
339
|
+
// rAF coalescing flips this so the worst case is **one measurement
|
|
340
|
+
// pass per frame** regardless of how many triggers fire. The
|
|
341
|
+
// `rafHandleRef` protects against double-scheduling; clearing it on
|
|
342
|
+
// unmount prevents the callback from firing after the component
|
|
343
|
+
// tears down.
|
|
344
|
+
const rafHandleRef = React.useRef<number | null>(null);
|
|
345
|
+
|
|
346
|
+
const refreshRectsNow = React.useCallback(() => {
|
|
347
|
+
if (!scrollRoot) {
|
|
348
|
+
setRects([]);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const origin = overlayRootRef.current;
|
|
352
|
+
const pageCount = facet.getPageCount();
|
|
353
|
+
if (origin) {
|
|
354
|
+
const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin);
|
|
355
|
+
const originRect = origin.getBoundingClientRect();
|
|
356
|
+
setRects(
|
|
357
|
+
resolvePageOverlayRects({
|
|
358
|
+
widgets,
|
|
359
|
+
pageCount,
|
|
360
|
+
scrollHeight:
|
|
361
|
+
origin.clientHeight > 0 ? origin.clientHeight : originRect.height,
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
} else {
|
|
365
|
+
setRects(resolvePageOverlayRects([scrollRoot, pageCount]));
|
|
366
|
+
}
|
|
367
|
+
}, [facet, scrollRoot]);
|
|
368
|
+
|
|
369
|
+
const refreshRects = React.useCallback(() => {
|
|
370
|
+
if (!scrollRoot) {
|
|
371
|
+
setRects([]);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
375
|
+
| (Window & {
|
|
376
|
+
requestAnimationFrame?: (cb: () => void) => number;
|
|
377
|
+
cancelAnimationFrame?: (handle: number) => void;
|
|
378
|
+
})
|
|
379
|
+
| null;
|
|
380
|
+
const raf = runtime?.requestAnimationFrame;
|
|
381
|
+
// SSR + older jsdom without rAF: fall back to immediate refresh.
|
|
382
|
+
if (!raf) {
|
|
383
|
+
refreshRectsNow();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Drop duplicate schedules inside the same frame — each subsequent
|
|
387
|
+
// refresh-request short-circuits while one is already pending.
|
|
388
|
+
if (rafHandleRef.current !== null) return;
|
|
389
|
+
rafHandleRef.current = raf(() => {
|
|
390
|
+
rafHandleRef.current = null;
|
|
391
|
+
refreshRectsNow();
|
|
392
|
+
});
|
|
393
|
+
}, [scrollRoot, refreshRectsNow]);
|
|
394
|
+
|
|
395
|
+
// P14: re-measure via the debounced scheduler so multiple
|
|
396
|
+
// `renderFrameRevision` bumps per edit (layout_recomputed +
|
|
397
|
+
// incremental_relayout + page_count_changed + page_field_dirtied)
|
|
398
|
+
// coalesce to one measurement pass per animation frame. `useEffect`
|
|
399
|
+
// instead of `useLayoutEffect` — we don't need to block paint for
|
|
400
|
+
// this decorative overlay; a one-frame delay before page borders
|
|
401
|
+
// reposition is imperceptible.
|
|
402
|
+
React.useEffect(() => {
|
|
403
|
+
refreshRects();
|
|
404
|
+
return () => {
|
|
405
|
+
const runtime = scrollRoot?.ownerDocument?.defaultView as
|
|
406
|
+
| (Window & { cancelAnimationFrame?: (h: number) => void })
|
|
407
|
+
| null;
|
|
408
|
+
if (rafHandleRef.current !== null && runtime?.cancelAnimationFrame) {
|
|
409
|
+
runtime.cancelAnimationFrame(rafHandleRef.current);
|
|
410
|
+
rafHandleRef.current = null;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
}, [refreshRects, renderFrameRevision, scrollRoot]);
|
|
414
|
+
|
|
415
|
+
// Observe scroll-root size changes so zoom, viewport resize, or font
|
|
416
|
+
// loading re-trigger measurement without a full app re-render.
|
|
417
|
+
React.useEffect(() => {
|
|
418
|
+
if (!scrollRoot) return;
|
|
419
|
+
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
420
|
+
| (Window & { ResizeObserver?: typeof ResizeObserver })
|
|
421
|
+
| null;
|
|
422
|
+
if (!runtime?.ResizeObserver) return;
|
|
423
|
+
const observer = new runtime.ResizeObserver(() => refreshRects());
|
|
424
|
+
observer.observe(scrollRoot);
|
|
425
|
+
return () => observer.disconnect();
|
|
426
|
+
}, [scrollRoot, refreshRects]);
|
|
427
|
+
|
|
428
|
+
// Observe DOM mutations so PM re-renders (page-break widgets added /
|
|
429
|
+
// removed on relayout) re-trigger measurement. We filter to
|
|
430
|
+
// childList + subtree changes to avoid needless recomputation on
|
|
431
|
+
// style-only attribute churn.
|
|
432
|
+
//
|
|
433
|
+
// Hardening: ignore mutations whose targets live INSIDE the overlay
|
|
434
|
+
// root itself. Without this filter, the `setRects` → React
|
|
435
|
+
// reconciliation → new/removed `<div>` children → MutationObserver
|
|
436
|
+
// cycle can fire recursively. The filter breaks the cycle cleanly
|
|
437
|
+
// by ignoring every mutation that only touches overlay descendants.
|
|
438
|
+
//
|
|
439
|
+
// P14: the observer callback now routes through the debounced
|
|
440
|
+
// `refreshRects`, so a keystroke burst that fires the observer many
|
|
441
|
+
// times within one animation frame coalesces into one measurement
|
|
442
|
+
// pass instead of a per-mutation re-render storm.
|
|
443
|
+
React.useEffect(() => {
|
|
444
|
+
if (!scrollRoot) return;
|
|
445
|
+
const runtime = scrollRoot.ownerDocument?.defaultView as
|
|
446
|
+
| (Window & { MutationObserver?: typeof MutationObserver })
|
|
447
|
+
| null;
|
|
448
|
+
if (!runtime?.MutationObserver) return;
|
|
449
|
+
const observer = new runtime.MutationObserver((records) => {
|
|
450
|
+
const overlay = overlayRootRef.current;
|
|
451
|
+
if (overlay) {
|
|
452
|
+
const allSelf = records.every(
|
|
453
|
+
(r) => r.target instanceof Node && overlay.contains(r.target),
|
|
454
|
+
);
|
|
455
|
+
if (allSelf) return;
|
|
456
|
+
}
|
|
457
|
+
refreshRects();
|
|
458
|
+
});
|
|
459
|
+
// P14.e: subtree:false eliminates the per-keystroke characterData
|
|
460
|
+
// schedule. Page boundaries only shift on childList changes — the
|
|
461
|
+
// page-frame widget `<div>` is added or removed when pagination
|
|
462
|
+
// emits a new page boundary. characterData mutations (every
|
|
463
|
+
// keystroke!) used to fire the callback even though they never
|
|
464
|
+
// change widget positions; narrowing the scope removes that storm
|
|
465
|
+
// entirely without losing the page-add / page-remove signal that
|
|
466
|
+
// actually matters for the overlay's rect math.
|
|
467
|
+
observer.observe(scrollRoot, { childList: true, subtree: false });
|
|
468
|
+
return () => observer.disconnect();
|
|
469
|
+
}, [scrollRoot, refreshRects]);
|
|
470
|
+
|
|
471
|
+
// Always render the overlay root so the ref resolves on first paint.
|
|
472
|
+
// Without the root element we never get a `getBoundingClientRect()`
|
|
473
|
+
// call, which means the first measurement pass falls back to the
|
|
474
|
+
// offset-chain (wrong coords) and the user sees mis-aligned page
|
|
475
|
+
// frames until the next `renderFrameRevision` tick. Keeping the
|
|
476
|
+
// root element present + empty is cheap (one `<div>`) and makes the
|
|
477
|
+
// ref resolve during the first layout-effect pass.
|
|
478
|
+
if (rects.length === 0) {
|
|
479
|
+
return (
|
|
480
|
+
<div
|
|
481
|
+
ref={overlayRootRef}
|
|
482
|
+
className="wre-page-stack-overlay pointer-events-none absolute inset-0 z-0"
|
|
483
|
+
aria-hidden="true"
|
|
484
|
+
data-testid={testId ?? "page-stack-overlay"}
|
|
485
|
+
data-page-count="0"
|
|
486
|
+
/>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<div
|
|
492
|
+
ref={overlayRootRef}
|
|
493
|
+
className="wre-page-stack-overlay pointer-events-none absolute inset-0 z-0"
|
|
494
|
+
aria-hidden="true"
|
|
495
|
+
data-testid={testId ?? "page-stack-overlay"}
|
|
496
|
+
data-page-count={rects.length}
|
|
497
|
+
>
|
|
498
|
+
{rects.map((rect) => (
|
|
499
|
+
<div
|
|
500
|
+
key={rect.pageId}
|
|
501
|
+
className="wre-page-stack-overlay-frame absolute"
|
|
502
|
+
data-page-index={rect.pageIndex}
|
|
503
|
+
data-page-id={rect.pageId}
|
|
504
|
+
style={{
|
|
505
|
+
top: `${rect.topPx}px`,
|
|
506
|
+
height: `${rect.heightPx}px`,
|
|
507
|
+
left: 0,
|
|
508
|
+
right: 0,
|
|
509
|
+
// The overlay paints decorative paper-edge treatment (border
|
|
510
|
+
// + soft shadow) around each page's vertical slice. It sits
|
|
511
|
+
// ABOVE the PM surface in stacking order so we deliberately
|
|
512
|
+
// leave `backgroundColor` unset — a filled overlay would
|
|
513
|
+
// occlude PM text. Border + box-shadow alone give the
|
|
514
|
+
// 'distinct paper' perception without covering content.
|
|
515
|
+
backgroundColor: "transparent",
|
|
516
|
+
border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
|
|
517
|
+
borderRadius: "var(--radius-page, 4px)",
|
|
518
|
+
boxShadow:
|
|
519
|
+
"0 8px 24px -20px var(--color-page-shadow, rgba(15,23,42,0.38))",
|
|
520
|
+
}}
|
|
521
|
+
/>
|
|
522
|
+
))}
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export default TwPageStackOverlayLayer;
|