@dynamic-scroll/core 1.2.1

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/dist/index.mjs ADDED
@@ -0,0 +1,880 @@
1
+ import { memo, forwardRef, useRef, useState, useEffect, useLayoutEffect, useCallback, useImperativeHandle, useMemo } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+
5
+ // src/DynamicScroll.tsx
6
+ function useHeightMap({
7
+ items,
8
+ estimatedItemSize
9
+ }) {
10
+ const heightMapRef = useRef(/* @__PURE__ */ new Map());
11
+ const pendingIdsRef = useRef(/* @__PURE__ */ new Set());
12
+ const batchRafRef = useRef(null);
13
+ const [version, setVersion] = useState(0);
14
+ const unmeasuredIds = [];
15
+ const currentIds = /* @__PURE__ */ new Set();
16
+ if (estimatedItemSize === void 0) {
17
+ for (const item of items) {
18
+ currentIds.add(item.id);
19
+ if (!heightMapRef.current.has(item.id)) {
20
+ unmeasuredIds.push(item.id);
21
+ }
22
+ }
23
+ for (const id of pendingIdsRef.current) {
24
+ if (!currentIds.has(id)) {
25
+ pendingIdsRef.current.delete(id);
26
+ }
27
+ }
28
+ for (const id of unmeasuredIds) {
29
+ pendingIdsRef.current.add(id);
30
+ }
31
+ }
32
+ const isAllMeasured = estimatedItemSize !== void 0 || items.length > 0 && unmeasuredIds.length === 0;
33
+ const onItemMeasured = useCallback((id, height) => {
34
+ heightMapRef.current.set(id, height);
35
+ pendingIdsRef.current.delete(id);
36
+ if (pendingIdsRef.current.size === 0) {
37
+ setVersion((v) => v + 1);
38
+ }
39
+ }, []);
40
+ const onHeightChange = useCallback((id, height) => {
41
+ const cur = heightMapRef.current.get(id);
42
+ if (cur === height) return;
43
+ heightMapRef.current.set(id, height);
44
+ if (batchRafRef.current === null) {
45
+ batchRafRef.current = requestAnimationFrame(() => {
46
+ batchRafRef.current = null;
47
+ setVersion((v) => v + 1);
48
+ });
49
+ }
50
+ }, []);
51
+ return {
52
+ heightMapRef,
53
+ isAllMeasured,
54
+ unmeasuredIds,
55
+ onItemMeasured,
56
+ onHeightChange,
57
+ version
58
+ };
59
+ }
60
+ function usePositions({
61
+ items,
62
+ heightMapRef,
63
+ version,
64
+ estimatedItemSize
65
+ }) {
66
+ const childPositions = useMemo(() => {
67
+ const positions = [0];
68
+ for (let i = 0; i < items.length; i++) {
69
+ const height = heightMapRef.current.get(items[i].id) ?? estimatedItemSize ?? 0;
70
+ positions.push(positions[i] + height);
71
+ }
72
+ return positions;
73
+ }, [items, version, estimatedItemSize]);
74
+ const totalHeight = childPositions.length > 1 ? childPositions[childPositions.length - 1] : 0;
75
+ return { childPositions, totalHeight };
76
+ }
77
+ function useGroupPositions(items, groupBy, heightMapRef, version) {
78
+ return useMemo(() => {
79
+ if (!groupBy || items.length === 0) return null;
80
+ const heightByGroup = /* @__PURE__ */ new Map();
81
+ const groupKeyByIndex = [];
82
+ const groupOrder = [];
83
+ for (let i = 0; i < items.length; i++) {
84
+ const key = groupBy(items[i]);
85
+ groupKeyByIndex.push(key);
86
+ const itemHeight = heightMapRef.current.get(items[i].id) ?? 0;
87
+ const current = heightByGroup.get(key) ?? 0;
88
+ heightByGroup.set(key, current + itemHeight);
89
+ if (!groupOrder.includes(key)) {
90
+ groupOrder.push(key);
91
+ }
92
+ }
93
+ const cumulativeHeightByGroup = /* @__PURE__ */ new Map();
94
+ let cumulative = 0;
95
+ for (let i = groupOrder.length - 1; i >= 0; i--) {
96
+ cumulativeHeightByGroup.set(groupOrder[i], cumulative);
97
+ cumulative += heightByGroup.get(groupOrder[i]) ?? 0;
98
+ }
99
+ return { heightByGroup, cumulativeHeightByGroup, groupKeyByIndex };
100
+ }, [items, groupBy, version]);
101
+ }
102
+
103
+ // src/utils/binarySearch.ts
104
+ function generateStartNodeIndex(scrollTop, nodePositions, itemCount) {
105
+ let startRange = 0;
106
+ let endRange = itemCount - 1;
107
+ if (endRange < 0) return 0;
108
+ if (scrollTop <= 0) return 0;
109
+ while (endRange !== startRange) {
110
+ const middle = Math.floor((endRange - startRange) / 2 + startRange);
111
+ const nodeCenter = (nodePositions[middle] + nodePositions[middle + 1]) / 2;
112
+ if (nodeCenter <= scrollTop && nodePositions[middle + 1] > scrollTop) {
113
+ return middle;
114
+ }
115
+ if (middle === startRange) {
116
+ return endRange;
117
+ }
118
+ if (nodeCenter <= scrollTop) {
119
+ startRange = middle;
120
+ } else {
121
+ endRange = middle;
122
+ }
123
+ }
124
+ return startRange;
125
+ }
126
+ function generateEndNodeIndex(nodePositions, startNodeIndex, itemCount, viewportHeight) {
127
+ if (itemCount === 0) return 0;
128
+ const basePosition = nodePositions[startNodeIndex + 1] ?? nodePositions[startNodeIndex];
129
+ const targetPosition = basePosition + viewportHeight;
130
+ let low = startNodeIndex;
131
+ let high = itemCount - 1;
132
+ if (nodePositions[high] <= targetPosition) {
133
+ return high;
134
+ }
135
+ while (low < high) {
136
+ const mid = Math.floor((low + high) / 2);
137
+ if (nodePositions[mid] <= targetPosition) {
138
+ low = mid + 1;
139
+ } else {
140
+ high = mid;
141
+ }
142
+ }
143
+ return low;
144
+ }
145
+ function generateCenteredStartNodeIndex(nodePositions, targetRowHeight, targetRowIndex, viewportHeight) {
146
+ if (!nodePositions || nodePositions.length === 0) return 0;
147
+ const totalHeight = nodePositions[targetRowIndex] + targetRowHeight;
148
+ if (totalHeight <= viewportHeight) return 0;
149
+ const viewHalf = Math.floor(viewportHeight / 2);
150
+ let start = 0;
151
+ let end = targetRowIndex;
152
+ let answer = 0;
153
+ let closestDistance = Infinity;
154
+ while (start <= end) {
155
+ const middle = Math.floor((end - start) / 2 + start);
156
+ const middleHeight = nodePositions[targetRowIndex] - nodePositions[middle] + targetRowHeight;
157
+ const distance = Math.abs(middleHeight - viewHalf);
158
+ if (distance < closestDistance) {
159
+ closestDistance = distance;
160
+ answer = middle;
161
+ }
162
+ if (viewHalf < middleHeight) {
163
+ start = middle + 1;
164
+ } else {
165
+ end = middle - 1;
166
+ }
167
+ }
168
+ return answer;
169
+ }
170
+
171
+ // src/hooks/useScrollState.ts
172
+ function useScrollState({
173
+ scrollTop,
174
+ itemCount,
175
+ overscanCount = 8,
176
+ viewportHeight,
177
+ childPositions
178
+ }) {
179
+ const firstVisibleNode = useMemo(
180
+ () => generateStartNodeIndex(scrollTop, childPositions, itemCount),
181
+ [scrollTop, childPositions, itemCount]
182
+ );
183
+ const lastVisibleNode = useMemo(
184
+ () => generateEndNodeIndex(
185
+ childPositions,
186
+ firstVisibleNode,
187
+ itemCount,
188
+ viewportHeight
189
+ ),
190
+ [childPositions, firstVisibleNode, itemCount, viewportHeight]
191
+ );
192
+ const startNode = Math.max(0, firstVisibleNode - overscanCount);
193
+ const endNode = Math.min(itemCount - 1, lastVisibleNode + overscanCount);
194
+ const visibleNodeCount = Math.max(0, endNode - startNode + 1);
195
+ return {
196
+ firstVisibleNode,
197
+ lastVisibleNode,
198
+ startNode,
199
+ endNode,
200
+ visibleNodeCount
201
+ };
202
+ }
203
+ function MeasureInner({
204
+ children,
205
+ itemId,
206
+ position,
207
+ onHeightChange,
208
+ knownHeight
209
+ }) {
210
+ const ref = useRef(null);
211
+ const prevHeightRef = useRef(knownHeight ?? 0);
212
+ const [heightLocked, setHeightLocked] = useState(!!knownHeight);
213
+ useEffect(() => {
214
+ const node = ref.current;
215
+ if (!node) return;
216
+ const measure = () => {
217
+ const newHeight = Math.ceil(node.offsetHeight);
218
+ if (newHeight === 0 || prevHeightRef.current === newHeight) return;
219
+ const oldHeight = prevHeightRef.current;
220
+ prevHeightRef.current = newHeight;
221
+ console.log(`[Measure] ${itemId} height changed: ${oldHeight} \u2192 ${newHeight} (locked: ${heightLocked}, knownHeight: ${knownHeight})`);
222
+ onHeightChange(itemId, newHeight);
223
+ };
224
+ const resizeObserver = new ResizeObserver(measure);
225
+ resizeObserver.observe(node);
226
+ return () => {
227
+ resizeObserver.disconnect();
228
+ };
229
+ }, [itemId, onHeightChange]);
230
+ useEffect(() => {
231
+ if (!heightLocked) return;
232
+ const node = ref.current;
233
+ if (!node) return;
234
+ const mutationObserver = new MutationObserver(() => {
235
+ console.log(`[Measure] ${itemId} mutation detected \u2192 unlocking height (was ${knownHeight}px)`);
236
+ setHeightLocked(false);
237
+ });
238
+ mutationObserver.observe(node, {
239
+ childList: true,
240
+ subtree: false
241
+ });
242
+ return () => {
243
+ mutationObserver.disconnect();
244
+ };
245
+ }, [heightLocked]);
246
+ return /* @__PURE__ */ jsx(
247
+ "div",
248
+ {
249
+ "data-dynamic-scroll-item": itemId,
250
+ style: {
251
+ position: "absolute",
252
+ top: position,
253
+ left: 0,
254
+ width: "100%",
255
+ ...heightLocked && knownHeight ? { height: knownHeight, overflow: "hidden" } : {}
256
+ },
257
+ ref,
258
+ children
259
+ }
260
+ );
261
+ }
262
+ var Measure = memo(MeasureInner);
263
+ var DEFAULT_OVERSCAN = 8;
264
+ function VirtualScrollInner({
265
+ items,
266
+ renderItem,
267
+ childPositions,
268
+ totalHeight,
269
+ heightMapRef,
270
+ onHeightChange,
271
+ overscanCount = DEFAULT_OVERSCAN,
272
+ onStartReached,
273
+ onEndReached,
274
+ threshold = 0,
275
+ onAtBottomChange,
276
+ syncScrollUpdates = false,
277
+ className,
278
+ style,
279
+ isMeasuring = false,
280
+ loadingComponent,
281
+ bottomLoadingComponent,
282
+ initialScrollPosition = "bottom",
283
+ groupInfo,
284
+ renderGroupHeader
285
+ }, ref) {
286
+ const containerRef = useRef(null);
287
+ const [scrollTop, setScrollTop] = useState(0);
288
+ const [viewportHeight, setViewportHeight] = useState(0);
289
+ const animationFrameRef = useRef(null);
290
+ const isAtBottomRef = useRef(true);
291
+ const mountedRef = useRef(false);
292
+ const programmaticScrollRef = useRef(false);
293
+ const backwardLoadingRef = useRef(false);
294
+ const prevScrollHeightRef = useRef(0);
295
+ const forwardLoadingRef = useRef(false);
296
+ useLayoutEffect(() => {
297
+ const el = containerRef.current;
298
+ if (!el) return;
299
+ setViewportHeight(el.clientHeight);
300
+ }, []);
301
+ useLayoutEffect(() => {
302
+ const el = containerRef.current;
303
+ if (!el || mountedRef.current || totalHeight <= 0) return;
304
+ mountedRef.current = true;
305
+ if (initialScrollPosition === "top") {
306
+ el.scrollTop = 0;
307
+ setScrollTop(0);
308
+ isAtBottomRef.current = false;
309
+ onAtBottomChange?.(false);
310
+ } else if (initialScrollPosition === "bottom") {
311
+ el.scrollTop = el.scrollHeight;
312
+ setScrollTop(el.scrollTop);
313
+ isAtBottomRef.current = true;
314
+ onAtBottomChange?.(true);
315
+ } else {
316
+ const { index, align = "center" } = initialScrollPosition;
317
+ if (index >= 0 && index < items.length) {
318
+ const itemTop = childPositions[index];
319
+ const itemHeight = heightMapRef.current.get(items[index].id) ?? 0;
320
+ let target;
321
+ if (align === "start") {
322
+ target = itemTop;
323
+ } else if (align === "end") {
324
+ target = itemTop + itemHeight - el.clientHeight;
325
+ } else {
326
+ target = itemTop + itemHeight / 2 - el.clientHeight / 2;
327
+ }
328
+ el.scrollTop = Math.max(0, target);
329
+ setScrollTop(el.scrollTop);
330
+ isAtBottomRef.current = false;
331
+ onAtBottomChange?.(false);
332
+ }
333
+ }
334
+ }, [totalHeight, onAtBottomChange, initialScrollPosition, items, childPositions, heightMapRef]);
335
+ const { startNode, endNode } = useScrollState({
336
+ scrollTop,
337
+ itemCount: items.length,
338
+ overscanCount,
339
+ viewportHeight,
340
+ childPositions
341
+ });
342
+ const onScroll = useCallback(
343
+ (e) => {
344
+ if (animationFrameRef.current !== null) {
345
+ cancelAnimationFrame(animationFrameRef.current);
346
+ }
347
+ animationFrameRef.current = requestAnimationFrame(() => {
348
+ animationFrameRef.current = null;
349
+ const target = e.target;
350
+ const newScrollTop = target.scrollTop;
351
+ if (syncScrollUpdates) {
352
+ flushSync(() => setScrollTop(newScrollTop));
353
+ } else {
354
+ setScrollTop(newScrollTop);
355
+ }
356
+ if (programmaticScrollRef.current) return;
357
+ const atBottom = target.scrollHeight - newScrollTop - target.clientHeight <= 1;
358
+ if (isAtBottomRef.current !== atBottom) {
359
+ isAtBottomRef.current = atBottom;
360
+ onAtBottomChange?.(atBottom);
361
+ }
362
+ });
363
+ },
364
+ [syncScrollUpdates, onAtBottomChange]
365
+ );
366
+ useEffect(() => {
367
+ const el = containerRef.current;
368
+ if (!el) return;
369
+ el.addEventListener("scroll", onScroll);
370
+ return () => el.removeEventListener("scroll", onScroll);
371
+ }, [onScroll]);
372
+ const scrollToBottom = useCallback(
373
+ (behavior = "auto") => {
374
+ const el = containerRef.current;
375
+ if (!el || items.length === 0) return;
376
+ el.scrollTo({ top: el.scrollHeight, left: 0, behavior });
377
+ isAtBottomRef.current = true;
378
+ onAtBottomChange?.(true);
379
+ },
380
+ [items.length, onAtBottomChange]
381
+ );
382
+ const scrollToItem = useCallback(
383
+ (index, align = "center") => {
384
+ const el = containerRef.current;
385
+ if (!el || index < 0 || index >= items.length) {
386
+ console.warn("[scrollToItem] SKIP", { index, itemsLength: items.length, hasEl: !!el });
387
+ return;
388
+ }
389
+ const itemTop = childPositions[index];
390
+ const itemHeight = heightMapRef.current.get(items[index].id) ?? 0;
391
+ let target;
392
+ if (align === "start") {
393
+ target = itemTop;
394
+ } else if (align === "end") {
395
+ target = itemTop + itemHeight - el.clientHeight;
396
+ } else {
397
+ target = itemTop + itemHeight / 2 - el.clientHeight / 2;
398
+ }
399
+ const maxScrollTop = el.scrollHeight - el.clientHeight;
400
+ const finalTarget = Math.max(0, Math.min(target, maxScrollTop));
401
+ console.log("[scrollToItem]", {
402
+ index,
403
+ align,
404
+ itemId: items[index].id,
405
+ itemTop,
406
+ itemHeight,
407
+ target,
408
+ finalTarget,
409
+ viewportHeight: el.clientHeight,
410
+ scrollHeight: el.scrollHeight,
411
+ totalHeight,
412
+ maxScrollTop
413
+ });
414
+ programmaticScrollRef.current = true;
415
+ isAtBottomRef.current = false;
416
+ el.scrollTo({ top: finalTarget, left: 0, behavior: "auto" });
417
+ requestAnimationFrame(() => {
418
+ programmaticScrollRef.current = false;
419
+ });
420
+ },
421
+ [childPositions, items, heightMapRef]
422
+ );
423
+ useImperativeHandle(ref, () => ({
424
+ scrollToItem,
425
+ scrollToBottom,
426
+ scrollToOffset: (offset, behavior = "auto") => {
427
+ containerRef.current?.scrollTo({ top: offset, left: 0, behavior });
428
+ },
429
+ getScrollOffset: () => containerRef.current?.scrollTop ?? 0
430
+ }));
431
+ useLayoutEffect(() => {
432
+ if (!mountedRef.current) return;
433
+ const el = containerRef.current;
434
+ if (!el) return;
435
+ if (prevScrollHeightRef.current > 0 && !isMeasuring) {
436
+ const diff = el.scrollHeight - prevScrollHeightRef.current;
437
+ console.log("[totalHeight] backward adjust", { diff, prevScrollHeight: prevScrollHeightRef.current, newScrollHeight: el.scrollHeight });
438
+ if (diff > 0) {
439
+ el.scrollTop += diff;
440
+ setScrollTop(el.scrollTop);
441
+ }
442
+ prevScrollHeightRef.current = 0;
443
+ backwardLoadingRef.current = false;
444
+ return;
445
+ }
446
+ if (forwardLoadingRef.current) {
447
+ if (!isMeasuring) {
448
+ forwardLoadingRef.current = false;
449
+ }
450
+ return;
451
+ }
452
+ if (isAtBottomRef.current && !isMeasuring) {
453
+ console.log("[totalHeight] stick-to-bottom", { scrollHeight: el.scrollHeight });
454
+ el.scrollTop = el.scrollHeight;
455
+ }
456
+ }, [totalHeight, isMeasuring]);
457
+ useLayoutEffect(() => {
458
+ const el = containerRef.current;
459
+ if (!el || !mountedRef.current || programmaticScrollRef.current) return;
460
+ const actualScrollTop = el.scrollTop;
461
+ if (actualScrollTop <= threshold && onStartReached && !backwardLoadingRef.current && prevScrollHeightRef.current === 0) {
462
+ console.log("[reach] START reached", { actualScrollTop, threshold, scrollHeight: el.scrollHeight, hasCallback: !!onStartReached });
463
+ backwardLoadingRef.current = true;
464
+ prevScrollHeightRef.current = el.scrollHeight;
465
+ Promise.resolve(onStartReached()).catch(() => {
466
+ prevScrollHeightRef.current = 0;
467
+ backwardLoadingRef.current = false;
468
+ });
469
+ return;
470
+ }
471
+ const distFromBottom = el.scrollHeight - actualScrollTop - el.clientHeight;
472
+ if (distFromBottom <= threshold && onEndReached && !forwardLoadingRef.current) {
473
+ console.log("[reach] END reached", { distFromBottom, threshold, actualScrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
474
+ forwardLoadingRef.current = true;
475
+ Promise.resolve(onEndReached()).catch(() => {
476
+ forwardLoadingRef.current = false;
477
+ });
478
+ }
479
+ }, [scrollTop, threshold, onStartReached, onEndReached]);
480
+ const visibleChildren = useMemo(() => {
481
+ if (viewportHeight === 0) return null;
482
+ const result = [];
483
+ for (let i = startNode; i <= endNode && i < items.length; i++) {
484
+ const item = items[i];
485
+ result.push(
486
+ /* @__PURE__ */ jsx(
487
+ Measure,
488
+ {
489
+ itemId: item.id,
490
+ position: childPositions[i],
491
+ onHeightChange,
492
+ knownHeight: heightMapRef.current.get(item.id),
493
+ children: renderItem(item, i)
494
+ },
495
+ item.id
496
+ )
497
+ );
498
+ }
499
+ return result;
500
+ }, [startNode, endNode, items, childPositions, renderItem, onHeightChange, viewportHeight]);
501
+ const groupWrappers = useMemo(() => {
502
+ if (!groupInfo || !renderGroupHeader || viewportHeight === 0) return null;
503
+ const renderedGroups = /* @__PURE__ */ new Set();
504
+ const result = [];
505
+ for (let i = startNode; i <= endNode && i < items.length; i++) {
506
+ const groupKey = groupInfo.groupKeyByIndex[i];
507
+ if (!groupKey || renderedGroups.has(groupKey)) continue;
508
+ renderedGroups.add(groupKey);
509
+ const groupTop = groupInfo.groupStartPositions.get(groupKey) ?? 0;
510
+ const groupHeight = groupInfo.heightByGroup.get(groupKey) ?? 0;
511
+ result.push(
512
+ /* @__PURE__ */ jsx(
513
+ "div",
514
+ {
515
+ style: {
516
+ position: "absolute",
517
+ top: groupTop,
518
+ left: 0,
519
+ width: "100%",
520
+ height: groupHeight,
521
+ pointerEvents: "none",
522
+ zIndex: 1
523
+ },
524
+ children: /* @__PURE__ */ jsx(
525
+ "div",
526
+ {
527
+ style: {
528
+ position: "sticky",
529
+ top: 0,
530
+ pointerEvents: "auto"
531
+ },
532
+ children: renderGroupHeader(groupKey)
533
+ }
534
+ )
535
+ },
536
+ `__group_${groupKey}`
537
+ )
538
+ );
539
+ }
540
+ return result;
541
+ }, [groupInfo, renderGroupHeader, startNode, endNode, items, viewportHeight]);
542
+ return /* @__PURE__ */ jsxs(
543
+ "div",
544
+ {
545
+ ref: containerRef,
546
+ className,
547
+ style: { overflow: "auto", position: "relative", ...style },
548
+ children: [
549
+ (backwardLoadingRef.current || isMeasuring) && loadingComponent,
550
+ /* @__PURE__ */ jsxs("div", { style: { position: "relative", width: "100%", height: totalHeight }, children: [
551
+ groupWrappers,
552
+ visibleChildren
553
+ ] }),
554
+ forwardLoadingRef.current && bottomLoadingComponent
555
+ ]
556
+ }
557
+ );
558
+ }
559
+ var VirtualScroll = forwardRef(VirtualScrollInner);
560
+ var IMAGE_LOAD_TIMEOUT = 5e3;
561
+ function InitialMeasure({
562
+ children,
563
+ itemId,
564
+ onMeasured
565
+ }) {
566
+ const ref = useRef(null);
567
+ const reportedRef = useRef(false);
568
+ useEffect(() => {
569
+ const node = ref.current;
570
+ if (!node || reportedRef.current) return;
571
+ const report = (reason) => {
572
+ if (reportedRef.current) return;
573
+ reportedRef.current = true;
574
+ const height = Math.ceil(node.offsetHeight);
575
+ const images2 = node.querySelectorAll("img");
576
+ const imgInfo = Array.from(images2).map((img) => ({
577
+ complete: img.complete,
578
+ naturalHeight: img.naturalHeight,
579
+ offsetHeight: img.offsetHeight,
580
+ src: img.src.slice(-40)
581
+ }));
582
+ console.log(`[InitialMeasure] ${itemId} \u2192 ${height}px (${reason})`, imgInfo.length > 0 ? imgInfo : "no images");
583
+ onMeasured(itemId, Math.max(height, 1));
584
+ };
585
+ const images = node.querySelectorAll("img");
586
+ const pending = [];
587
+ images.forEach((img) => {
588
+ if (!img.complete) pending.push(img);
589
+ });
590
+ if (pending.length === 0) {
591
+ queueMicrotask(() => report(images.length > 0 ? "images already complete" : "no images"));
592
+ return;
593
+ }
594
+ console.log(`[InitialMeasure] ${itemId} waiting for ${pending.length} image(s)`);
595
+ let remaining = pending.length;
596
+ const onSettled = () => {
597
+ remaining--;
598
+ if (remaining <= 0) report("image load/error");
599
+ };
600
+ pending.forEach((img) => {
601
+ img.addEventListener("load", onSettled, { once: true });
602
+ img.addEventListener("error", onSettled, { once: true });
603
+ });
604
+ const timeout = setTimeout(() => report("timeout"), IMAGE_LOAD_TIMEOUT);
605
+ return () => {
606
+ clearTimeout(timeout);
607
+ pending.forEach((img) => {
608
+ img.removeEventListener("load", onSettled);
609
+ img.removeEventListener("error", onSettled);
610
+ });
611
+ };
612
+ }, [itemId, onMeasured]);
613
+ return /* @__PURE__ */ jsx(
614
+ "div",
615
+ {
616
+ ref,
617
+ "data-dynamic-scroll-measure": itemId,
618
+ style: {
619
+ position: "absolute",
620
+ top: 0,
621
+ left: 0,
622
+ width: "100%",
623
+ visibility: "hidden",
624
+ pointerEvents: "none"
625
+ },
626
+ children
627
+ }
628
+ );
629
+ }
630
+ function DynamicScrollInner({
631
+ items,
632
+ renderItem,
633
+ overscanCount,
634
+ estimatedItemSize,
635
+ onStartReached,
636
+ onEndReached,
637
+ threshold,
638
+ onAtBottomChange,
639
+ syncScrollUpdates,
640
+ className,
641
+ style,
642
+ groupBy,
643
+ renderGroupHeader,
644
+ renderGroupSeparator,
645
+ loadingComponent,
646
+ bottomLoadingComponent,
647
+ initialScrollPosition = "bottom",
648
+ onMeasurementComplete,
649
+ initialLoadingComponent
650
+ }, ref) {
651
+ const itemsWithSeparators = useMemo(() => {
652
+ if (!groupBy || !renderGroupSeparator) return items;
653
+ const result = [];
654
+ let prevGroup = null;
655
+ for (let i = 0; i < items.length; i++) {
656
+ const currentGroup = groupBy(items[i]);
657
+ if (currentGroup !== prevGroup) {
658
+ result.push({
659
+ id: `__separator_${currentGroup}`,
660
+ __isSeparator: true,
661
+ __groupKey: currentGroup
662
+ });
663
+ prevGroup = currentGroup;
664
+ }
665
+ result.push(items[i]);
666
+ }
667
+ return result;
668
+ }, [items, groupBy, renderGroupSeparator]);
669
+ const wrappedRenderItem = useMemo(() => {
670
+ if (!groupBy || !renderGroupSeparator) return renderItem;
671
+ return (item, _index) => {
672
+ if ("__isSeparator" in item && item.__isSeparator) {
673
+ return renderGroupSeparator(item.__groupKey);
674
+ }
675
+ return renderItem(item, _index);
676
+ };
677
+ }, [groupBy, renderGroupSeparator, renderItem]);
678
+ const allItems = itemsWithSeparators;
679
+ const {
680
+ heightMapRef,
681
+ isAllMeasured,
682
+ unmeasuredIds,
683
+ onItemMeasured,
684
+ onHeightChange,
685
+ version
686
+ } = useHeightMap({ items: allItems, estimatedItemSize });
687
+ const hasEverMeasuredRef = useRef(false);
688
+ if (isAllMeasured && !hasEverMeasuredRef.current) {
689
+ hasEverMeasuredRef.current = true;
690
+ console.log("[DynamicScroll] initial measurement complete", { itemCount: allItems.length, unmeasuredCount: unmeasuredIds.length });
691
+ }
692
+ const isMeasuring = !isAllMeasured && hasEverMeasuredRef.current;
693
+ const innerRef = useRef(null);
694
+ const pendingScrollRef = useRef(null);
695
+ const isMeasuringRef = useRef(isMeasuring);
696
+ isMeasuringRef.current = isMeasuring;
697
+ const wasMeasuringRef = useRef(false);
698
+ useEffect(() => {
699
+ if (isMeasuring) {
700
+ wasMeasuringRef.current = true;
701
+ console.log("[DynamicScroll] measuring started", { unmeasuredCount: unmeasuredIds.length, totalItems: allItems.length });
702
+ } else if (wasMeasuringRef.current) {
703
+ wasMeasuringRef.current = false;
704
+ console.log("[DynamicScroll] measuring complete", { hasPendingScroll: !!pendingScrollRef.current });
705
+ if (pendingScrollRef.current) {
706
+ pendingScrollRef.current();
707
+ pendingScrollRef.current = null;
708
+ }
709
+ onMeasurementComplete?.();
710
+ }
711
+ }, [isMeasuring, onMeasurementComplete, unmeasuredIds.length, allItems.length]);
712
+ const toInternalIndex = useCallback(
713
+ (externalIndex) => {
714
+ if (!groupBy || !renderGroupSeparator) return externalIndex;
715
+ let separatorCount = 0;
716
+ let prevGroup = null;
717
+ for (let i = 0; i <= externalIndex && i < items.length; i++) {
718
+ const group = groupBy(items[i]);
719
+ if (group !== prevGroup) {
720
+ separatorCount++;
721
+ prevGroup = group;
722
+ }
723
+ }
724
+ return externalIndex + separatorCount;
725
+ },
726
+ [items, groupBy, renderGroupSeparator]
727
+ );
728
+ useImperativeHandle(ref, () => ({
729
+ scrollToItem: (index, align) => {
730
+ const internalIdx = toInternalIndex(index);
731
+ console.log("[DynamicScroll.scrollToItem]", { externalIndex: index, internalIndex: internalIdx, align, isMeasuring: isMeasuringRef.current });
732
+ const action = () => innerRef.current?.scrollToItem(internalIdx, align);
733
+ isMeasuringRef.current ? pendingScrollRef.current = action : action();
734
+ },
735
+ scrollToBottom: (behavior) => {
736
+ console.log("[DynamicScroll.scrollToBottom]", { behavior, isMeasuring: isMeasuringRef.current, queued: isMeasuringRef.current });
737
+ const action = () => innerRef.current?.scrollToBottom(behavior);
738
+ isMeasuringRef.current ? pendingScrollRef.current = action : action();
739
+ },
740
+ scrollToOffset: (offset, behavior) => {
741
+ const action = () => innerRef.current?.scrollToOffset(offset, behavior);
742
+ isMeasuringRef.current ? pendingScrollRef.current = action : action();
743
+ },
744
+ getScrollOffset: () => innerRef.current?.getScrollOffset() ?? 0
745
+ }));
746
+ const stableItemsRef = useRef(allItems);
747
+ if (!isMeasuring) {
748
+ stableItemsRef.current = allItems;
749
+ }
750
+ const stableItems = isMeasuring ? stableItemsRef.current : allItems;
751
+ const { childPositions, totalHeight } = usePositions({
752
+ items: stableItems,
753
+ heightMapRef,
754
+ version,
755
+ estimatedItemSize
756
+ });
757
+ const groupByWithSeparator = useMemo(() => {
758
+ if (!groupBy) return void 0;
759
+ return (item) => {
760
+ if ("__isSeparator" in item && item.__isSeparator) {
761
+ return item.__groupKey;
762
+ }
763
+ return groupBy(item);
764
+ };
765
+ }, [groupBy]);
766
+ const groupInfo = useGroupPositions(stableItems, groupByWithSeparator, heightMapRef, version);
767
+ const virtualScrollGroupInfo = useMemo(() => {
768
+ if (!groupInfo || childPositions.length === 0) return null;
769
+ const groupStartPositions = /* @__PURE__ */ new Map();
770
+ let prevGroup = null;
771
+ for (let i = 0; i < groupInfo.groupKeyByIndex.length; i++) {
772
+ const group = groupInfo.groupKeyByIndex[i];
773
+ if (group !== prevGroup) {
774
+ groupStartPositions.set(group, childPositions[i] ?? 0);
775
+ prevGroup = group;
776
+ }
777
+ }
778
+ return {
779
+ heightByGroup: groupInfo.heightByGroup,
780
+ groupKeyByIndex: groupInfo.groupKeyByIndex,
781
+ groupStartPositions
782
+ };
783
+ }, [groupInfo, childPositions]);
784
+ if (!hasEverMeasuredRef.current) {
785
+ return /* @__PURE__ */ jsxs(
786
+ "div",
787
+ {
788
+ className,
789
+ style: {
790
+ overflow: "hidden",
791
+ position: "relative",
792
+ ...style
793
+ },
794
+ children: [
795
+ initialLoadingComponent,
796
+ unmeasuredIds.map((id) => {
797
+ const index = allItems.findIndex((item) => item.id === id);
798
+ if (index === -1) return null;
799
+ return /* @__PURE__ */ jsx(InitialMeasure, { itemId: id, onMeasured: onItemMeasured, children: wrappedRenderItem(allItems[index], index) }, id);
800
+ })
801
+ ]
802
+ }
803
+ );
804
+ }
805
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
806
+ isMeasuring && /* @__PURE__ */ jsx(
807
+ "div",
808
+ {
809
+ style: {
810
+ position: "absolute",
811
+ top: 0,
812
+ left: 0,
813
+ width: "100%",
814
+ overflow: "hidden",
815
+ height: 0,
816
+ visibility: "hidden",
817
+ pointerEvents: "none"
818
+ },
819
+ children: unmeasuredIds.map((id) => {
820
+ const index = allItems.findIndex((item) => item.id === id);
821
+ if (index === -1) return null;
822
+ return /* @__PURE__ */ jsx(InitialMeasure, { itemId: id, onMeasured: onItemMeasured, children: wrappedRenderItem(allItems[index], index) }, id);
823
+ })
824
+ }
825
+ ),
826
+ /* @__PURE__ */ jsx(
827
+ VirtualScroll,
828
+ {
829
+ ref: innerRef,
830
+ items: stableItems,
831
+ renderItem: wrappedRenderItem,
832
+ childPositions,
833
+ totalHeight,
834
+ heightMapRef,
835
+ onHeightChange,
836
+ overscanCount,
837
+ onStartReached: isMeasuring ? void 0 : onStartReached,
838
+ onEndReached: isMeasuring ? void 0 : onEndReached,
839
+ threshold,
840
+ onAtBottomChange,
841
+ syncScrollUpdates,
842
+ className,
843
+ style,
844
+ isMeasuring,
845
+ loadingComponent,
846
+ bottomLoadingComponent,
847
+ initialScrollPosition: typeof initialScrollPosition === "object" && "index" in initialScrollPosition ? { ...initialScrollPosition, index: toInternalIndex(initialScrollPosition.index) } : initialScrollPosition,
848
+ groupInfo: virtualScrollGroupInfo,
849
+ renderGroupHeader
850
+ }
851
+ )
852
+ ] });
853
+ }
854
+ var DynamicScroll = forwardRef(DynamicScrollInner);
855
+ function StickyGroupHeader({
856
+ groupKey,
857
+ renderGroupHeader,
858
+ totalHeight,
859
+ cumulativeHeight,
860
+ topOffset = 0
861
+ }) {
862
+ return /* @__PURE__ */ jsx(
863
+ "div",
864
+ {
865
+ "data-dynamic-scroll-group-header": groupKey,
866
+ style: {
867
+ position: "sticky",
868
+ top: topOffset,
869
+ zIndex: 1,
870
+ height: totalHeight - cumulativeHeight,
871
+ pointerEvents: "none"
872
+ },
873
+ children: /* @__PURE__ */ jsx("div", { style: { pointerEvents: "auto" }, children: renderGroupHeader(groupKey) })
874
+ }
875
+ );
876
+ }
877
+
878
+ export { DynamicScroll, InitialMeasure, Measure, StickyGroupHeader, VirtualScroll, generateCenteredStartNodeIndex, generateEndNodeIndex, generateStartNodeIndex, useGroupPositions, useHeightMap, usePositions, useScrollState };
879
+ //# sourceMappingURL=index.mjs.map
880
+ //# sourceMappingURL=index.mjs.map