/** * Represents a virtualized list item with layout information. * * Used to render visible items with absolute positioning based on computed offsets. */ export interface VirtualItem { /** * Index of the item in the data array */ index: number; /** * Offset from the top of the list in pixels */ start: number; /** * Height/size of the item in pixels */ size: number; /** * End position in pixels (start + size) */ end: number; /** * Unique key for the item (for Svelte's {#each} keying) */ key: string | number; /** * Whether the item is currently fully visible in the viewport */ isFullyVisible: boolean; /** * Whether the item is currently partially visible in the viewport */ isPartiallyVisible: boolean; /** * Proximity of the item to the center of the viewport */ proximity: number; } /** * Configuration options for {@link createVirtualizer}. * * Options are reactive - pass them through a function getter to enable updates. */ 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. * Used for initial layout before actual measurements are available. */ estimateSize: (index: number) => number; /** Number of extra items to render outside viewport for smoother scrolling (default: 5) */ overscan?: number; /** * Function to get the key of an item at a given index. * Defaults to using the index directly. Useful for stable keys when items reorder. */ getItemKey?: (index: number) => string | number; /** * Optional margin in pixels for scroll calculations. * Can be useful for handling sticky headers or other UI elements. */ scrollMargin?: number; /** * Whether to use the window as the scroll container. * @default false */ useWindowScroll?: boolean; } /** * Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items. * * Uses Svelte 5 runes ($state, $derived) for reactive state management and optimizes rendering * through scroll position tracking and item height measurement. Supports dynamic item heights * and programmatic scrolling. * * @param optionsGetter - Function that returns reactive virtualizer options * @returns Virtualizer instance with computed properties and action functions * * @example * ```svelte * * *
*
* {#each virtualizer.items as item (item.key)} *
* Item {item.index} *
* {/each} *
*
* ``` */ export function createVirtualizer( optionsGetter: () => VirtualizerOptions & { data: T[]; }, ) { let scrollOffset = $state(0); let containerHeight = $state(0); let measuredSizes = $state>({}); let elementRef: HTMLElement | null = null; let elementOffsetTop = 0; // By wrapping the getter in $derived, we track everything inside it const options = $derived(optionsGetter()); // This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself const offsets = $derived.by(() => { const count = options.count; // Implicit dependency on version signal const v = _version; const result = new Float64Array(count); let accumulated = 0; for (let i = 0; i < count; i++) { result[i] = accumulated; // Accessing measuredSizes here creates the subscription 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, ); const items = $derived.by((): VirtualItem[] => { // We MUST read options.data here so Svelte knows to re-run // this derivation when the items array is replaced! const { count, data } = options; // Implicit dependency const v = _version; if (count === 0 || containerHeight === 0 || !data) return []; const overscan = options.overscan ?? 5; // Binary search for efficiency let low = 0; let high = count - 1; let startIdx = 0; while (low <= high) { const mid = Math.floor((low + high) / 2); if (offsets[mid] <= scrollOffset) { startIdx = mid; low = mid + 1; } else { high = mid - 1; } } let endIdx = startIdx; const viewportEnd = scrollOffset + containerHeight; const viewportCenter = scrollOffset + (containerHeight / 2); 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 itemStart = offsets[i]; const itemSize = measuredSizes[i] ?? options.estimateSize(i); const itemEnd = itemStart + itemSize; // Visibility check: Does the item overlap the viewport? const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset; const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; // Proximity calculation: 1.0 at center, 0.0 at edges const itemCenter = itemStart + (itemSize / 2); const distanceToCenter = Math.abs(viewportCenter - itemCenter); const maxDistance = containerHeight / 2; const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance)); result.push({ index: i, start: itemStart, size: itemSize, end: itemEnd, key: options.getItemKey?.(i) ?? i, isPartiallyVisible, isFullyVisible, proximity, }); } // console.log('🎯 Virtual Items Calculation:', { // scrollOffset, // containerHeight, // viewportEnd, // startIdx, // endIdx, // withOverscan: { start, end }, // itemCount: end - start, // }); return result; }); // Svelte Actions (The DOM Interface) /** * Svelte action to attach to the scrollable container element. * * Sets up scroll tracking, container height monitoring, and cleanup on destroy. * * @param node - The DOM element to attach to (should be the scrollable container) * @returns Object with destroy method for cleanup */ function container(node: HTMLElement) { elementRef = node; const { useWindowScroll } = optionsGetter(); if (useWindowScroll) { // Calculate initial offset ONCE const getElementOffset = () => { const rect = node.getBoundingClientRect(); return rect.top + window.scrollY; }; let cachedOffsetTop = 0; let rafId: number | null = null; containerHeight = window.innerHeight; const handleScroll = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { // Get current position of element relative to viewport const rect = node.getBoundingClientRect(); // Calculate how much of the element has scrolled past the top of viewport // When element.top is 0, element is at top of viewport // When element.top is -100, element has scrolled up 100px past viewport top const scrolledPastTop = Math.max(0, -rect.top); scrollOffset = scrolledPastTop; rafId = null; }); // 🔍 DIAGNOSTIC // console.log('📜 Scroll Event:', { // windowScrollY: window.scrollY, // elementRectTop: rect.top, // scrolledPastTop, // containerHeight // }); }; const handleResize = () => { containerHeight = window.innerHeight; cachedOffsetTop = getElementOffset(); handleScroll(); }; // Initial setup requestAnimationFrame(() => { cachedOffsetTop = getElementOffset(); handleScroll(); }); window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('resize', handleResize); return { destroy() { window.removeEventListener('scroll', handleScroll); window.removeEventListener('resize', handleResize); if (frameId !== null) { cancelAnimationFrame(frameId); frameId = null; } if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } elementRef = null; }, }; } else { 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; }, }; } } let measurementBuffer: Record = {}; let frameId: number | null = null; // Signal to trigger updates when mutating measuredSizes in place let _version = $state(0); /** * Svelte action to measure individual item elements for dynamic height support. * * Attaches a ResizeObserver to track actual element height and updates * measured sizes when dimensions change. Requires `data-index` attribute on the element. * * @param node - The DOM element to measure (should have `data-index` attribute) * @returns Object with destroy method for cleanup */ function measureElement(node: HTMLElement) { const resizeObserver = new ResizeObserver(([entry]) => { if (!entry) return; const index = parseInt(node.dataset.index || '', 10); const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; if (!isNaN(index)) { // Accessing the version ensures we have the latest state if needed, // though here we just read the raw object. const oldHeight = measuredSizes[index]; // Only update if the height difference is significant (> 0.5px) if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { // Stuff the measurement into a temporary buffer to batch updates measurementBuffer[index] = height; // Schedule a single update for the next animation frame if (frameId === null) { frameId = requestAnimationFrame(() => { // Mutation in place for performance Object.assign(measuredSizes, measurementBuffer); // Trigger reactivity _version += 1; // Reset buffer measurementBuffer = {}; frameId = null; }); } } } }); resizeObserver.observe(node); return { destroy: () => resizeObserver.disconnect() }; } // Programmatic Scroll /** * Scrolls the container to bring the specified item into view. * * @param index - Index of the item to scroll to * @param align - Scroll alignment: 'start', 'center', 'end', or 'auto' (default) * * @example * ```ts * virtualizer.scrollToIndex(50, 'center'); // Scroll to item 50 and center it * ``` */ 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; const { useWindowScroll } = optionsGetter(); if (useWindowScroll) { if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2; if (align === 'end') target = itemStart - window.innerHeight + itemSize; // Add container offset to target to get absolute document position const absoluteTarget = target + elementOffsetTop; window.scrollTo({ top: absoluteTarget, behavior: 'smooth' }); } else { if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (align === 'end') target = itemStart - containerHeight + itemSize; elementRef.scrollTo({ top: target, behavior: 'smooth' }); } } return { get scrollOffset() { return scrollOffset; }, get containerHeight() { return containerHeight; }, /** Computed array of visible items to render (reactive) */ get items() { return items; }, /** Total height of all items in pixels (reactive) */ get totalSize() { return totalSize; }, /** Svelte action for the scrollable container element */ container, /** Svelte action for measuring individual item elements */ measureElement, /** Programmatic scroll method to scroll to a specific item */ scrollToIndex, }; } /** * Virtualizer instance returned by {@link createVirtualizer}. * * Provides reactive computed properties for visible items and total size, * along with action functions for DOM integration and element measurement. */ export type Virtualizer = ReturnType;