443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
/**
|
|
* 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
|
|
* <script lang="ts">
|
|
* const virtualizer = createVirtualizer(() => ({
|
|
* count: 1000,
|
|
* estimateSize: (i) => i % 3 === 0 ? 100 : 50,
|
|
* overscan: 5,
|
|
* getItemKey: (i) => `item-${i}`
|
|
* }));
|
|
* </script>
|
|
*
|
|
* <div use:virtualizer.container style="height: 500px; overflow: auto;">
|
|
* <div style="height: {virtualizer.totalSize}px;">
|
|
* {#each virtualizer.items as item (item.key)}
|
|
* <div
|
|
* use:virtualizer.measureElement
|
|
* data-index={item.index}
|
|
* style="position: absolute; top: {item.start}px; height: {item.size}px;"
|
|
* >
|
|
* Item {item.index}
|
|
* </div>
|
|
* {/each}
|
|
* </div>
|
|
* </div>
|
|
* ```
|
|
*/
|
|
export function createVirtualizer<T>(
|
|
optionsGetter: () => VirtualizerOptions & {
|
|
data: T[];
|
|
},
|
|
) {
|
|
let scrollOffset = $state(0);
|
|
let containerHeight = $state(0);
|
|
let measuredSizes = $state<Record<number, number>>({});
|
|
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<number, number> = {};
|
|
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<typeof createVirtualizer>;
|