@beyondwork/docx-react-component 1.0.80 → 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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/api/public-types.ts +12 -13
  3. package/src/api/v3/_runtime-handle.ts +4 -0
  4. package/src/api/v3/runtime/document.ts +61 -3
  5. package/src/api/v3/runtime/review.ts +55 -2
  6. package/src/api/v3/ui/chrome-composition.ts +10 -2
  7. package/src/io/normalize/normalize-text.ts +4 -1
  8. package/src/io/ooxml/parse-drawing.ts +4 -0
  9. package/src/model/canonical-document.ts +2 -0
  10. package/src/ui/WordReviewEditor.tsx +132 -3
  11. package/src/ui/editor-shell-view.tsx +1 -0
  12. package/src/ui-tailwind/chrome/editor-action-registry.ts +373 -0
  13. package/src/ui-tailwind/chrome/editor-actions-to-palette.ts +59 -35
  14. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +2 -0
  15. package/src/ui-tailwind/chrome/tw-selection-tool-structure.tsx +5 -1
  16. package/src/ui-tailwind/chrome/use-context-menu-controller.ts +15 -10
  17. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  18. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +256 -37
  19. package/src/ui-tailwind/editor-surface/pm-schema.ts +54 -1
  20. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +31 -1
  21. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -0
  22. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +24 -5
  23. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +35 -6
  24. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +333 -43
  25. package/src/ui-tailwind/review-workspace/types.ts +1 -0
  26. package/src/ui-tailwind/review-workspace/use-page-markers.ts +273 -24
  27. package/src/ui-tailwind/review-workspace/use-workspace-composition.ts +46 -6
  28. package/src/ui-tailwind/theme/editor-theme.css +3 -5
  29. package/src/ui-tailwind/tw-review-workspace.tsx +117 -14
@@ -70,11 +70,13 @@
70
70
  import React from "react";
71
71
  import type {
72
72
  EditorStoryTarget,
73
+ GeometryFacet,
73
74
  WordReviewEditorLayoutFacet,
74
75
  } from "../../api/public-types.ts";
75
76
  import {
76
77
  measureWidgetsViaOffsetChain,
77
78
  measureWidgetsViaBoundingRect,
79
+ resolvePageOverlayRectsFromGeometry,
78
80
  resolvePageOverlayRects,
79
81
  type PageOverlayRect,
80
82
  } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
