Files
frontend-svelte/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts

479 lines
17 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
// Guard against division by zero (containerHeight can be 0 on initial render)
const itemCenter = itemStart + (itemSize / 2);
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
const maxDistance = containerHeight / 2;
const proximity = maxDistance > 0
? Math.max(0, 1 - (distanceToCenter / maxDistance))
: 0;
result.push({
index: i,
start: itemStart,
size: itemSize,
end: itemEnd,
key: options.getItemKey?.(i) ?? i,
isPartiallyVisible,
isFullyVisible,
proximity,
});
}
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;
});
};
const handleResize = () => {
containerHeight = window.innerHeight;
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
handleScroll();
};
// Initial setup
requestAnimationFrame(() => {
elementOffsetTop = getElementOffset();
cachedOffsetTop = elementOffsetTop;
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;
}
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = 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();
// Disconnect shared ResizeObserver
if (sharedResizeObserver) {
sharedResizeObserver.disconnect();
sharedResizeObserver = null;
}
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);
// Single shared ResizeObserver for all items (performance optimization)
let sharedResizeObserver: ResizeObserver | null = null;
/**
* Svelte action to measure individual item elements for dynamic height support.
*
* Uses a single shared ResizeObserver for all items to track actual element heights.
* 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) {
// Initialize shared observer on first use
if (!sharedResizeObserver) {
sharedResizeObserver = new ResizeObserver(entries => {
// Process all entries in a single batch
for (const entry of entries) {
const target = entry.target as HTMLElement;
const index = parseInt(target.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight;
if (!isNaN(index)) {
const oldHeight = measuredSizes[index];
// Only update if the height difference is significant (> 0.5px)
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
measurementBuffer[index] = height;
}
}
}
// Schedule a single update for the next animation frame
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
frameId = requestAnimationFrame(() => {
// Mutation in place for performance
Object.assign(measuredSizes, measurementBuffer);
// Trigger reactivity
_version += 1;
// Reset buffer
measurementBuffer = {};
frameId = null;
});
}
});
}
// Observe this element with the shared observer
sharedResizeObserver.observe(node);
// Return cleanup that only unobserves this specific element
return {
destroy: () => {
sharedResizeObserver?.unobserve(node);
},
};
}
// 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' });
}
}
/**
* Scrolls the container to a specific pixel offset.
* Used for preserving scroll position during data updates.
*
* @param offset - The scroll offset in pixels
* @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated
*
* @example
* ```ts
* virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px
* ```
*/
function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
const { useWindowScroll } = optionsGetter();
if (useWindowScroll) {
window.scrollTo({ top: offset + elementOffsetTop, behavior });
} else if (elementRef) {
elementRef.scrollTo({ top: offset, behavior });
}
}
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,
/** Programmatic scroll method to scroll to a specific pixel offset */
scrollToOffset,
};
}
/**
* 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>;