import { untrack } from 'svelte'; export function createVirtualizer(optionsGetter: () => VirtualizerOptions) { // Reactive State let scrollOffset = $state(0); let containerHeight = $state(0); let measuredSizes = $state>({}); // Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking) let elementRef: HTMLElement | null = null; // Reactive Options const options = $derived(optionsGetter()); // Optimized Memoization (The Cache Layer) // Only recalculates when item count or measured sizes change. const offsets = $derived.by(() => { const count = options.count; const result = new Array(count); let accumulated = 0; for (let i = 0; i < count; i++) { result[i] = accumulated; accumulated += measuredSizes[i] ?? options.estimateSize(i); } return result; }); const totalSize = $derived( options.count > 0 ? offsets[options.count - 1] + (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1)) : 0, ); // Visible Range Calculation // Svelte tracks dependencies automatically here. const items = $derived.by((): VirtualItem[] => { const count = options.count; if (count === 0 || containerHeight === 0) return []; const overscan = options.overscan ?? 5; const viewportStart = scrollOffset; const viewportEnd = scrollOffset + containerHeight; // Find Start (Linear Scan) let startIdx = 0; while (startIdx < count && offsets[startIdx + 1] < viewportStart) { startIdx++; } // Find End let endIdx = startIdx; while (endIdx < count && offsets[endIdx] < viewportEnd) { endIdx++; } const start = Math.max(0, startIdx - overscan); const end = Math.min(count, endIdx + overscan); const result: VirtualItem[] = []; for (let i = start; i < end; i++) { const size = measuredSizes[i] ?? options.estimateSize(i); result.push({ index: i, start: offsets[i], size, end: offsets[i] + size, key: options.getItemKey?.(i) ?? i, }); } return result; }); // Svelte Actions (The DOM Interface) function container(node: HTMLElement) { elementRef = node; containerHeight = node.offsetHeight; const handleScroll = () => { scrollOffset = node.scrollTop; }; const resizeObserver = new ResizeObserver(([entry]) => { if (entry) containerHeight = entry.contentRect.height; }); node.addEventListener('scroll', handleScroll, { passive: true }); resizeObserver.observe(node); return { destroy() { node.removeEventListener('scroll', handleScroll); resizeObserver.disconnect(); elementRef = null; }, }; } function measureElement(node: HTMLElement) { // Use a ResizeObserver on individual items for dynamic height support const resizeObserver = new ResizeObserver(([entry]) => { if (entry) { const index = parseInt(node.dataset.index || '', 10); const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; // Only update if height actually changed to prevent loops if (!isNaN(index) && measuredSizes[index] !== height) { measuredSizes[index] = height; } } }); resizeObserver.observe(node); return { destroy: () => resizeObserver.disconnect(), }; } // Programmatic Scroll function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') { if (!elementRef || index < 0 || index >= options.count) return; const itemStart = offsets[index]; const itemSize = measuredSizes[index] ?? options.estimateSize(index); let target = itemStart; if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (align === 'end') target = itemStart - containerHeight + itemSize; elementRef.scrollTo({ top: target, behavior: 'smooth' }); } return { get items() { return items; }, get totalSize() { return totalSize; }, container, measureElement, scrollToIndex, }; } export interface VirtualItem { /** Index of the item in the data array */ index: number; /** Offset from the top of the list */ start: number; /** Height of the item */ size: number; /** End position (start + size) */ end: number; /** Unique key for the item (for Svelte's {#each} keying) */ key: string | number; } export interface VirtualizerOptions { /** Total number of items in the data array */ count: number; /** Function to estimate the size of an item at a given index */ estimateSize: (index: number) => number; /** Number of extra items to render outside viewport (default: 5) */ overscan?: number; /** Function to get the key of an item at a given index (defaults to index) */ getItemKey?: (index: number) => string | number; /** Optional margin in pixels for scroll calculations */ scrollMargin?: number; } export type Virtualizer = ReturnType;