2026-01-15 13:33:59 +03:00
|
|
|
import { untrack } from 'svelte';
|
|
|
|
|
|
|
|
|
|
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
|
|
|
|
// Reactive State
|
|
|
|
|
let scrollOffset = $state(0);
|
|
|
|
|
let containerHeight = $state(0);
|
|
|
|
|
let measuredSizes = $state<Record<number, number>>({});
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-01-06 21:38:18 +03:00
|
|
|
|
|
|
|
|
export interface VirtualItem {
|
2026-01-15 13:33:59 +03:00
|
|
|
/** Index of the item in the data array */
|
2026-01-06 21:38:18 +03:00
|
|
|
index: number;
|
2026-01-15 13:33:59 +03:00
|
|
|
/** Offset from the top of the list */
|
2026-01-06 21:38:18 +03:00
|
|
|
start: number;
|
2026-01-15 13:33:59 +03:00
|
|
|
/** Height of the item */
|
2026-01-06 21:38:18 +03:00
|
|
|
size: number;
|
2026-01-15 13:33:59 +03:00
|
|
|
/** End position (start + size) */
|
2026-01-06 21:38:18 +03:00
|
|
|
end: number;
|
2026-01-15 13:33:59 +03:00
|
|
|
/** Unique key for the item (for Svelte's {#each} keying) */
|
2026-01-06 21:38:18 +03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 14:27:25 +03:00
|
|
|
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|