@beyondwork/docx-react-component 1.0.42 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +30 -41
  2. package/src/api/editor-state-types.ts +110 -0
  3. package/src/api/public-types.ts +194 -1
  4. package/src/core/commands/index.ts +33 -8
  5. package/src/core/search/search-text.ts +15 -2
  6. package/src/index.ts +13 -0
  7. package/src/io/docx-session.ts +672 -2
  8. package/src/io/load-scheduler.ts +230 -0
  9. package/src/io/normalize/normalize-text.ts +83 -0
  10. package/src/io/ooxml/workflow-payload-validator.ts +97 -1
  11. package/src/io/ooxml/workflow-payload.ts +172 -1
  12. package/src/runtime/collab-session.ts +1 -1
  13. package/src/runtime/document-runtime.ts +364 -36
  14. package/src/runtime/editor-state-channel.ts +544 -0
  15. package/src/runtime/editor-state-integration.ts +217 -0
  16. package/src/runtime/layout/index.ts +2 -0
  17. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  18. package/src/runtime/layout/layout-engine-instance.ts +17 -2
  19. package/src/runtime/layout/paginated-layout-engine.ts +211 -14
  20. package/src/runtime/layout/public-facet.ts +400 -1
  21. package/src/runtime/perf-counters.ts +28 -0
  22. package/src/runtime/render/render-frame-types.ts +17 -0
  23. package/src/runtime/render/render-kernel.ts +172 -29
  24. package/src/runtime/surface-projection.ts +10 -5
  25. package/src/runtime/workflow-markup.ts +71 -16
  26. package/src/ui/WordReviewEditor.tsx +67 -45
  27. package/src/ui/editor-command-bag.ts +14 -0
  28. package/src/ui/editor-runtime-boundary.ts +110 -11
  29. package/src/ui/editor-shell-view.tsx +10 -0
  30. package/src/ui/editor-surface-controller.tsx +5 -0
  31. package/src/ui/headless/selection-helpers.ts +10 -0
  32. package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
  33. package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
  34. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
  35. package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
  36. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
  37. package/src/ui-tailwind/editor-surface/pm-schema.ts +152 -4
  38. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +35 -7
  39. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
  40. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
  41. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -0
  42. package/src/ui-tailwind/index.ts +5 -1
  43. package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
  44. package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
  45. package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
  46. package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
  47. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
  48. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
  49. package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
  50. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
  51. package/src/ui-tailwind/tw-review-workspace.tsx +172 -94
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwFootnoteArea (P8.6)
8
+ //
9
+ // Read-only area mounted at an absolute pixel rectangle inside a per-page
10
+ // chrome overlay. Renders:
11
+ // 1. A default 1px separator (1/3 of the parent width) along the top edge,
12
+ // and
13
+ // 2. The ordered footnote bodies via `TwRegionBlockRenderer` (P8.4).
14
+ //
15
+ // The chrome layer (P8.8) computes the `topPx / leftPx / widthPx / heightPx`
16
+ // rectangle from the page graph's `regions.footnotes` geometry and mounts
17
+ // this component above the footer band.
18
+ //
19
+ // The default separator matches Word's implicit footnote separator; reading
20
+ // the `w:separator` / `w:continuationSeparator` parts from the footnotes
21
+ // package is deferred to P8.b polish. No active-slot / portal plumbing in
22
+ // this pass — the P8 plan only reserves portal slots for header / footer.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface TwFootnoteAreaProps {
26
+ pageIndex: number;
27
+ blocks: readonly SurfaceBlockSnapshot[];
28
+ topPx: number;
29
+ leftPx: number;
30
+ widthPx: number;
31
+ heightPx: number;
32
+ "data-testid"?: string;
33
+ }
34
+
35
+ export const TwFootnoteArea: React.FC<TwFootnoteAreaProps> = ({
36
+ pageIndex,
37
+ blocks,
38
+ topPx,
39
+ leftPx,
40
+ widthPx,
41
+ heightPx,
42
+ "data-testid": testId,
43
+ }) => {
44
+ return (
45
+ <div
46
+ data-footnote-area
47
+ data-page-index={pageIndex}
48
+ data-testid={testId}
49
+ style={{
50
+ position: "absolute",
51
+ top: `${topPx}px`,
52
+ left: `${leftPx}px`,
53
+ width: `${widthPx}px`,
54
+ height: `${heightPx}px`,
55
+ }}
56
+ >
57
+ <div
58
+ data-footnote-separator
59
+ style={{
60
+ width: `${Math.round(widthPx / 3)}px`,
61
+ height: "1px",
62
+ backgroundColor: "currentColor",
63
+ marginBottom: "4pt",
64
+ }}
65
+ />
66
+ <TwRegionBlockRenderer blocks={blocks} />
67
+ </div>
68
+ );
69
+ };
70
+
71
+ export default TwFootnoteArea;
@@ -0,0 +1,73 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwPageFooterBand (P8.5)
8
+ //
9
+ // Symmetric counterpart to `TwPageHeaderBand` (see its header for design
10
+ // context). The footer band positions itself via `bottom` rather than
11
+ // `top` so the chrome layer can measure the footer margin up from the
12
+ // page rect's bottom edge and keep footer content pinned correctly when
13
+ // page size / margin changes.
14
+ //
15
+ // When `isActiveSlot` is true, the band emits a single `data-pm-portal-slot`
16
+ // div tagged `data-page-band-slot="footer"` — the chrome layer portals the
17
+ // PM surface into this target in P8.10. Otherwise it renders the footer
18
+ // story's `SurfaceBlockSnapshot[]` through `TwRegionBlockRenderer` (P8.4).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export interface TwPageFooterBandProps {
22
+ pageIndex: number;
23
+ blocks: readonly SurfaceBlockSnapshot[];
24
+ bandHeightPx: number;
25
+ bottomPx: number;
26
+ leftPx: number;
27
+ widthPx: number;
28
+ /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
29
+ isActiveSlot: boolean;
30
+ onClick: () => void;
31
+ "data-testid"?: string;
32
+ }
33
+
34
+ export const TwPageFooterBand: React.FC<TwPageFooterBandProps> = ({
35
+ pageIndex,
36
+ blocks,
37
+ bandHeightPx,
38
+ bottomPx,
39
+ leftPx,
40
+ widthPx,
41
+ isActiveSlot,
42
+ onClick,
43
+ "data-testid": testId,
44
+ }) => {
45
+ return (
46
+ <div
47
+ data-page-band="footer"
48
+ data-page-index={pageIndex}
49
+ data-testid={testId}
50
+ onClick={onClick}
51
+ style={{
52
+ position: "absolute",
53
+ bottom: `${bottomPx}px`,
54
+ left: `${leftPx}px`,
55
+ width: `${widthPx}px`,
56
+ height: `${bandHeightPx}px`,
57
+ cursor: "pointer",
58
+ }}
59
+ >
60
+ {isActiveSlot ? (
61
+ <div
62
+ data-pm-portal-slot
63
+ data-page-band-slot="footer"
64
+ style={{ width: "100%", height: "100%" }}
65
+ />
66
+ ) : (
67
+ <TwRegionBlockRenderer blocks={blocks} />
68
+ )}
69
+ </div>
70
+ );
71
+ };
72
+
73
+ export default TwPageFooterBand;
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+
3
+ import type { SurfaceBlockSnapshot } from "../../api/public-types.ts";
4
+ import { TwRegionBlockRenderer } from "./tw-region-block-renderer.tsx";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // TwPageHeaderBand (P8.5)
8
+ //
9
+ // Read-only band mounted at an absolute pixel position inside a per-page
10
+ // chrome overlay. The band either:
11
+ // - Renders the header story's `SurfaceBlockSnapshot[]` through
12
+ // `TwRegionBlockRenderer` (P8.4) as pure presentational DOM, or
13
+ // - Emits a single `data-pm-portal-slot` div when the chrome layer has
14
+ // promoted this band to the active story slot (P8.10 wires the PM
15
+ // surface into this target via React portal).
16
+ //
17
+ // Clicks on the band bubble to the chrome layer's `openStory` dispatch
18
+ // (wired in P8.8 / P8.10) so legal reviewers can promote a header into
19
+ // the active editing surface.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface TwPageHeaderBandProps {
23
+ pageIndex: number;
24
+ blocks: readonly SurfaceBlockSnapshot[];
25
+ bandHeightPx: number;
26
+ topPx: number;
27
+ leftPx: number;
28
+ widthPx: number;
29
+ /** True when this band is the active-story slot. Renders portal target instead of read-only DOM. */
30
+ isActiveSlot: boolean;
31
+ onClick: () => void;
32
+ "data-testid"?: string;
33
+ }
34
+
35
+ export const TwPageHeaderBand: React.FC<TwPageHeaderBandProps> = ({
36
+ pageIndex,
37
+ blocks,
38
+ bandHeightPx,
39
+ topPx,
40
+ leftPx,
41
+ widthPx,
42
+ isActiveSlot,
43
+ onClick,
44
+ "data-testid": testId,
45
+ }) => {
46
+ return (
47
+ <div
48
+ data-page-band="header"
49
+ data-page-index={pageIndex}
50
+ data-testid={testId}
51
+ onClick={onClick}
52
+ style={{
53
+ position: "absolute",
54
+ top: `${topPx}px`,
55
+ left: `${leftPx}px`,
56
+ width: `${widthPx}px`,
57
+ height: `${bandHeightPx}px`,
58
+ cursor: "pointer",
59
+ }}
60
+ >
61
+ {isActiveSlot ? (
62
+ <div
63
+ data-pm-portal-slot
64
+ data-page-band-slot="header"
65
+ style={{ width: "100%", height: "100%" }}
66
+ />
67
+ ) : (
68
+ <TwRegionBlockRenderer blocks={blocks} />
69
+ )}
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export default TwPageHeaderBand;
@@ -0,0 +1,477 @@
1
+ /**
2
+ * TwPageStackChromeLayer — per-page chrome overlay sibling of
3
+ * `TwPageStackOverlayLayer` (P3.b).
4
+ *
5
+ * Where the P3.b layer paints the decorative paper-frame rounded-rect
6
+ * treatment for each page, this layer composes the *content* chrome for
7
+ * those same pages: header / footer bands, footnote areas, and a
8
+ * document-end endnote area. It reuses P3.b's measurement helpers
9
+ * (`measureWidgetsViaBoundingRect`, `resolvePageOverlayRects`) so the
10
+ * per-page pixel rectangles stay identical between the two layers.
11
+ *
12
+ * Band placement
13
+ * --------------
14
+ * For each measured page rect the layer emits one
15
+ * `<div data-page-chrome-frame data-page-index={i}>` positioned at the
16
+ * rect's top/height. Inside the frame, four children conditionally
17
+ * mount:
18
+ *
19
+ * 1. `<TwPageHeaderBand>` — when the page's `regions.header` + the
20
+ * resolved `stories.header` both exist.
21
+ * 2. `<TwPageFooterBand>` — symmetric; uses the page's
22
+ * `regions.footer` + `stories.footer`.
23
+ * 3. `<TwFootnoteArea>` — when `regions.footnotes?.[0]` is non-empty
24
+ * (at least one footnote was allocated on this page). The P8.6
25
+ * polish reserves only a single footnote region per page today;
26
+ * multi-region split lands later.
27
+ * 4. Endnotes live OUTSIDE the per-page loop — one
28
+ * `<TwEndnoteArea>` sibling renders after every page frame with
29
+ * the document-end endnote bodies (per OOXML
30
+ * `w:endnotePr/w:pos="docEnd"`).
31
+ *
32
+ * Twip → px conversion uses the same `FRAME_PX_PER_TWIP_AT_96DPI`
33
+ * constant the outer workspace uses, so band geometry matches the page
34
+ * frame's physical size exactly.
35
+ *
36
+ * Active slot
37
+ * -----------
38
+ * When `activeStory` matches a band's resolved story target, the band
39
+ * renders in "active slot" mode — it emits a
40
+ * `data-pm-portal-slot` target instead of a read-only
41
+ * `TwRegionBlockRenderer`.
42
+ *
43
+ * PM portal reparent (P8.10)
44
+ * --------------------------
45
+ * When `pmSurfaceElement` is supplied, the layer runs a
46
+ * `useLayoutEffect` that keeps the PM DOM node parented to whichever
47
+ * band currently owns the active slot. The effect:
48
+ *
49
+ * 1. Resolves the active target via
50
+ * `overlayRoot.querySelector("[data-pm-portal-slot]")` — if the
51
+ * current `activeStory` matches one or more visible bands, every
52
+ * matching band emits a slot; we pick the first in document
53
+ * order and park PM there.
54
+ * 2. Otherwise falls back to the body slot
55
+ * (`[data-pm-body-slot]`) by walking up from the scroll root /
56
+ * owner document.
57
+ * 3. If `pmSurfaceElement.parentElement !== target`, captures the
58
+ * PM selection state, calls `target.appendChild(pmSurfaceElement)`,
59
+ * then restores selection + focus when the PM view is supplied.
60
+ *
61
+ * `useLayoutEffect` is deliberate — running after commit but before
62
+ * paint avoids a visible flash where PM briefly appears in the wrong
63
+ * slot (or in neither slot, since React wipes the old portal-slot
64
+ * container). Selection capture/restore is best-effort: when the PM
65
+ * view handle isn't supplied (e.g. early P8.10 before P8.11 threads it
66
+ * through the workspace), the DOM reparent still happens — the caller
67
+ * simply accepts that selection may land at offset 0 after a swap.
68
+ */
69
+
70
+ import React from "react";
71
+ import type {
72
+ EditorStoryTarget,
73
+ WordReviewEditorLayoutFacet,
74
+ } from "../../api/public-types.ts";
75
+ import {
76
+ measureWidgetsViaBoundingRect,
77
+ resolvePageOverlayRects,
78
+ type PageOverlayRect,
79
+ } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
80
+ import { TwPageHeaderBand } from "./tw-page-header-band.tsx";
81
+ import { TwPageFooterBand } from "./tw-page-footer-band.tsx";
82
+ import { TwFootnoteArea } from "./tw-footnote-area.tsx";
83
+ import { TwEndnoteArea } from "./tw-endnote-area.tsx";
84
+ import { FRAME_PX_PER_TWIP_AT_96DPI } from "../tw-review-workspace.tsx";
85
+
86
+ /**
87
+ * Minimal structural type for the PM `EditorView` handle consumed by
88
+ * the portal-swap effect. We only read `.hasFocus()` and optionally
89
+ * `.focus()` so we avoid a static dependency on `prosemirror-view` in
90
+ * this chrome file — the production call site threads the real
91
+ * `EditorView` via the same interface.
92
+ */
93
+ export interface PmPortalView {
94
+ hasFocus: () => boolean;
95
+ focus: () => void;
96
+ }
97
+
98
+ export interface TwPageStackChromeLayerProps {
99
+ /** Layout facet — source of per-page regions, stories, and blocks. */
100
+ facet: WordReviewEditorLayoutFacet;
101
+ /** Scroll root whose `[data-page-frame-*]` markers drive per-page rects. */
102
+ scrollRoot: HTMLElement | null;
103
+ /**
104
+ * Render-frame revision tick incremented on `layout_recomputed`,
105
+ * `incremental_relayout`, `render_frame_ready`, or `zoom_changed`.
106
+ * Bumping re-measures per-page rects.
107
+ */
108
+ renderFrameRevision: number;
109
+ /** Current active story target — used to promote the matching band to slot mode. */
110
+ activeStory: EditorStoryTarget;
111
+ /** Fires when a band is clicked. Task 10 routes PM into the matching band. */
112
+ onOpenStory?: (target: EditorStoryTarget) => void;
113
+ /**
114
+ * PM surface DOM element (typically `view.dom`, i.e. the outer
115
+ * `.ProseMirror` div). When supplied, the layer reparents this
116
+ * element across band slots via imperative `appendChild` in a
117
+ * `useLayoutEffect`: it becomes a child of the active band's
118
+ * `[data-pm-portal-slot]` when `activeStory` matches a visible band,
119
+ * or returns to `[data-pm-body-slot]` when `activeStory.kind` is
120
+ * `"main"`. When omitted, the layer skips the reparent step — useful
121
+ * for the pre-P8.10 callers that only consume the read-only chrome.
122
+ */
123
+ pmSurfaceElement?: HTMLElement | null;
124
+ /**
125
+ * Optional PM view handle — lets the layer check `hasFocus()` before
126
+ * the reparent and re-focus PM afterwards so mid-edit clicks don't
127
+ * silently drop focus. When omitted the layer still reparents the
128
+ * DOM element; only the refocus step is skipped.
129
+ */
130
+ pmView?: PmPortalView | null;
131
+ /** Optional test id applied to the layer root. */
132
+ "data-testid"?: string;
133
+ }
134
+
135
+ export const TwPageStackChromeLayer: React.FC<TwPageStackChromeLayerProps> = ({
136
+ facet,
137
+ scrollRoot,
138
+ renderFrameRevision,
139
+ activeStory,
140
+ onOpenStory,
141
+ pmSurfaceElement,
142
+ pmView,
143
+ "data-testid": testId,
144
+ }) => {
145
+ const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
146
+ const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
147
+ const rafHandleRef = React.useRef<number | null>(null);
148
+
149
+ // --------------------------------------------------------------------
150
+ // rAF-debounced refresh. Mirrors the pattern in
151
+ // `TwPageStackOverlayLayer` (see that file's P14 hardening note) —
152
+ // multiple triggers within the same animation frame coalesce to one
153
+ // measurement pass.
154
+ // --------------------------------------------------------------------
155
+
156
+ const refreshRectsNow = React.useCallback(() => {
157
+ if (!scrollRoot) {
158
+ setRects([]);
159
+ return;
160
+ }
161
+ const origin = overlayRootRef.current;
162
+ const pageCount = facet.getPageCount();
163
+ if (origin) {
164
+ const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin);
165
+ const originRect = origin.getBoundingClientRect();
166
+ // jsdom + SSR never populate `origin.clientHeight` (no layout
167
+ // pass). Fall back to `originRect.height`, and if that's also
168
+ // zero, to `scrollRoot.clientHeight` so the last page rect still
169
+ // resolves (otherwise pages whose bottom edge depends on the
170
+ // scroll-height fallback would be silently dropped).
171
+ const scrollHeight =
172
+ origin.clientHeight > 0
173
+ ? origin.clientHeight
174
+ : originRect.height > 0
175
+ ? originRect.height
176
+ : scrollRoot.clientHeight;
177
+ setRects(
178
+ resolvePageOverlayRects({
179
+ widgets,
180
+ pageCount,
181
+ scrollHeight,
182
+ }),
183
+ );
184
+ } else {
185
+ setRects(resolvePageOverlayRects([scrollRoot, pageCount]));
186
+ }
187
+ }, [facet, scrollRoot]);
188
+
189
+ const refreshRects = React.useCallback(() => {
190
+ if (!scrollRoot) {
191
+ setRects([]);
192
+ return;
193
+ }
194
+ const runtime = scrollRoot.ownerDocument?.defaultView as
195
+ | (Window & {
196
+ requestAnimationFrame?: (cb: () => void) => number;
197
+ cancelAnimationFrame?: (handle: number) => void;
198
+ })
199
+ | null;
200
+ const raf = runtime?.requestAnimationFrame;
201
+ if (!raf) {
202
+ refreshRectsNow();
203
+ return;
204
+ }
205
+ if (rafHandleRef.current !== null) return;
206
+ rafHandleRef.current = raf(() => {
207
+ rafHandleRef.current = null;
208
+ refreshRectsNow();
209
+ });
210
+ }, [scrollRoot, refreshRectsNow]);
211
+
212
+ React.useEffect(() => {
213
+ refreshRects();
214
+ return () => {
215
+ const runtime = scrollRoot?.ownerDocument?.defaultView as
216
+ | (Window & { cancelAnimationFrame?: (h: number) => void })
217
+ | null;
218
+ if (rafHandleRef.current !== null && runtime?.cancelAnimationFrame) {
219
+ runtime.cancelAnimationFrame(rafHandleRef.current);
220
+ rafHandleRef.current = null;
221
+ }
222
+ };
223
+ }, [refreshRects, renderFrameRevision, scrollRoot]);
224
+
225
+ // Observe scroll-root size changes.
226
+ React.useEffect(() => {
227
+ if (!scrollRoot) return;
228
+ const runtime = scrollRoot.ownerDocument?.defaultView as
229
+ | (Window & { ResizeObserver?: typeof ResizeObserver })
230
+ | null;
231
+ if (!runtime?.ResizeObserver) return;
232
+ const observer = new runtime.ResizeObserver(() => refreshRects());
233
+ observer.observe(scrollRoot);
234
+ return () => observer.disconnect();
235
+ }, [scrollRoot, refreshRects]);
236
+
237
+ // Observe DOM mutations on the scroll root so PM re-renders
238
+ // (page-break widgets added / removed) re-trigger measurement.
239
+ //
240
+ // Matches `TwPageStackOverlayLayer`:
241
+ // - `subtree: false` (P14.e) avoids per-keystroke character-data
242
+ // churn — page boundaries only shift on childList changes.
243
+ // - Self-mutation filter (P14.a) ignores mutations whose targets
244
+ // live inside the overlay root itself, which prevents the
245
+ // `setRects` → reconciliation → mutation callback loop.
246
+ React.useEffect(() => {
247
+ if (!scrollRoot) return;
248
+ const runtime = scrollRoot.ownerDocument?.defaultView as
249
+ | (Window & { MutationObserver?: typeof MutationObserver })
250
+ | null;
251
+ if (!runtime?.MutationObserver) return;
252
+ const observer = new runtime.MutationObserver((records) => {
253
+ const overlay = overlayRootRef.current;
254
+ if (overlay) {
255
+ const allSelf = records.every(
256
+ (r) => r.target instanceof Node && overlay.contains(r.target),
257
+ );
258
+ if (allSelf) return;
259
+ }
260
+ refreshRects();
261
+ });
262
+ observer.observe(scrollRoot, { childList: true, subtree: false });
263
+ return () => observer.disconnect();
264
+ }, [scrollRoot, refreshRects]);
265
+
266
+ // --------------------------------------------------------------------
267
+ // P8.10 — PM portal reparent.
268
+ //
269
+ // When `pmSurfaceElement` is supplied, keep the PM DOM node parented
270
+ // to the band whose story matches `activeStory`; otherwise return
271
+ // it to the body slot. `useLayoutEffect` runs after React commits
272
+ // the band re-render (so the portal-slot div exists) but before the
273
+ // browser paints (so the swap is invisible to the user).
274
+ //
275
+ // Effect dependencies:
276
+ // - `pmSurfaceElement` — identity change means a different PM DOM
277
+ // node needs to be parented; we re-run so the fresh node lands
278
+ // in the right slot.
279
+ // - `activeStory` — the identity of the active band changes.
280
+ // - `rects` — per-page rects refreshing may cause the active band
281
+ // to mount for the first time (e.g. page 2's band wasn't
282
+ // measured on the previous pass), so we re-check the portal slot.
283
+ // --------------------------------------------------------------------
284
+ React.useLayoutEffect(() => {
285
+ if (!pmSurfaceElement) return;
286
+ const overlay = overlayRootRef.current;
287
+ // Find a portal slot the ChromeLayer currently exposes. Every
288
+ // visible band whose story equals `activeStory` renders a slot —
289
+ // so if a header variant repeats across pages, every one of those
290
+ // pages emits a `[data-pm-portal-slot]` div. PM is a single DOM
291
+ // element, so we pick the first slot in document order via
292
+ // `querySelector` (singular) and park the element there. The
293
+ // remaining slot divs stay empty until the next reparent.
294
+ const activeSlot =
295
+ overlay?.querySelector<HTMLElement>("[data-pm-portal-slot]") ?? null;
296
+ // Body slot lives outside the chrome overlay root. Walk up from
297
+ // the PM element's owner document so jsdom tests and the real
298
+ // production host both resolve the same target.
299
+ const ownerDoc =
300
+ pmSurfaceElement.ownerDocument ??
301
+ scrollRoot?.ownerDocument ??
302
+ overlay?.ownerDocument ??
303
+ null;
304
+ const bodySlot =
305
+ ownerDoc?.querySelector<HTMLElement>("[data-pm-body-slot]") ?? null;
306
+ const target = activeSlot ?? bodySlot;
307
+ if (!target) return;
308
+ if (pmSurfaceElement.parentElement === target) return;
309
+
310
+ // Capture selection + focus so the reparent is transparent to the
311
+ // user. Moving a contenteditable element via `appendChild` drops
312
+ // the DOM selection range on some browsers; we re-run focus after
313
+ // the DOM mutation so the runtime-driven selection sync can
314
+ // restore PM's caret.
315
+ const hadFocus = pmView?.hasFocus() ?? false;
316
+ target.appendChild(pmSurfaceElement);
317
+ if (hadFocus && pmView) {
318
+ try {
319
+ pmView.focus();
320
+ } catch {
321
+ // Swallow — re-focus is best-effort. Production PM will
322
+ // recompute selection from the snapshot on the next
323
+ // updateState pass.
324
+ }
325
+ }
326
+ }, [pmSurfaceElement, pmView, activeStory, rects, scrollRoot]);
327
+
328
+ // --------------------------------------------------------------------
329
+ // Render
330
+ // --------------------------------------------------------------------
331
+
332
+ const endnoteBlocks = React.useMemo(
333
+ () =>
334
+ facet
335
+ .getDocumentEndnoteBlocks()
336
+ .map((block) => block.blockSnapshot),
337
+ // Endnote bodies are cached per-revision at the facet boundary — we
338
+ // can piggy-back on `renderFrameRevision` to invalidate the local
339
+ // memo when the facet's revision ticks.
340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
341
+ [facet, renderFrameRevision],
342
+ );
343
+
344
+ return (
345
+ <div
346
+ ref={overlayRootRef}
347
+ data-page-stack-chrome-layer=""
348
+ data-testid={testId ?? "page-stack-chrome-layer"}
349
+ style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
350
+ >
351
+ {rects.map((rect, pageIndex) => {
352
+ const page = facet.getPage(pageIndex);
353
+ if (!page) return null;
354
+
355
+ const layout = page.layout;
356
+ const headerStory = page.stories.header;
357
+ const footerStory = page.stories.footer;
358
+ const headerRegion = page.regions.header;
359
+ const footerRegion = page.regions.footer;
360
+ const footnoteRegion = page.regions.footnotes?.[0];
361
+
362
+ const headerBlocks = headerStory
363
+ ? facet
364
+ .getStoryBlocksForRegion(pageIndex, "header")
365
+ .map((b) => b.blockSnapshot)
366
+ : [];
367
+ const footerBlocks = footerStory
368
+ ? facet
369
+ .getStoryBlocksForRegion(pageIndex, "footer")
370
+ .map((b) => b.blockSnapshot)
371
+ : [];
372
+ const footnoteBlocks = footnoteRegion
373
+ ? facet
374
+ .getStoryBlocksForRegion(pageIndex, "footnote-area")
375
+ .map((b) => b.blockSnapshot)
376
+ : [];
377
+
378
+ const px = (twips: number): number => twips * FRAME_PX_PER_TWIP_AT_96DPI;
379
+ const bandWidthPx = px(
380
+ layout.pageWidth - layout.marginLeft - layout.marginRight,
381
+ );
382
+ const bandLeftPx = px(layout.marginLeft);
383
+ const frameHeightPx = rect.bottomPx - rect.topPx;
384
+
385
+ return (
386
+ <div
387
+ key={`page-chrome-${rect.pageId}`}
388
+ data-page-chrome-frame=""
389
+ data-page-index={pageIndex}
390
+ style={{
391
+ position: "absolute",
392
+ top: `${rect.topPx}px`,
393
+ left: 0,
394
+ width: "100%",
395
+ height: `${frameHeightPx}px`,
396
+ pointerEvents: "none",
397
+ }}
398
+ >
399
+ {headerRegion && headerStory ? (
400
+ <TwPageHeaderBand
401
+ pageIndex={pageIndex}
402
+ blocks={headerBlocks}
403
+ topPx={px(layout.headerMargin ?? 720)}
404
+ leftPx={bandLeftPx}
405
+ widthPx={bandWidthPx}
406
+ bandHeightPx={px(headerRegion.heightTwips)}
407
+ isActiveSlot={isActiveStoryMatch(activeStory, headerStory)}
408
+ onClick={() => onOpenStory?.(headerStory)}
409
+ />
410
+ ) : null}
411
+ {footerRegion && footerStory ? (
412
+ <TwPageFooterBand
413
+ pageIndex={pageIndex}
414
+ blocks={footerBlocks}
415
+ bottomPx={px(layout.footerMargin ?? 720)}
416
+ leftPx={bandLeftPx}
417
+ widthPx={bandWidthPx}
418
+ bandHeightPx={px(footerRegion.heightTwips)}
419
+ isActiveSlot={isActiveStoryMatch(activeStory, footerStory)}
420
+ onClick={() => onOpenStory?.(footerStory)}
421
+ />
422
+ ) : null}
423
+ {footnoteRegion ? (
424
+ <TwFootnoteArea
425
+ pageIndex={pageIndex}
426
+ blocks={footnoteBlocks}
427
+ topPx={px(footnoteRegion.originTwips - layout.marginTop)}
428
+ leftPx={bandLeftPx}
429
+ widthPx={px(footnoteRegion.widthTwips)}
430
+ heightPx={px(footnoteRegion.heightTwips)}
431
+ />
432
+ ) : null}
433
+ </div>
434
+ );
435
+ })}
436
+ <TwEndnoteArea blocks={endnoteBlocks} />
437
+ </div>
438
+ );
439
+ };
440
+
441
+ /**
442
+ * Strict equality for two `EditorStoryTarget` values — used to decide
443
+ * whether a given band should render in active-slot mode.
444
+ *
445
+ * For `"main"` the kinds must match; for header/footer the triple
446
+ * `(relationshipId, variant, sectionIndex)` must match; for footnote /
447
+ * endnote the `noteId` must match. Any mismatch returns false.
448
+ */
449
+ function isActiveStoryMatch(
450
+ active: EditorStoryTarget,
451
+ candidate: EditorStoryTarget,
452
+ ): boolean {
453
+ if (active.kind !== candidate.kind) return false;
454
+ if (active.kind === "main" || candidate.kind === "main") {
455
+ return active.kind === candidate.kind;
456
+ }
457
+ if (active.kind === "footnote" && candidate.kind === "footnote") {
458
+ return active.noteId === candidate.noteId;
459
+ }
460
+ if (active.kind === "endnote" && candidate.kind === "endnote") {
461
+ return active.noteId === candidate.noteId;
462
+ }
463
+ // header / footer — triple match.
464
+ if (
465
+ (active.kind === "header" && candidate.kind === "header") ||
466
+ (active.kind === "footer" && candidate.kind === "footer")
467
+ ) {
468
+ return (
469
+ active.relationshipId === candidate.relationshipId &&
470
+ active.variant === candidate.variant &&
471
+ active.sectionIndex === candidate.sectionIndex
472
+ );
473
+ }
474
+ return false;
475
+ }
476
+
477
+ export default TwPageStackChromeLayer;