@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.
@@ -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