@beyondwork/docx-react-component 1.0.81 → 1.0.83

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) ───
@@ -139,6 +139,7 @@ export function TwRoleActionRegion(
139
139
  for (const id of order) {
140
140
  const entry = props.policy.toolbar[id];
141
141
  if (!entry?.visible) continue;
142
+ if (!isRoleActionRenderable(id, props)) continue;
142
143
  if (entry.placement === "overflow") {
143
144
  overflowIds.push(id);
144
145
  } else if (entry.placement === "inline") {
@@ -171,6 +172,26 @@ export function TwRoleActionRegion(
171
172
  );
172
173
  }
173
174
 
175
+ function isRoleActionRenderable(
176
+ id: ToolbarChromeItemId,
177
+ props: TwRoleActionRegionProps,
178
+ ): boolean {
179
+ const reviewQueueTotal = props.reviewQueue?.totalCount ?? 0;
180
+ switch (id) {
181
+ case "review-queue-prev":
182
+ case "review-queue-next":
183
+ case "review-queue-counts":
184
+ case "review-queue-active-label":
185
+ case "review-accept":
186
+ case "review-reject":
187
+ case "review-accept-all":
188
+ case "review-reject-all":
189
+ return reviewQueueTotal > 0;
190
+ default:
191
+ return true;
192
+ }
193
+ }
194
+
174
195
  interface RoleActionButtonProps {
175
196
  id: ToolbarChromeItemId;
176
197
  compact: boolean;
@@ -357,6 +357,7 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
357
357
  role: viewState.editorRole,
358
358
  hasSidebarPanelAccess,
359
359
  effectiveSelectionMode,
360
+ activeStoryKind: snapshot.activeStory.kind,
360
361
  });
361
362
 
362
363
  // L7 Phase 2 Task 2.2.4a — viewport-scroll wiring. Page marker
@@ -366,6 +367,9 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
366
367
  pageStackScrollRoot,
367
368
  snapshot,
368
369
  layoutFacet: props.layoutFacet,
370
+ geometryFacet: props.geometryFacet,
371
+ renderFrameRevision,
372
+ viewportScale: zoomScale,
369
373
  });
370
374
 
371
375
  const { dismissSelectionToolbar, runWithSelectionToolbarDismiss } =