@humanspeak/svelte-virtual-list 0.2.6-beta.0 → 0.2.6-beta.2
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/SvelteVirtualList.svelte +159 -208
- package/dist/utils/heightCalculation.d.ts +2 -1
- package/dist/utils/heightCalculation.js +8 -5
- package/dist/utils/initialization.d.ts +103 -0
- package/dist/utils/initialization.js +114 -0
- package/dist/utils/resizeObserver.d.ts +122 -0
- package/dist/utils/resizeObserver.js +176 -0
- package/dist/utils/scrollCalculation.d.ts +47 -0
- package/dist/utils/scrollCalculation.js +173 -0
- package/dist/utils/virtualList.d.ts +2 -1
- package/dist/utils/virtualList.js +107 -23
- package/package.json +4 -3
|
@@ -152,16 +152,16 @@
|
|
|
152
152
|
calculateScrollPosition,
|
|
153
153
|
calculateTransformY,
|
|
154
154
|
calculateVisibleRange,
|
|
155
|
-
getScrollOffsetForIndex,
|
|
156
|
-
processChunked,
|
|
157
155
|
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
158
156
|
} from './utils/virtualList.js'
|
|
159
157
|
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
158
|
+
import { calculateScrollTarget } from './utils/scrollCalculation.js'
|
|
159
|
+
import { initializeVirtualList } from './utils/initialization.js'
|
|
160
160
|
import { BROWSER } from 'esm-env'
|
|
161
161
|
import { onMount, tick } from 'svelte'
|
|
162
162
|
|
|
163
163
|
const rafSchedule = createRafScheduler()
|
|
164
|
-
|
|
164
|
+
const INTERNAL_DEBUG = false
|
|
165
165
|
/**
|
|
166
166
|
* Core configuration props with default values
|
|
167
167
|
* @type {SvelteVirtualListProps}
|
|
@@ -241,25 +241,74 @@
|
|
|
241
241
|
calculatedItemHeight = result.newHeight
|
|
242
242
|
lastMeasuredIndex = result.newLastMeasuredIndex
|
|
243
243
|
heightCache = result.updatedHeightCache
|
|
244
|
-
|
|
244
|
+
|
|
245
|
+
// Update running totals for precise height calculation (only when significant changes)
|
|
246
|
+
if (result.clearedDirtyItems.size > 10) {
|
|
247
|
+
const heights = Object.values(heightCache)
|
|
248
|
+
totalMeasuredHeight = heights.reduce((sum, h) => sum + h, 0)
|
|
249
|
+
measuredCount = heights.length
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Clear processed dirty items
|
|
253
|
+
result.clearedDirtyItems.forEach((index) => {
|
|
254
|
+
dirtyItems.delete(index)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
if (INTERNAL_DEBUG && result.clearedDirtyItems.size > 0) {
|
|
258
|
+
console.log(
|
|
259
|
+
`Cleared ${result.clearedDirtyItems.size} dirty items:`,
|
|
260
|
+
Array.from(result.clearedDirtyItems)
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
100, // debounceTime
|
|
265
|
+
dirtyItems // Pass dirty items for processing
|
|
245
266
|
)
|
|
246
267
|
}
|
|
247
268
|
|
|
248
269
|
// Add new effect to handle height changes
|
|
270
|
+
// Track if user has scrolled away from bottom to prevent snap-back
|
|
271
|
+
let userHasScrolledAway = $state(false)
|
|
272
|
+
let lastCalculatedHeight = $state(0)
|
|
273
|
+
|
|
249
274
|
$effect(() => {
|
|
250
275
|
if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
|
|
251
276
|
const totalHeight = Math.max(0, items.length * calculatedItemHeight)
|
|
252
277
|
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
278
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
279
|
+
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
280
|
+
|
|
281
|
+
// Only correct scroll if:
|
|
282
|
+
// 1. Item height changed significantly (not just user scrolling)
|
|
283
|
+
// 2. User hasn't intentionally scrolled away from bottom
|
|
284
|
+
// 3. We're significantly off target
|
|
285
|
+
const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
|
|
286
|
+
const shouldCorrect =
|
|
287
|
+
heightChanged && !userHasScrolledAway && scrollDifference > calculatedItemHeight * 3
|
|
288
|
+
|
|
289
|
+
if (shouldCorrect) {
|
|
290
|
+
if (INTERNAL_DEBUG) {
|
|
291
|
+
console.log(
|
|
292
|
+
'🔄 Correcting scroll position from',
|
|
293
|
+
currentScrollTop,
|
|
294
|
+
'to',
|
|
295
|
+
targetScrollTop,
|
|
296
|
+
'diff:',
|
|
297
|
+
scrollDifference,
|
|
298
|
+
'heightChanged:',
|
|
299
|
+
heightChanged
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
viewportElement.scrollTop = targetScrollTop
|
|
303
|
+
scrollTop = targetScrollTop
|
|
304
|
+
}
|
|
253
305
|
|
|
254
|
-
//
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
if (viewportElement) {
|
|
258
|
-
viewportElement.scrollTop = targetScrollTop
|
|
259
|
-
scrollTop = targetScrollTop
|
|
260
|
-
}
|
|
261
|
-
})
|
|
306
|
+
// Track if user has scrolled significantly away from bottom
|
|
307
|
+
if (scrollDifference > calculatedItemHeight * 5) {
|
|
308
|
+
userHasScrolledAway = true
|
|
262
309
|
}
|
|
310
|
+
|
|
311
|
+
lastCalculatedHeight = calculatedItemHeight
|
|
263
312
|
}
|
|
264
313
|
})
|
|
265
314
|
|
|
@@ -303,6 +352,23 @@
|
|
|
303
352
|
}
|
|
304
353
|
})
|
|
305
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Calculate precise item height based on actual measurements when available
|
|
357
|
+
*/
|
|
358
|
+
// Running totals for efficient precise height calculation
|
|
359
|
+
let totalMeasuredHeight = $state(0)
|
|
360
|
+
let measuredCount = $state(0)
|
|
361
|
+
const preciseItemHeight = $derived(() => {
|
|
362
|
+
if (measuredCount > 100) {
|
|
363
|
+
const avgHeight = totalMeasuredHeight / measuredCount
|
|
364
|
+
// Only use if the difference is significant (more than 0.5px)
|
|
365
|
+
if (Math.abs(avgHeight - calculatedItemHeight) > 0.5) {
|
|
366
|
+
return avgHeight
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return calculatedItemHeight
|
|
370
|
+
})
|
|
371
|
+
|
|
306
372
|
/**
|
|
307
373
|
* Calculates the range of items that should be rendered based on current scroll position.
|
|
308
374
|
*
|
|
@@ -326,7 +392,27 @@
|
|
|
326
392
|
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
327
393
|
const viewportHeight = height || 0
|
|
328
394
|
|
|
329
|
-
|
|
395
|
+
// For bottomToTop mode, don't calculate visible range until properly initialized
|
|
396
|
+
// This prevents showing wrong items when scrollTop starts at 0
|
|
397
|
+
if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
|
|
398
|
+
// Calculate what the correct scroll position should be
|
|
399
|
+
const totalHeight = items.length * calculatedItemHeight
|
|
400
|
+
const targetScrollTop = Math.max(0, totalHeight - viewportHeight)
|
|
401
|
+
|
|
402
|
+
// Use the target scroll position for visible range calculation
|
|
403
|
+
const result = calculateVisibleRange(
|
|
404
|
+
targetScrollTop,
|
|
405
|
+
viewportHeight,
|
|
406
|
+
calculatedItemHeight,
|
|
407
|
+
items.length,
|
|
408
|
+
bufferSize,
|
|
409
|
+
mode
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
return result
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const result = calculateVisibleRange(
|
|
330
416
|
scrollTop,
|
|
331
417
|
viewportHeight,
|
|
332
418
|
calculatedItemHeight,
|
|
@@ -334,6 +420,8 @@
|
|
|
334
420
|
bufferSize,
|
|
335
421
|
mode
|
|
336
422
|
)
|
|
423
|
+
|
|
424
|
+
return result
|
|
337
425
|
})
|
|
338
426
|
|
|
339
427
|
/**
|
|
@@ -442,50 +530,15 @@
|
|
|
442
530
|
)
|
|
443
531
|
}
|
|
444
532
|
|
|
445
|
-
|
|
446
|
-
* Initializes large datasets in chunks to prevent UI blocking.
|
|
447
|
-
*
|
|
448
|
-
* This function processes items in smaller chunks using setTimeout to yield
|
|
449
|
-
* to the main thread, allowing other UI operations to remain responsive.
|
|
450
|
-
* Progress is tracked and reported through the processedItems state.
|
|
451
|
-
*
|
|
452
|
-
* For datasets larger than 1000 items, this method is automatically used
|
|
453
|
-
* instead of immediate initialization. The chunk size is controlled by the
|
|
454
|
-
* component's chunkSize state (default: 50).
|
|
455
|
-
*
|
|
456
|
-
* @async
|
|
457
|
-
* @example
|
|
458
|
-
* ```typescript
|
|
459
|
-
* // Component initialization
|
|
460
|
-
* $effect(() => {
|
|
461
|
-
* if (BROWSER && items.length > 1000) {
|
|
462
|
-
* initializeChunked()
|
|
463
|
-
* } else {
|
|
464
|
-
* initialized = true
|
|
465
|
-
* }
|
|
466
|
-
* })
|
|
467
|
-
* ```
|
|
468
|
-
*
|
|
469
|
-
* @throws {Error} If processChunked fails to complete initialization
|
|
470
|
-
* @returns {Promise<void>} Resolves when all chunks have been processed
|
|
471
|
-
*/
|
|
472
|
-
const initializeChunked = async () => {
|
|
473
|
-
if (!items.length) return
|
|
474
|
-
|
|
475
|
-
await processChunked(
|
|
476
|
-
items,
|
|
477
|
-
chunkSize,
|
|
478
|
-
(processed) => (processedItems = processed),
|
|
479
|
-
() => (initialized = true)
|
|
480
|
-
)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Modify the mount effect to use chunked initialization
|
|
533
|
+
// Initialize the virtual list when items change
|
|
484
534
|
$effect(() => {
|
|
485
|
-
if (BROWSER
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
535
|
+
if (BROWSER) {
|
|
536
|
+
initializeVirtualList({
|
|
537
|
+
items,
|
|
538
|
+
chunkSize,
|
|
539
|
+
onProgress: (processed) => (processedItems = processed),
|
|
540
|
+
onComplete: () => (initialized = true)
|
|
541
|
+
})
|
|
489
542
|
}
|
|
490
543
|
})
|
|
491
544
|
|
|
@@ -495,7 +548,7 @@
|
|
|
495
548
|
itemResizeObserver = new ResizeObserver((entries) => {
|
|
496
549
|
let shouldRecalculate = false
|
|
497
550
|
|
|
498
|
-
if (
|
|
551
|
+
if (INTERNAL_DEBUG) {
|
|
499
552
|
console.log(`ResizeObserver fired for ${entries.length} entries`)
|
|
500
553
|
}
|
|
501
554
|
|
|
@@ -510,7 +563,7 @@
|
|
|
510
563
|
dirtyItems.add(actualIndex)
|
|
511
564
|
shouldRecalculate = true
|
|
512
565
|
|
|
513
|
-
if (
|
|
566
|
+
if (INTERNAL_DEBUG) {
|
|
514
567
|
console.log(
|
|
515
568
|
`Item ${actualIndex} marked dirty (resized), queue size: ${dirtyItems.size}`
|
|
516
569
|
)
|
|
@@ -556,7 +609,7 @@
|
|
|
556
609
|
|
|
557
610
|
// Add the effect in the script section
|
|
558
611
|
$effect(() => {
|
|
559
|
-
if (
|
|
612
|
+
if (INTERNAL_DEBUG) {
|
|
560
613
|
prevVisibleRange = visibleItems()
|
|
561
614
|
prevHeight = calculatedItemHeight
|
|
562
615
|
}
|
|
@@ -661,144 +714,30 @@
|
|
|
661
714
|
}
|
|
662
715
|
|
|
663
716
|
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
|
|
664
|
-
let scrollTarget: number | null = null
|
|
665
717
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
// Calculate the offset of the item relative to the viewport
|
|
680
|
-
const itemTop = totalHeight - (itemOffset + itemHeight)
|
|
681
|
-
const itemBottom = totalHeight - itemOffset
|
|
682
|
-
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
683
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
684
|
-
if (distanceToTop < distanceToBottom) {
|
|
685
|
-
// Closer to top, align to top
|
|
686
|
-
scrollTarget = itemTop
|
|
687
|
-
} else {
|
|
688
|
-
// Closer to bottom, align to bottom
|
|
689
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
} else if (align === 'top') {
|
|
693
|
-
// Align to top
|
|
694
|
-
scrollTarget = Math.max(0, totalHeight - (itemOffset + itemHeight))
|
|
695
|
-
} else if (align === 'bottom') {
|
|
696
|
-
// Align to bottom
|
|
697
|
-
scrollTarget = Math.max(0, totalHeight - itemOffset - height)
|
|
698
|
-
} else if (align === 'nearest') {
|
|
699
|
-
// If not visible, align to nearest edge; if visible, do nothing
|
|
700
|
-
const itemTop = totalHeight - (itemOffset + itemHeight)
|
|
701
|
-
const itemBottom = totalHeight - itemOffset
|
|
702
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
703
|
-
// Not visible, align to nearest edge
|
|
704
|
-
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
705
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
706
|
-
if (distanceToTop < distanceToBottom) {
|
|
707
|
-
scrollTarget = itemTop
|
|
708
|
-
} else {
|
|
709
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
710
|
-
}
|
|
711
|
-
} else {
|
|
712
|
-
// Already visible, do nothing
|
|
713
|
-
return
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
} else {
|
|
717
|
-
// topToBottom (default)
|
|
718
|
-
if (align === 'auto') {
|
|
719
|
-
// If item is above the viewport, align to top
|
|
720
|
-
if (targetIndex < firstVisibleIndex) {
|
|
721
|
-
scrollTarget = getScrollOffsetForIndex(
|
|
722
|
-
heightCache,
|
|
723
|
-
calculatedItemHeight,
|
|
724
|
-
targetIndex
|
|
725
|
-
)
|
|
726
|
-
// If item is below the viewport, align to bottom
|
|
727
|
-
} else if (targetIndex > lastVisibleIndex - 1) {
|
|
728
|
-
const itemBottom = getScrollOffsetForIndex(
|
|
729
|
-
heightCache,
|
|
730
|
-
calculatedItemHeight,
|
|
731
|
-
targetIndex + 1
|
|
732
|
-
)
|
|
733
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
734
|
-
} else {
|
|
735
|
-
// Item is visible but not aligned: align to nearest edge
|
|
736
|
-
const itemTop = getScrollOffsetForIndex(
|
|
737
|
-
heightCache,
|
|
738
|
-
calculatedItemHeight,
|
|
739
|
-
targetIndex
|
|
740
|
-
)
|
|
741
|
-
const itemBottom = getScrollOffsetForIndex(
|
|
742
|
-
heightCache,
|
|
743
|
-
calculatedItemHeight,
|
|
744
|
-
targetIndex + 1
|
|
745
|
-
)
|
|
746
|
-
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
747
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
748
|
-
if (distanceToTop < distanceToBottom) {
|
|
749
|
-
// Closer to top, align to top
|
|
750
|
-
scrollTarget = itemTop
|
|
751
|
-
} else {
|
|
752
|
-
// Closer to bottom, align to bottom
|
|
753
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
} else if (align === 'top') {
|
|
757
|
-
scrollTarget = getScrollOffsetForIndex(
|
|
758
|
-
heightCache,
|
|
759
|
-
calculatedItemHeight,
|
|
760
|
-
targetIndex
|
|
761
|
-
)
|
|
762
|
-
} else if (align === 'bottom') {
|
|
763
|
-
const itemBottom = getScrollOffsetForIndex(
|
|
764
|
-
heightCache,
|
|
765
|
-
calculatedItemHeight,
|
|
766
|
-
targetIndex + 1
|
|
767
|
-
)
|
|
768
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
769
|
-
} else if (align === 'nearest') {
|
|
770
|
-
const itemTop = getScrollOffsetForIndex(
|
|
771
|
-
heightCache,
|
|
772
|
-
calculatedItemHeight,
|
|
773
|
-
targetIndex
|
|
774
|
-
)
|
|
775
|
-
const itemBottom = getScrollOffsetForIndex(
|
|
776
|
-
heightCache,
|
|
777
|
-
calculatedItemHeight,
|
|
778
|
-
targetIndex + 1
|
|
779
|
-
)
|
|
780
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
781
|
-
// Not visible, align to nearest edge
|
|
782
|
-
const distanceToTop = Math.abs(scrollTop - itemTop)
|
|
783
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom)
|
|
784
|
-
if (distanceToTop < distanceToBottom) {
|
|
785
|
-
scrollTarget = itemTop
|
|
786
|
-
} else {
|
|
787
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
788
|
-
}
|
|
789
|
-
} else {
|
|
790
|
-
// Already visible, do nothing
|
|
791
|
-
return
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
718
|
+
// Use extracted scroll calculation utility
|
|
719
|
+
const scrollTarget = calculateScrollTarget({
|
|
720
|
+
mode,
|
|
721
|
+
align,
|
|
722
|
+
targetIndex,
|
|
723
|
+
itemsLength: items.length,
|
|
724
|
+
calculatedItemHeight,
|
|
725
|
+
height,
|
|
726
|
+
scrollTop,
|
|
727
|
+
firstVisibleIndex,
|
|
728
|
+
lastVisibleIndex,
|
|
729
|
+
heightCache
|
|
730
|
+
})
|
|
795
731
|
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
800
|
-
})
|
|
732
|
+
// Handle early return for 'nearest' alignment when item is already visible
|
|
733
|
+
if (scrollTarget === null) {
|
|
734
|
+
return
|
|
801
735
|
}
|
|
736
|
+
|
|
737
|
+
viewportElement.scrollTo({
|
|
738
|
+
top: scrollTarget,
|
|
739
|
+
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
740
|
+
})
|
|
802
741
|
}
|
|
803
742
|
|
|
804
743
|
/**
|
|
@@ -811,7 +750,7 @@
|
|
|
811
750
|
function autoObserveItemResize(element: HTMLElement) {
|
|
812
751
|
if (itemResizeObserver) {
|
|
813
752
|
itemResizeObserver.observe(element)
|
|
814
|
-
if (
|
|
753
|
+
if (INTERNAL_DEBUG) {
|
|
815
754
|
console.log(
|
|
816
755
|
'Started observing element:',
|
|
817
756
|
element,
|
|
@@ -819,7 +758,7 @@
|
|
|
819
758
|
element.getBoundingClientRect().height
|
|
820
759
|
)
|
|
821
760
|
}
|
|
822
|
-
} else if (
|
|
761
|
+
} else if (INTERNAL_DEBUG) {
|
|
823
762
|
console.log('itemResizeObserver not available for element:', element)
|
|
824
763
|
}
|
|
825
764
|
|
|
@@ -827,7 +766,7 @@
|
|
|
827
766
|
destroy() {
|
|
828
767
|
if (itemResizeObserver) {
|
|
829
768
|
itemResizeObserver.unobserve(element)
|
|
830
|
-
if (
|
|
769
|
+
if (INTERNAL_DEBUG) {
|
|
831
770
|
console.log('Stopped observing element:', element)
|
|
832
771
|
}
|
|
833
772
|
}
|
|
@@ -862,24 +801,36 @@
|
|
|
862
801
|
id="virtual-list-content"
|
|
863
802
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
864
803
|
class={contentClass ?? 'virtual-list-content'}
|
|
865
|
-
style:height="{
|
|
804
|
+
style:height="{(() => {
|
|
805
|
+
// Use precise height when available for better cross-browser compatibility
|
|
806
|
+
const totalActualHeight = items.length * preciseItemHeight()
|
|
807
|
+
return Math.max(height, totalActualHeight)
|
|
808
|
+
})()}px"
|
|
866
809
|
>
|
|
867
810
|
<!-- Items container is translated to show correct items -->
|
|
868
811
|
<div
|
|
869
812
|
id="virtual-list-items"
|
|
870
813
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
871
814
|
class={itemsClass ?? 'virtual-list-items'}
|
|
872
|
-
style:transform="translateY({
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
815
|
+
style:transform="translateY({(() => {
|
|
816
|
+
const transform = calculateTransformY(
|
|
817
|
+
mode,
|
|
818
|
+
items.length,
|
|
819
|
+
visibleItems().end,
|
|
820
|
+
visibleItems().start,
|
|
821
|
+
calculatedItemHeight
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
return transform
|
|
825
|
+
})()}px)"
|
|
879
826
|
>
|
|
880
|
-
{#each
|
|
881
|
-
|
|
882
|
-
|
|
827
|
+
{#each (() => {
|
|
828
|
+
const slice = mode === 'bottomToTop' ? items
|
|
829
|
+
.slice(visibleItems().start, visibleItems().end)
|
|
830
|
+
.reverse() : items.slice(visibleItems().start, visibleItems().end)
|
|
831
|
+
|
|
832
|
+
return slice
|
|
833
|
+
})() as currentItem, i (currentItem?.id ?? i)}
|
|
883
834
|
<!-- Only debug when visible range or average height changes -->
|
|
884
835
|
{#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
|
|
885
836
|
{@const debugInfo = createDebugInfo(
|
|
@@ -74,4 +74,5 @@ export declare const calculateAverageHeightDebounced: (isCalculatingHeight: bool
|
|
|
74
74
|
newHeight: number;
|
|
75
75
|
newLastMeasuredIndex: number;
|
|
76
76
|
updatedHeightCache: Record<number, number>;
|
|
77
|
-
|
|
77
|
+
clearedDirtyItems: Set<number>;
|
|
78
|
+
}) => void, debounceTime: number, dirtyItems: Set<number>) => NodeJS.Timeout | null;
|
|
@@ -71,20 +71,23 @@ import { BROWSER } from 'esm-env';
|
|
|
71
71
|
*/
|
|
72
72
|
export const calculateAverageHeightDebounced = (isCalculatingHeight, heightUpdateTimeout, visibleItemsGetter, itemElements, heightCache, lastMeasuredIndex, calculatedItemHeight,
|
|
73
73
|
/* trunk-ignore(eslint/no-unused-vars) */
|
|
74
|
-
onUpdate, debounceTime
|
|
75
|
-
if (!BROWSER || isCalculatingHeight
|
|
74
|
+
onUpdate, debounceTime, dirtyItems) => {
|
|
75
|
+
if (!BROWSER || isCalculatingHeight)
|
|
76
76
|
return null;
|
|
77
77
|
const visibleRange = visibleItemsGetter();
|
|
78
78
|
const currentIndex = visibleRange.start;
|
|
79
79
|
if (currentIndex === lastMeasuredIndex)
|
|
80
80
|
return null;
|
|
81
|
+
if (heightUpdateTimeout)
|
|
82
|
+
clearTimeout(heightUpdateTimeout);
|
|
81
83
|
return setTimeout(() => {
|
|
82
|
-
const { newHeight, newLastMeasuredIndex, updatedHeightCache } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight);
|
|
83
|
-
if (Math.abs(newHeight - calculatedItemHeight) > 1) {
|
|
84
|
+
const { newHeight, newLastMeasuredIndex, updatedHeightCache, clearedDirtyItems } = calculateAverageHeight(itemElements, visibleRange, heightCache, calculatedItemHeight, dirtyItems);
|
|
85
|
+
if (Math.abs(newHeight - calculatedItemHeight) > 1 || dirtyItems.size > 0) {
|
|
84
86
|
onUpdate({
|
|
85
87
|
newHeight,
|
|
86
88
|
newLastMeasuredIndex,
|
|
87
|
-
updatedHeightCache
|
|
89
|
+
updatedHeightCache,
|
|
90
|
+
clearedDirtyItems
|
|
88
91
|
});
|
|
89
92
|
}
|
|
90
93
|
}, debounceTime);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for virtual list initialization
|
|
3
|
+
*/
|
|
4
|
+
export interface InitializationConfig {
|
|
5
|
+
/** Array of items to initialize */
|
|
6
|
+
items: unknown[];
|
|
7
|
+
/** Number of items to process in each chunk */
|
|
8
|
+
chunkSize: number;
|
|
9
|
+
/** Threshold above which to use chunked initialization */
|
|
10
|
+
chunkThreshold?: number;
|
|
11
|
+
/** Callback called with progress updates during chunked initialization */
|
|
12
|
+
onProgress?: (processedItems: number, totalItems: number) => void;
|
|
13
|
+
/** Callback called when initialization is complete */
|
|
14
|
+
onComplete?: () => void;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Determines whether to use chunked initialization based on item count and threshold.
|
|
18
|
+
*
|
|
19
|
+
* @param itemCount - Number of items to initialize
|
|
20
|
+
* @param threshold - Threshold above which chunked initialization is used (default: 1000)
|
|
21
|
+
* @returns True if chunked initialization should be used
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const useChunked = shouldUseChunkedInitialization(5000) // true
|
|
26
|
+
* const useImmediate = shouldUseChunkedInitialization(500) // false
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export declare const shouldUseChunkedInitialization: (itemCount: number, threshold?: number) => boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Initializes a virtual list with items, using chunked processing for large datasets.
|
|
32
|
+
*
|
|
33
|
+
* This function automatically determines whether to use immediate or chunked initialization
|
|
34
|
+
* based on the number of items. For large datasets, it processes items in chunks to
|
|
35
|
+
* prevent UI blocking, yielding to the main thread between chunks.
|
|
36
|
+
*
|
|
37
|
+
* @param config - Configuration object for initialization
|
|
38
|
+
* @returns Promise that resolves when initialization is complete
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* import { initializeVirtualList } from './initialization.js'
|
|
43
|
+
*
|
|
44
|
+
* // Initialize with progress tracking
|
|
45
|
+
* await initializeVirtualList({
|
|
46
|
+
* items: largeDataset,
|
|
47
|
+
* chunkSize: 50,
|
|
48
|
+
* onProgress: (processed, total) => {
|
|
49
|
+
* console.log(`Progress: ${processed}/${total}`)
|
|
50
|
+
* },
|
|
51
|
+
* onComplete: () => {
|
|
52
|
+
* console.log('Initialization complete!')
|
|
53
|
+
* }
|
|
54
|
+
* })
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare const initializeVirtualList: (config: InitializationConfig) => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Calculates the optimal chunk size for initialization based on item count and device capabilities.
|
|
60
|
+
*
|
|
61
|
+
* This function provides a heuristic for determining an appropriate chunk size that balances
|
|
62
|
+
* performance and responsiveness. It considers both the total number of items and the
|
|
63
|
+
* estimated processing time per item.
|
|
64
|
+
*
|
|
65
|
+
* @param itemCount - Total number of items to process
|
|
66
|
+
* @param baseChunkSize - Base chunk size to use as a starting point (default: 50)
|
|
67
|
+
* @returns Recommended chunk size
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const chunkSize = calculateOptimalChunkSize(10000) // Returns optimized chunk size
|
|
72
|
+
* const smallChunkSize = calculateOptimalChunkSize(100) // Returns smaller chunk size
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export declare const calculateOptimalChunkSize: (itemCount: number, baseChunkSize?: number) => number;
|
|
76
|
+
/**
|
|
77
|
+
* Progress information for initialization
|
|
78
|
+
*/
|
|
79
|
+
export interface InitializationProgress {
|
|
80
|
+
/** Number of items processed */
|
|
81
|
+
processed: number;
|
|
82
|
+
/** Total number of items */
|
|
83
|
+
total: number;
|
|
84
|
+
/** Percentage complete (0-100) */
|
|
85
|
+
percentage: number;
|
|
86
|
+
/** Whether initialization is complete */
|
|
87
|
+
isComplete: boolean;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Creates a progress tracking object for initialization.
|
|
91
|
+
*
|
|
92
|
+
* @param processed - Number of items processed
|
|
93
|
+
* @param total - Total number of items
|
|
94
|
+
* @returns Progress information object
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```typescript
|
|
98
|
+
* const progress = createProgressInfo(750, 1000)
|
|
99
|
+
* console.log(progress.percentage) // 75
|
|
100
|
+
* console.log(progress.isComplete) // false
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export declare const createProgressInfo: (processed: number, total: number) => InitializationProgress;
|