@@ -97,6 +99,8 @@ export interface PmPortalView {
97
99
  export interface TwPageStackChromeLayerProps {
98
100
  /** Layout facet — source of per-page regions, stories, and blocks. */
99
101
  facet: WordReviewEditorLayoutFacet;
102
+ /** Geometry facet — warm-path source of per-page frame rects. */
103
+ geometryFacet?: GeometryFacet;
100
104
  /** Scroll root whose `[data-page-frame-*]` markers drive per-page rects. */
101
105
  scrollRoot: HTMLElement | null;
102
106
  /**
@@ -161,6 +165,7 @@ export interface TwPageStackChromeLayerProps {
161
165
 
162
166
  const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
163
167
  facet,
168
+ geometryFacet,
164
169
  scrollRoot,
165
170
  renderFrameRevision,
166
171
  activeStory,
@@ -172,7 +177,16 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
172
177
  mediaPreviews,
173
178
  activeBandRibbonProps,
174
179
  }) => {
175
- const [rects, setRects] = React.useState<readonly PageOverlayRect[]>([]);
180
+ const [rects, setRects] = React.useState<readonly PageOverlayRect[]>(() => {
181
+ if (!geometryFacet) return [];
182
+ const pageCount = facet.getPageCount();
183
+ const warm = resolvePageOverlayRectsFromGeometry(
184
+ geometryFacet,
185
+ pageCount,
186
+ visiblePageIndexRange,
187
+ );
188
+ return warm ?? [];
189
+ });
176
190
  const overlayRootRef = React.useRef<HTMLDivElement | null>(null);
177
191
  const rafHandleRef = React.useRef<number | null>(null);
178
192
 
@@ -184,12 +198,24 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
184
198
  // --------------------------------------------------------------------
185
199
 
186
200
  const refreshRectsNow = React.useCallback(() => {
201
+ const pageCount = facet.getPageCount();
202
+ if (geometryFacet) {
203
+ const geometryRects = resolvePageOverlayRectsFromGeometry(
204
+ geometryFacet,
205
+ pageCount,
206
+ visiblePageIndexRange,
207
+ );
208
+ if (geometryRects !== null) {
209
+ setRects(geometryRects);
210
+ return;
211
+ }
212
+ }
213
+
187
214
  if (!scrollRoot) {
188
215
  setRects([]);
189
216
  return;
190
217
  }
191
218
  const origin = overlayRootRef.current;
192
- const pageCount = facet.getPageCount();
193
219
  if (origin) {
194
220
  const widgets = measureWidgetsViaBoundingRect(scrollRoot, origin, {
195
221
  pageCount,
@@ -229,11 +255,11 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
229
255
  }),
230
256
  );
231
257
  }
232
- }, [facet, scrollRoot, visiblePageIndexRange]);
258
+ }, [facet, geometryFacet, scrollRoot, visiblePageIndexRange]);
233
259
 
234
260
  const refreshRects = React.useCallback(() => {
235
261
  if (!scrollRoot) {
236
- setRects([]);
262
+ refreshRectsNow();
237
263
  return;
238
264
  }
239
265
  const runtime = scrollRoot.ownerDocument?.defaultView as
@@ -269,6 +295,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
269
295
 
270
296
  // Observe scroll-root size changes.
271
297
  React.useEffect(() => {
298
+ if (geometryFacet) return;
272
299
  if (!scrollRoot) return;
273
300
  const runtime = scrollRoot.ownerDocument?.defaultView as
274
301
  | (Window & { ResizeObserver?: typeof ResizeObserver })
@@ -277,7 +304,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
277
304
  const observer = new runtime.ResizeObserver(() => refreshRects());
278
305
  observer.observe(scrollRoot);
279
306
  return () => observer.disconnect();
280
- }, [scrollRoot, refreshRects]);
307
+ }, [geometryFacet, scrollRoot, refreshRects]);
281
308
 
282
309
  // Observe DOM mutations on the scroll root so PM re-renders
283
310
  // (page-break widgets added / removed) re-trigger measurement.
@@ -289,6 +316,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
289
316
  // live inside the overlay root itself, which prevents the
290
317
  // `setRects` → reconciliation → mutation callback loop.
291
318
  React.useEffect(() => {
319
+ if (geometryFacet) return;
292
320
  if (!scrollRoot) return;
293
321
  const runtime = scrollRoot.ownerDocument?.defaultView as
294
322
  | (Window & { MutationObserver?: typeof MutationObserver })
@@ -306,7 +334,7 @@ const TwPageStackChromeLayerInner: React.FC<TwPageStackChromeLayerProps> = ({
306
334
  });
307
335
  observer.observe(scrollRoot, { childList: true, subtree: false });
308
336
  return () => observer.disconnect();
309
- }, [scrollRoot, refreshRects]);
337
+ }, [geometryFacet, scrollRoot, refreshRects]);
310
338
 
311
339
  // --------------------------------------------------------------------
312
340
  // P8.10 — PM portal reparent.
@@ -461,6 +489,7 @@ function propsAreEqual(
461
489
  ): boolean {
462
490
  return (
463
491
  prev.facet === next.facet &&
492
+ prev.geometryFacet === next.geometryFacet &&
464
493
  prev.scrollRoot === next.scrollRoot &&
465
494
  prev.renderFrameRevision === next.renderFrameRevision &&
466
495
  storyTargetEqual(prev.activeStory, next.activeStory) &&
@@ -15,9 +15,10 @@ const ESTIMATED_BLOCKS_PER_PAGE_FALLBACK = 50;
15
15
  * rendered in PM as "real" (non-placeholder) blocks.
16
16
  *
17
17
  * Sources of truth:
18
- * 1. IntersectionObserver on `[data-page-frame]` markers in the PM DOM.
19
- * 2. Selection head block-index always included (selection-guard).
20
- * 3. Overscan ±N pages around the visible set to avoid jank when scrolling.
18
+ * 1. Warm path: geometry page frames + layout page offsets.
19
+ * 2. Degraded path: IntersectionObserver on `[data-page-frame]` markers.
20
+ * 3. Selection head block-index always included (selection-guard).
21
+ * 4. Overscan — ±N pages around the visible set to avoid jank when scrolling.
21
22
  *
22
23
  * Contract: the hook returns a **list of disjoint intervals**, not a single
23
24
  * contiguous range. When the caret is far off-screen the hook emits the
@@ -60,6 +61,21 @@ export interface VisiblePageIndexRange {
60
61
  end: number; // exclusive
61
62
  }
62
63
 
64
+ export interface PageFrameForVisibility {
65
+ topPx: number;
66
+ heightPx: number;
67
+ }
68
+
69
+ export interface PageOffsetsForVisibility {
70
+ startOffset: number;
71
+ endOffset: number;
72
+ }
73
+
74
+ export interface BlockOffsetsForVisibility {
75
+ from: number;
76
+ to: number;
77
+ }
78
+
63
79
  function readBlockIndex(el: HTMLElement, attr: string): number | null {
64
80
  const v = el.getAttribute(attr);
65
81
  if (v === null) return null;
@@ -67,6 +83,309 @@ function readBlockIndex(el: HTMLElement, attr: string): number | null {
67
83
  return Number.isFinite(n) ? n : null;
68
84
  }
69
85
 
86
+ function clampPageRange(
87
+ range: VisiblePageIndexRange,
88
+ pageCount: number,
89
+ ): VisiblePageIndexRange | null {
90
+ const start = Math.max(0, Math.min(range.start, pageCount));
91
+ const end = Math.max(start, Math.min(range.end, pageCount));
92
+ return start < end ? { start, end } : null;
93
+ }
94
+
95
+ function initialLoadRange(totalBlockCount: number, overscanPages: number): readonly BlockRange[] {
96
+ const initialEnd = Math.min(
97
+ totalBlockCount,
98
+ (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK,
99
+ );
100
+ return initialEnd > 0 ? [{ start: 0, end: initialEnd }] : [];
101
+ }
102
+
103
+ function resolveBlockRangeForPageRange(input: {
104
+ pageMarkers: readonly HTMLElement[];
105
+ visiblePageIndexRange: VisiblePageIndexRange;
106
+ totalBlockCount: number;
107
+ }): BlockRange | null {
108
+ const { pageMarkers, visiblePageIndexRange, totalBlockCount } = input;
109
+ const normalized = clampPageRange(visiblePageIndexRange, pageMarkers.length);
110
+ if (!normalized) return null;
111
+
112
+ let minBlock = Infinity;
113
+ let maxBlock = -Infinity;
114
+ for (let pageIndex = normalized.start; pageIndex < normalized.end; pageIndex += 1) {
115
+ const marker = pageMarkers[pageIndex];
116
+ if (!marker) continue;
117
+ const first = readBlockIndex(marker, "data-page-first-block-index");
118
+ const last = readBlockIndex(marker, "data-page-last-block-index");
119
+ if (first !== null) minBlock = Math.min(minBlock, first);
120
+ if (last !== null) maxBlock = Math.max(maxBlock, last + 1);
121
+ }
122
+
123
+ if (minBlock === Infinity) return null;
124
+ const range = {
125
+ start: Math.max(0, minBlock),
126
+ end: Math.min(totalBlockCount, maxBlock),
127
+ };
128
+ return range.end > range.start ? range : null;
129
+ }
130
+
131
+ function resolveSelectionRange(input: {
132
+ pageMarkers: readonly HTMLElement[];
133
+ selectionBlockIndex: number;
134
+ totalBlockCount: number;
135
+ }): BlockRange {
136
+ const { pageMarkers, selectionBlockIndex, totalBlockCount } = input;
137
+ let selStart = selectionBlockIndex;
138
+ let selEnd = selectionBlockIndex + 1;
139
+ for (const marker of pageMarkers) {
140
+ const first = readBlockIndex(marker, "data-page-first-block-index");
141
+ const last = readBlockIndex(marker, "data-page-last-block-index");
142
+ if (
143
+ first !== null &&
144
+ last !== null &&
145
+ selectionBlockIndex >= first &&
146
+ selectionBlockIndex <= last
147
+ ) {
148
+ selStart = first;
149
+ selEnd = last + 1;
150
+ break;
151
+ }
152
+ }
153
+ return {
154
+ start: Math.max(0, selStart),
155
+ end: Math.min(totalBlockCount, selEnd),
156
+ };
157
+ }
158
+
159
+ function resolveBlockRangeFromOffsetSpan(input: {
160
+ blocks: readonly BlockOffsetsForVisibility[];
161
+ startOffset: number;
162
+ endOffset: number;
163
+ totalBlockCount: number;
164
+ }): BlockRange | null {
165
+ const { blocks, startOffset, endOffset, totalBlockCount } = input;
166
+ if (endOffset <= startOffset) return null;
167
+
168
+ let first = -1;
169
+ let last = -1;
170
+ const scanCount = Math.min(blocks.length, totalBlockCount);
171
+ for (let index = 0; index < scanCount; index += 1) {
172
+ const block = blocks[index];
173
+ if (!block) continue;
174
+ if (block.from >= endOffset) break;
175
+ if (block.from >= startOffset && block.from < endOffset) {
176
+ if (first < 0) first = index;
177
+ last = index;
178
+ }
179
+ }
180
+ if (first < 0 || last < first) return null;
181
+ return { start: first, end: last + 1 };
182
+ }
183
+
184
+ function resolveSelectionRangeFromPageOffsets(input: {
185
+ blocks: readonly BlockOffsetsForVisibility[];
186
+ pageCount: number;
187
+ getPageOffsets: (pageIndex: number) => PageOffsetsForVisibility | null;
188
+ selectionBlockIndex: number;
189
+ totalBlockCount: number;
190
+ }): BlockRange {
191
+ const {
192
+ blocks,
193
+ pageCount,
194
+ getPageOffsets,
195
+ selectionBlockIndex,
196
+ totalBlockCount,
197
+ } = input;
198
+ const selectionBlock = blocks[selectionBlockIndex];
199
+ if (!selectionBlock) {
200
+ return {
201
+ start: Math.max(0, selectionBlockIndex),
202
+ end: Math.min(totalBlockCount, selectionBlockIndex + 1),
203
+ };
204
+ }
205
+
206
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
207
+ const page = getPageOffsets(pageIndex);
208
+ if (!page) continue;
209
+ if (selectionBlock.from >= page.startOffset && selectionBlock.from < page.endOffset) {
210
+ const pageRange = resolveBlockRangeFromOffsetSpan({
211
+ blocks,
212
+ startOffset: page.startOffset,
213
+ endOffset: page.endOffset,
214
+ totalBlockCount,
215
+ });
216
+ if (pageRange) return pageRange;
217
+ break;
218
+ }
219
+ }
220
+
221
+ return {
222
+ start: Math.max(0, selectionBlockIndex),
223
+ end: Math.min(totalBlockCount, selectionBlockIndex + 1),
224
+ };
225
+ }
226
+
227
+ function withSelectionGuard(input: {
228
+ viewportRange: BlockRange;
229
+ selectionBlockIndex: number | null;
230
+ resolveSelectionRange: (selectionBlockIndex: number) => BlockRange;
231
+ }): readonly BlockRange[] {
232
+ const {
233
+ viewportRange,
234
+ selectionBlockIndex,
235
+ resolveSelectionRange: resolveSelectionRangeForIndex,
236
+ } = input;
237
+ if (
238
+ selectionBlockIndex === null ||
239
+ (selectionBlockIndex >= viewportRange.start && selectionBlockIndex < viewportRange.end)
240
+ ) {
241
+ return viewportRange.end > viewportRange.start ? [viewportRange] : [];
242
+ }
243
+
244
+ const selectionRange = resolveSelectionRangeForIndex(selectionBlockIndex);
245
+ if (selectionRange.end <= selectionRange.start) {
246
+ return viewportRange.end > viewportRange.start ? [viewportRange] : [];
247
+ }
248
+
249
+ return selectionRange.start < viewportRange.start
250
+ ? [selectionRange, viewportRange]
251
+ : [viewportRange, selectionRange];
252
+ }
253
+
254
+ export function resolveVisibleBlockRangesFromPageRange(input: {
255
+ pageMarkers: readonly HTMLElement[];
256
+ visiblePageIndexRange: VisiblePageIndexRange;
257
+ selectionBlockIndex: number | null;
258
+ totalBlockCount: number;
259
+ }): readonly BlockRange[] | null {
260
+ if (input.totalBlockCount <= 0) return [];
261
+ const viewportRange = resolveBlockRangeForPageRange(input);
262
+ if (!viewportRange) return null;
263
+ return withSelectionGuard({
264
+ viewportRange,
265
+ selectionBlockIndex: input.selectionBlockIndex,
266
+ resolveSelectionRange: (selectionBlockIndex) =>
267
+ resolveSelectionRange({
268
+ pageMarkers: input.pageMarkers,
269
+ selectionBlockIndex,
270
+ totalBlockCount: input.totalBlockCount,
271
+ }),
272
+ });
273
+ }
274
+
275
+ export function resolveVisibleBlockRangesFromPageOffsets(input: {
276
+ blocks: readonly BlockOffsetsForVisibility[];
277
+ pageCount: number;
278
+ visiblePageIndexRange: VisiblePageIndexRange;
279
+ selectionBlockIndex: number | null;
280
+ totalBlockCount: number;
281
+ getPageOffsets: (pageIndex: number) => PageOffsetsForVisibility | null;
282
+ }): readonly BlockRange[] | null {
283
+ const {
284
+ blocks,
285
+ pageCount,
286
+ visiblePageIndexRange,
287
+ selectionBlockIndex,
288
+ totalBlockCount,
289
+ getPageOffsets,
290
+ } = input;
291
+ if (totalBlockCount <= 0) return [];
292
+ const normalized = clampPageRange(visiblePageIndexRange, pageCount);
293
+ if (!normalized) return null;
294
+
295
+ let startOffset = Infinity;
296
+ let endOffset = -Infinity;
297
+ for (let pageIndex = normalized.start; pageIndex < normalized.end; pageIndex += 1) {
298
+ const page = getPageOffsets(pageIndex);
299
+ if (!page) continue;
300
+ if (page.endOffset <= page.startOffset) continue;
301
+ startOffset = Math.min(startOffset, page.startOffset);
302
+ endOffset = Math.max(endOffset, page.endOffset);
303
+ }
304
+
305
+ if (startOffset === Infinity || endOffset <= startOffset) return null;
306
+ const viewportRange = resolveBlockRangeFromOffsetSpan({
307
+ blocks,
308
+ startOffset,
309
+ endOffset,
310
+ totalBlockCount,
311
+ });
312
+ if (!viewportRange) return null;
313
+
314
+ return withSelectionGuard({
315
+ viewportRange,
316
+ selectionBlockIndex,
317
+ resolveSelectionRange: (selectionBlockIndexForGuard) =>
318
+ resolveSelectionRangeFromPageOffsets({
319
+ blocks,
320
+ pageCount,
321
+ getPageOffsets,
322
+ selectionBlockIndex: selectionBlockIndexForGuard,
323
+ totalBlockCount,
324
+ }),
325
+ });
326
+ }
327
+
328
+ export function resolveVisiblePageIndexRangeFromViewport(input: {
329
+ pageCount: number;
330
+ viewportTopPx: number;
331
+ viewportHeightPx: number;
332
+ overscanPages: number;
333
+ getPageFrame: (pageIndex: number) => PageFrameForVisibility | null;
334
+ }): VisiblePageIndexRange | null {
335
+ const { pageCount, viewportTopPx, viewportHeightPx, overscanPages, getPageFrame } = input;
336
+ if (pageCount <= 0 || viewportHeightPx <= 0) return null;
337
+
338
+ const viewportBottomPx = viewportTopPx + viewportHeightPx;
339
+ let firstVisible = Infinity;
340
+ let lastVisible = -Infinity;
341
+ let nearestPageAfterViewport = -1;
342
+ let nearestPageAfterDistance = Infinity;
343
+ let lastPageBeforeViewport = -1;
344
+ let lastPageBeforeDistance = Infinity;
345
+
346
+ for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) {
347
+ const frame = getPageFrame(pageIndex);
348
+ if (!frame) continue;
349
+ const pageTopPx = frame.topPx;
350
+ const pageBottomPx = frame.topPx + frame.heightPx;
351
+ if (pageBottomPx >= viewportTopPx && pageTopPx <= viewportBottomPx) {
352
+ firstVisible = Math.min(firstVisible, pageIndex);
353
+ lastVisible = Math.max(lastVisible, pageIndex);
354
+ }
355
+ if (pageBottomPx < viewportTopPx) {
356
+ lastPageBeforeViewport = pageIndex;
357
+ lastPageBeforeDistance = viewportTopPx - pageBottomPx;
358
+ } else if (nearestPageAfterViewport < 0 && pageTopPx > viewportBottomPx) {
359
+ nearestPageAfterViewport = pageIndex;
360
+ nearestPageAfterDistance = pageTopPx - viewportBottomPx;
361
+ }
362
+ }
363
+
364
+ if (firstVisible === Infinity) {
365
+ const fallbackPage =
366
+ nearestPageAfterViewport >= 0 && lastPageBeforeViewport >= 0
367
+ ? nearestPageAfterDistance < lastPageBeforeDistance
368
+ ? nearestPageAfterViewport
369
+ : lastPageBeforeViewport
370
+ : nearestPageAfterViewport >= 0
371
+ ? nearestPageAfterViewport
372
+ : lastPageBeforeViewport >= 0
373
+ ? lastPageBeforeViewport
374
+ : -1;
375
+ if (fallbackPage < 0) return null;
376
+ firstVisible = fallbackPage;
377
+ lastVisible = fallbackPage;
378
+ }
379
+
380
+ return clampPageRange(
381
+ {
382
+ start: firstVisible - overscanPages,
383
+ end: lastVisible + overscanPages + 1,
384
+ },
385
+ pageCount,
386
+ );
387
+ }
388
+
70
389
  /**
71
390
  * Multi-interval hook. Returns the list of non-overlapping block-index
72
391
  * intervals that should be real in the surface projection. Typical output:
@@ -121,8 +440,7 @@ export function useVisibleBlockRanges(input: VisibleBlockRangeInput): readonly B
121
440
  if (totalBlockCount <= 0) return [];
122
441
  if (visiblePages.size === 0 && selectionBlockIndex === null) {
123
442
  // No visibility signal yet — return initial-load window (first 2 × overscanPages worth).
124
- const initialEnd = Math.min(totalBlockCount, (overscanPages * 2 + 1) * ESTIMATED_BLOCKS_PER_PAGE_FALLBACK);
125
- return initialEnd > 0 ? [{ start: 0, end: initialEnd }] : [];
443
+ return initialLoadRange(totalBlockCount, overscanPages);
126
444
  }
127
445
 
128
446
  // Expand visiblePages by ±overscanPages.
@@ -158,48 +476,20 @@ export function useVisibleBlockRanges(input: VisibleBlockRangeInput): readonly B
158
476
  // to cover the gap. On long documents with the caret far from the
159
477
  // viewport this avoids realizing the entire gap as real blocks (the
160
478
  // pre-fix behavior that dominated steady-state scroll cost).
161
- if (
162
- selectionBlockIndex === null ||
163
- (selectionBlockIndex >= viewportRange.start &&
164
- selectionBlockIndex < viewportRange.end)
165
- ) {
166
- return viewportRange.end > viewportRange.start ? [viewportRange] : [];
167
- }
168
-
169
- // Resolve the page that contains the selection.
170
- let selStart = selectionBlockIndex;
171
- let selEnd = selectionBlockIndex + 1;
172
- for (const marker of pageMarkers) {
173
- const first = readBlockIndex(marker, "data-page-first-block-index");
174
- const last = readBlockIndex(marker, "data-page-last-block-index");
175
- if (
176
- first !== null &&
177
- last !== null &&
178
- selectionBlockIndex >= first &&
179
- selectionBlockIndex <= last
180
- ) {
181
- selStart = first;
182
- selEnd = last + 1;
183
- break;
184
- }
185
- }
186
- const selectionRange: BlockRange = {
187
- start: Math.max(0, selStart),
188
- end: Math.min(totalBlockCount, selEnd),
189
- };
190
- if (selectionRange.end <= selectionRange.start) {
191
- return viewportRange.end > viewportRange.start ? [viewportRange] : [];
192
- }
193
-
194
479
  // Sort ascending by `start` so the facet layer sees a canonical order
195
480
  // and can dedupe with a simple serialization. Runtime's
196
481
  // `normalizeViewportRanges` merges touching/overlapping intervals, so
197
482
  // we don't need to handle that here.
198
- const ranges: BlockRange[] =
199
- selectionRange.start < viewportRange.start
200
- ? [selectionRange, viewportRange]
201
- : [viewportRange, selectionRange];
202
- return ranges;
483
+ return withSelectionGuard({
484
+ viewportRange,
485
+ selectionBlockIndex,
486
+ resolveSelectionRange: (selectionBlockIndexForGuard) =>
487
+ resolveSelectionRange({
488
+ pageMarkers,
489
+ selectionBlockIndex: selectionBlockIndexForGuard,
490
+ totalBlockCount,
491
+ }),
492
+ });
203
493
  }, [visiblePages, selectionBlockIndex, pageMarkers, overscanPages, totalBlockCount]);
204
494
  }
205
495
 
@@ -150,6 +150,7 @@ export interface TwReviewWorkspaceProps {
150
150
  interactionGuardSnapshot?: InteractionGuardSnapshot;
151
151
  chromePreset?: WordReviewEditorChromePreset;
152
152
  chromeOptions?: Partial<WordReviewEditorChromeOptions>;
153
+ density?: "compact" | "standard" | "comfortable";
153
154
  /** P9g — live collab session for the `"collab"` chrome preset's top nav. */
154
155
  collabSession?: import("../../runtime/collab-session.ts").CollabSession;
155
156
  collabTransportStatus?: import("../../api/awareness-identity-types.ts").TransportStatus;