@beyondwork/docx-react-component 1.0.42 → 1.0.45
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/README.md +17 -0
- package/package.json +5 -4
- package/src/api/editor-state-types.ts +110 -0
- package/src/api/public-types.ts +333 -4
- package/src/core/commands/formatting-commands.ts +7 -1
- package/src/core/commands/index.ts +60 -10
- package/src/core/commands/text-commands.ts +59 -0
- package/src/core/search/search-text.ts +15 -2
- package/src/core/selection/review-anchors.ts +131 -21
- package/src/index.ts +29 -1
- package/src/io/chart-preview-resolver.ts +281 -0
- package/src/io/docx-session.ts +692 -2
- package/src/io/export/build-app-properties-xml.ts +1 -1
- package/src/io/export/serialize-comments.ts +38 -9
- package/src/io/export/twip.ts +1 -1
- package/src/io/load-scheduler.ts +230 -0
- package/src/io/normalize/normalize-text.ts +116 -0
- package/src/io/ooxml/parse-comments.ts +0 -33
- package/src/io/ooxml/parse-complex-content.ts +14 -0
- package/src/io/ooxml/parse-main-document.ts +4 -0
- package/src/io/ooxml/workflow-payload-validator.ts +97 -1
- package/src/io/ooxml/workflow-payload.ts +172 -1
- package/src/preservation/opaque-region.ts +5 -0
- package/src/review/store/comment-remapping.ts +2 -2
- package/src/runtime/collab-session.ts +1 -1
- package/src/runtime/document-runtime.ts +661 -42
- package/src/runtime/edit-dispatch/dispatch-text-command.ts +98 -0
- package/src/runtime/edit-dispatch/index.ts +2 -0
- package/src/runtime/edit-dispatch/list-aware-dispatch.ts +125 -0
- package/src/runtime/editor-state-channel.ts +544 -0
- package/src/runtime/editor-state-integration.ts +217 -0
- package/src/runtime/editor-surface/capabilities.ts +411 -0
- package/src/runtime/layout/index.ts +2 -0
- package/src/runtime/layout/inert-layout-facet.ts +4 -0
- package/src/runtime/layout/layout-engine-instance.ts +63 -2
- package/src/runtime/layout/layout-engine-version.ts +41 -0
- package/src/runtime/layout/paginated-layout-engine.ts +211 -14
- package/src/runtime/layout/public-facet.ts +430 -1
- package/src/runtime/perf-counters.ts +28 -0
- package/src/runtime/prerender/cache-envelope.ts +29 -0
- package/src/runtime/prerender/cache-key.ts +66 -0
- package/src/runtime/prerender/font-fingerprint.ts +17 -0
- package/src/runtime/prerender/graph-canonicalize.ts +121 -0
- package/src/runtime/prerender/indexeddb-cache.ts +184 -0
- package/src/runtime/prerender/prerender-document.ts +145 -0
- package/src/runtime/render/block-fragment-projection.ts +2 -0
- package/src/runtime/render/render-frame-types.ts +17 -0
- package/src/runtime/render/render-kernel.ts +172 -29
- package/src/runtime/selection/post-edit-validator.ts +77 -0
- package/src/runtime/surface-projection.ts +45 -7
- package/src/runtime/workflow-markup.ts +71 -16
- package/src/ui/WordReviewEditor.tsx +142 -237
- package/src/ui/editor-command-bag.ts +14 -0
- package/src/ui/editor-runtime-boundary.ts +115 -12
- package/src/ui/editor-shell-view.tsx +10 -0
- package/src/ui/editor-surface-controller.tsx +5 -0
- package/src/ui/headless/selection-helpers.ts +10 -0
- package/src/ui/runtime-shortcut-dispatch.ts +28 -68
- package/src/ui-tailwind/chrome/chrome-preset-toolbar.tsx +62 -2
- package/src/ui-tailwind/chrome/collab-top-nav-container.tsx +281 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +48 -0
- package/src/ui-tailwind/editor-surface/paste-plain-text.ts +72 -0
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +118 -8
- package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +76 -165
- package/src/ui-tailwind/editor-surface/pm-schema.ts +170 -4
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +58 -7
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +265 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +8 -255
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +47 -0
- package/src/ui-tailwind/index.ts +5 -1
- package/src/ui-tailwind/page-stack/tw-endnote-area.tsx +57 -0
- package/src/ui-tailwind/page-stack/tw-footnote-area.tsx +71 -0
- package/src/ui-tailwind/page-stack/tw-page-footer-band.tsx +73 -0
- package/src/ui-tailwind/page-stack/tw-page-header-band.tsx +74 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +477 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +374 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +157 -0
- package/src/ui-tailwind/review/comment-markdown-renderer.tsx +155 -0
- package/src/ui-tailwind/review/tw-comment-sidebar.tsx +77 -16
- package/src/ui-tailwind/theme/editor-theme.css +47 -14
- package/src/ui-tailwind/tw-review-workspace.tsx +303 -123
|
@@ -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;
|