@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.
@@ -1,16 +1,24 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
 
3
- import type { RuntimeRenderSnapshot } from "../../api/public-types.ts";
3
+ import type { GeometryFacet, RuntimeRenderSnapshot } from "../../api/public-types.ts";
4
4
  import {
5
+ resolveVisibleBlockRangesFromPageOffsets,
6
+ resolveVisibleBlockRangesFromPageRange,
7
+ resolveVisiblePageIndexRangeFromViewport,
5
8
  useVisibleBlockRange,
6
9
  useVisibleBlockRanges,
7
10
  useVisiblePageIndexRange,
11
+ type VisiblePageIndexRange,
8
12
  } from "../page-stack/use-visible-block-range.ts";
9
13
 
10
14
  export interface UsePageMarkersOptions {
11
15
  pageStackScrollRoot: HTMLElement | null;
12
16
  snapshot: RuntimeRenderSnapshot;
13
17
  layoutFacet?: import("../../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
18
+ geometryFacet?: GeometryFacet;
19
+ renderFrameRevision?: number;
20
+ /** CSS zoom applied to the document surface; scroll pixels are normalized by this. */
21
+ viewportScale?: number;
14
22
  }
15
23
 
16
24
  export interface PageMarkersResult {
@@ -27,6 +35,151 @@ export interface PageMarkersResult {
27
35
  visiblePageIndexRange: ReturnType<typeof useVisiblePageIndexRange>;
28
36
  }
29
37
 
38
+ function pageRangeEqual(
39
+ a: VisiblePageIndexRange | null,
40
+ b: VisiblePageIndexRange | null,
41
+ ): boolean {
42
+ if (a === b) return true;
43
+ if (!a || !b) return false;
44
+ return a.start === b.start && a.end === b.end;
45
+ }
46
+
47
+ function readMarkerSignature(el: HTMLElement): string {
48
+ return [
49
+ el.getAttribute("data-page-frame") ?? "",
50
+ el.getAttribute("data-page-first-block-index") ?? "",
51
+ el.getAttribute("data-page-last-block-index") ?? "",
52
+ ].join(":");
53
+ }
54
+
55
+ function isSyntheticPageZeroMarker(el: HTMLElement): boolean {
56
+ return !el.isConnected && el.getAttribute("data-page-frame") === "0";
57
+ }
58
+
59
+ function pageMarkersEqual(
60
+ a: readonly HTMLElement[],
61
+ b: readonly HTMLElement[],
62
+ ): boolean {
63
+ if (a === b) return true;
64
+ if (a.length !== b.length) return false;
65
+ for (let i = 0; i < a.length; i += 1) {
66
+ const left = a[i]!;
67
+ const right = b[i]!;
68
+ if (left === right) continue;
69
+ if (
70
+ isSyntheticPageZeroMarker(left) &&
71
+ isSyntheticPageZeroMarker(right) &&
72
+ readMarkerSignature(left) === readMarkerSignature(right)
73
+ ) {
74
+ continue;
75
+ }
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ function useGeometryVisiblePageIndexRange(input: {
82
+ scrollRoot: HTMLElement | null;
83
+ geometryFacet?: GeometryFacet;
84
+ layoutFacet?: import("../../runtime/layout/index.ts").WordReviewEditorLayoutFacet;
85
+ pageMarkerCount: number;
86
+ overscanPages: number;
87
+ renderFrameRevision?: number;
88
+ viewportScale?: number;
89
+ }): VisiblePageIndexRange | null {
90
+ const {
91
+ scrollRoot,
92
+ geometryFacet,
93
+ layoutFacet,
94
+ pageMarkerCount,
95
+ overscanPages,
96
+ renderFrameRevision,
97
+ viewportScale,
98
+ } = input;
99
+ const [range, setRange] = useState<VisiblePageIndexRange | null>(null);
100
+
101
+ useEffect(() => {
102
+ if (!scrollRoot || !geometryFacet) {
103
+ setRange((prev) => (prev === null ? prev : null));
104
+ return undefined;
105
+ }
106
+
107
+ const runtime = scrollRoot.ownerDocument?.defaultView as
108
+ | (Window & {
109
+ requestAnimationFrame?: (cb: () => void) => number;
110
+ cancelAnimationFrame?: (handle: number) => void;
111
+ ResizeObserver?: typeof ResizeObserver;
112
+ })
113
+ | null;
114
+ let rafHandle: number | null = null;
115
+ let disposed = false;
116
+
117
+ const publish = () => {
118
+ if (disposed) return;
119
+ const pageCount = layoutFacet?.getPageCount() ?? pageMarkerCount;
120
+ const scale =
121
+ typeof viewportScale === "number" &&
122
+ Number.isFinite(viewportScale) &&
123
+ viewportScale > 0
124
+ ? viewportScale
125
+ : 1;
126
+ const next = resolveVisiblePageIndexRangeFromViewport({
127
+ pageCount,
128
+ viewportTopPx: scrollRoot.scrollTop / scale,
129
+ viewportHeightPx: scrollRoot.clientHeight / scale,
130
+ overscanPages,
131
+ getPageFrame: (pageIndex) => {
132
+ const page = geometryFacet.getPage(pageIndex);
133
+ return page ? page.frame : null;
134
+ },
135
+ });
136
+ setRange((prev) => (pageRangeEqual(prev, next) ? prev : next));
137
+ };
138
+
139
+ const schedule = () => {
140
+ if (disposed || rafHandle !== null) return;
141
+ const raf = runtime?.requestAnimationFrame;
142
+ if (!raf) {
143
+ publish();
144
+ return;
145
+ }
146
+ rafHandle = raf.call(runtime, () => {
147
+ rafHandle = null;
148
+ publish();
149
+ });
150
+ };
151
+
152
+ publish();
153
+ scrollRoot.addEventListener("scroll", schedule, { passive: true });
154
+
155
+ const ResizeObserverCtor = runtime?.ResizeObserver;
156
+ const resizeObserver = ResizeObserverCtor
157
+ ? new ResizeObserverCtor(schedule)
158
+ : null;
159
+ resizeObserver?.observe(scrollRoot);
160
+
161
+ return () => {
162
+ disposed = true;
163
+ scrollRoot.removeEventListener("scroll", schedule);
164
+ resizeObserver?.disconnect();
165
+ if (rafHandle !== null && runtime?.cancelAnimationFrame) {
166
+ runtime.cancelAnimationFrame(rafHandle);
167
+ rafHandle = null;
168
+ }
169
+ };
170
+ }, [
171
+ geometryFacet,
172
+ layoutFacet,
173
+ overscanPages,
174
+ pageMarkerCount,
175
+ renderFrameRevision,
176
+ scrollRoot,
177
+ viewportScale,
178
+ ]);
179
+
180
+ return range;
181
+ }
182
+
30
183
  /**
31
184
  * L7 Phase 2 Task 2.2.4a — viewport-scroll wiring.
32
185
  *
@@ -46,17 +199,29 @@ export interface PageMarkersResult {
46
199
  * `data-page-frame="N+1"`, so page 0 has no widget before it.
47
200
  */
48
201
  export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResult {
49
- const { pageStackScrollRoot, snapshot, layoutFacet } = options;
202
+ const {
203
+ pageStackScrollRoot,
204
+ snapshot,
205
+ layoutFacet,
206
+ geometryFacet,
207
+ renderFrameRevision,
208
+ viewportScale,
209
+ } = options;
210
+ const overscanPages = 2;
50
211
 
51
212
  const [pageMarkers, setPageMarkers] = useState<readonly HTMLElement[]>([]);
52
213
 
53
214
  useEffect(() => {
54
215
  const root = pageStackScrollRoot;
55
216
  if (!root) {
56
- setPageMarkers([]);
217
+ setPageMarkers((prev) => (prev.length === 0 ? prev : []));
57
218
  return undefined;
58
219
  }
59
- const refresh = () => {
220
+ const view = root.ownerDocument?.defaultView;
221
+ let rafHandle: number | null = null;
222
+ let disposed = false;
223
+
224
+ const collectMarkers = (): readonly HTMLElement[] => {
60
225
  const found = Array.from(root.querySelectorAll<HTMLElement>("[data-page-frame]"));
61
226
  const hasPage0 = found.some(
62
227
  (el) => el.getAttribute("data-page-frame") === "0",
@@ -76,17 +241,42 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
76
241
  synth.setAttribute("data-page-frame", "0");
77
242
  synth.setAttribute("data-page-first-block-index", "0");
78
243
  synth.setAttribute("data-page-last-block-index", String(Math.max(0, page0Last)));
79
- setPageMarkers([synth, ...found]);
80
- } else {
81
- setPageMarkers(found);
244
+ return [synth, ...found];
82
245
  }
246
+ return found;
83
247
  };
84
- refresh();
85
- const view = root.ownerDocument?.defaultView;
248
+
249
+ const publish = () => {
250
+ if (disposed) return;
251
+ const next = collectMarkers();
252
+ setPageMarkers((prev) => (pageMarkersEqual(prev, next) ? prev : next));
253
+ };
254
+
255
+ const schedulePublish = () => {
256
+ if (disposed || rafHandle !== null) return;
257
+ const raf = view?.requestAnimationFrame;
258
+ if (!raf) {
259
+ publish();
260
+ return;
261
+ }
262
+ rafHandle = raf.call(view, () => {
263
+ rafHandle = null;
264
+ publish();
265
+ });
266
+ };
267
+
268
+ publish();
86
269
  if (!view?.MutationObserver) return undefined;
87
- const mo = new view.MutationObserver(refresh);
270
+ const mo = new view.MutationObserver(schedulePublish);
88
271
  mo.observe(root, { childList: true, subtree: true });
89
- return () => mo.disconnect();
272
+ return () => {
273
+ disposed = true;
274
+ mo.disconnect();
275
+ if (rafHandle !== null && view.cancelAnimationFrame) {
276
+ view.cancelAnimationFrame(rafHandle);
277
+ rafHandle = null;
278
+ }
279
+ };
90
280
  // Re-run when the scroll root changes OR when a new snapshot lands
91
281
  // (which may have a different page count and new widgets in the PM DOM).
92
282
  // NOTE: snapshot.surface is intentionally excluded — its reference changes on
@@ -108,13 +298,83 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
108
298
  return null;
109
299
  }, [snapshot.selection, snapshot.surface]);
110
300
 
111
- const visibleBlockRanges = useVisibleBlockRanges({
301
+ const markerVisibleBlockRanges = useVisibleBlockRanges({
112
302
  pageMarkers,
113
- overscanPages: 2,
303
+ overscanPages,
114
304
  selectionBlockIndex,
115
305
  totalBlockCount: snapshot.surface?.blocks.length ?? 0,
116
306
  });
117
307
 
308
+ // Marker IO is retained as the cold-open/degraded path, but the warm editor
309
+ // path derives the page window from render-kernel geometry plus viewport
310
+ // scroll. Boundary widgets only exist in the inter-page gaps; using them as
311
+ // primary visibility sentinels makes culling disappear while the user is in
312
+ // the middle of a page.
313
+ const markerVisiblePageIndexRange = useVisiblePageIndexRange({
314
+ pageMarkers,
315
+ overscanPages,
316
+ });
317
+ const geometryVisiblePageIndexRange = useGeometryVisiblePageIndexRange({
318
+ scrollRoot: pageStackScrollRoot,
319
+ geometryFacet,
320
+ layoutFacet,
321
+ pageMarkerCount: pageMarkers.length,
322
+ overscanPages,
323
+ renderFrameRevision,
324
+ viewportScale,
325
+ });
326
+ const visiblePageIndexRange =
327
+ geometryVisiblePageIndexRange ?? markerVisiblePageIndexRange;
328
+
329
+ const visibleBlockRangesFromPageOffsets = useMemo(
330
+ () =>
331
+ visiblePageIndexRange && layoutFacet
332
+ ? resolveVisibleBlockRangesFromPageOffsets({
333
+ blocks: snapshot.surface?.blocks ?? [],
334
+ pageCount: layoutFacet.getPageCount(),
335
+ visiblePageIndexRange,
336
+ selectionBlockIndex,
337
+ totalBlockCount: snapshot.surface?.blocks.length ?? 0,
338
+ getPageOffsets: (pageIndex) => {
339
+ const page = layoutFacet.getPage(pageIndex);
340
+ return page
341
+ ? { startOffset: page.startOffset, endOffset: page.endOffset }
342
+ : null;
343
+ },
344
+ })
345
+ : null,
346
+ [
347
+ layoutFacet,
348
+ selectionBlockIndex,
349
+ snapshot.surface?.blocks,
350
+ snapshot.surface?.blocks.length,
351
+ visiblePageIndexRange,
352
+ ],
353
+ );
354
+
355
+ const visibleBlockRangesFromPageRange = useMemo(
356
+ () =>
357
+ visiblePageIndexRange
358
+ ? resolveVisibleBlockRangesFromPageRange({
359
+ pageMarkers,
360
+ visiblePageIndexRange,
361
+ selectionBlockIndex,
362
+ totalBlockCount: snapshot.surface?.blocks.length ?? 0,
363
+ })
364
+ : null,
365
+ [
366
+ pageMarkers,
367
+ selectionBlockIndex,
368
+ snapshot.surface?.blocks.length,
369
+ visiblePageIndexRange,
370
+ ],
371
+ );
372
+
373
+ const visibleBlockRanges =
374
+ visibleBlockRangesFromPageOffsets ??
375
+ visibleBlockRangesFromPageRange ??
376
+ markerVisibleBlockRanges;
377
+
118
378
  // Bounding hull of the disjoint ranges for the back-compat `visibleBlockRange`
119
379
  // result field + for the effect's dep key (below).
120
380
  const visibleBlockRange = useMemo(() => {
@@ -128,17 +388,6 @@ export function usePageMarkers(options: UsePageMarkersOptions): PageMarkersResul
128
388
  return { start, end };
129
389
  }, [visibleBlockRanges]);
130
390
 
131
- // L7 Phase 2.8 — viewport cull for `TwPageStackChromeLayer`. Returns
132
- // `null` while the IntersectionObserver hasn't reported yet; the chrome
133
- // layer treats null as "render every page" so first paint is
134
- // unaffected. Once the observer fires, the chrome layer only mounts
135
- // bands for pages inside `[start, end)` plus overscan, eliminating the
136
- // measured 412 ms Layout + 229 ms Pre-paint cost on 138-pp extra-large.
137
- const visiblePageIndexRange = useVisiblePageIndexRange({
138
- pageMarkers,
139
- overscanPages: 2,
140
- });
141
-
142
391
  // Stable fingerprint of the current disjoint-range set; used as the effect
143
392
  // dep so identity-preserving recomputes (same intervals, new memo array
144
393
  // reference) don't re-fire the viewport refresh.
@@ -83,12 +83,10 @@
83
83
  * The outer `.wre-page-chrome` wrapper paints this; the inner paper
84
84
  * frame paints the white `--color-page-bg` on top.
85
85
  *
86
- * L8 polish (2026-04-19): darkened from #e7e5e4 #d4d1cc so the canvas
87
- * reads as a distinctly different surface from the white paper (the
88
- * earlier tone was close enough that the paper edges blurred against
89
- * the canvas on high-brightness screens).
86
+ * Editor-experience pass (2026-04-23): keep the canvas quiet so paper
87
+ * edges come from the card border + shadow, not a high-contrast gray field.
90
88
  */
91
- --color-workspace-canvas: #d4d1cc;
89
+ --color-workspace-canvas: #f7f8fa;
92
90
 
93
91
  /*
94
92
  * ─── Radius tokens (balanced 10 / 8 / 4 / 2) ───
@@ -366,6 +366,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
366
366
  pageStackScrollRoot,
367
367
  snapshot,
368
368
  layoutFacet: props.layoutFacet,
369
+ geometryFacet: props.geometryFacet,
370
+ renderFrameRevision,
371
+ viewportScale: zoomScale,
369
372
  });
370
373
 
371
374
  const { dismissSelectionToolbar, runWithSelectionToolbarDismiss } =