From f90f1e39e0b62132f59d785af3dd367e977a1d76 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Mon, 2 Feb 2026 12:04:19 +0300 Subject: [PATCH] feat(createVirtualizer): refine virtualizer logic, add useWindowScroll flag to use window scroll --- .../createVirtualizer.svelte.ts | 145 ++++++++++++++---- 1 file changed, 114 insertions(+), 31 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 7129e52..aeb67a7 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -4,19 +4,37 @@ * Used to render visible items with absolute positioning based on computed offsets. */ export interface VirtualItem { - /** Index of the item in the data array */ + /** + * Index of the item in the data array + */ index: number; - /** Offset from the top of the list in pixels */ + /** + * Offset from the top of the list in pixels + */ start: number; - /** Height/size of the item in pixels */ + /** + * Height/size of the item in pixels + */ size: number; - /** End position in pixels (start + size) */ + /** + * End position in pixels (start + size) + */ end: number; - /** Unique key for the item (for Svelte's {#each} keying) */ + /** + * Unique key for the item (for Svelte's {#each} keying) + */ key: string | number; - /** Whether the item is currently visible in the viewport */ - isVisible: boolean; - /** Proximity of the item to the center of the viewport */ + /** + * 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; } @@ -45,6 +63,11 @@ export interface VirtualizerOptions { * 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; } /** @@ -92,6 +115,7 @@ export function createVirtualizer( 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()); @@ -157,9 +181,8 @@ export function createVirtualizer( const itemEnd = itemStart + itemSize; // Visibility check: Does the item overlap the viewport? - // const isVisible = itemStart < viewportEnd && itemEnd > scrollOffset; - // Fully visible - const isVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd; + 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); @@ -173,7 +196,8 @@ export function createVirtualizer( size: itemSize, end: itemEnd, key: options.getItemKey?.(i) ?? i, - isVisible, + isPartiallyVisible, + isFullyVisible, proximity, }); } @@ -192,26 +216,74 @@ export function createVirtualizer( */ function container(node: HTMLElement) { elementRef = node; - containerHeight = node.offsetHeight; + const { useWindowScroll } = optionsGetter(); - const handleScroll = () => { - scrollOffset = node.scrollTop; - }; + if (useWindowScroll) { + // Calculate initial offset ONCE + const getElementOffset = () => { + const rect = node.getBoundingClientRect(); + return rect.top + window.scrollY; + }; - const resizeObserver = new ResizeObserver(([entry]) => { - if (entry) containerHeight = entry.contentRect.height; - }); + let cachedOffsetTop = getElementOffset(); + containerHeight = window.innerHeight; - node.addEventListener('scroll', handleScroll, { passive: true }); - resizeObserver.observe(node); + const handleScroll = () => { + // Use cached offset for scroll calculations + scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop); + }; - return { - destroy() { - node.removeEventListener('scroll', handleScroll); - resizeObserver.disconnect(); - elementRef = null; - }, - }; + const handleResize = () => { + const oldHeight = containerHeight; + containerHeight = window.innerHeight; + + // Recalculate offset on resize (layout may have shifted) + const newOffsetTop = getElementOffset(); + if (Math.abs(newOffsetTop - cachedOffsetTop) > 0.5) { + cachedOffsetTop = newOffsetTop; + handleScroll(); // Recalculate scroll position + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + window.addEventListener('resize', handleResize); + + // Initial calculation + handleScroll(); + + return { + destroy() { + window.removeEventListener('scroll', handleScroll); + window.removeEventListener('resize', handleResize); + if (frameId !== null) { + cancelAnimationFrame(frameId); + frameId = 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 = {}; @@ -275,11 +347,22 @@ export function createVirtualizer( const itemStart = offsets[index]; const itemSize = measuredSizes[index] ?? options.estimateSize(index); let target = itemStart; + const { useWindowScroll } = optionsGetter(); - if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; - if (align === 'end') target = itemStart - containerHeight + itemSize; + if (useWindowScroll) { + if (align === 'center') target = itemStart - window.innerHeight / 2 + itemSize / 2; + if (align === 'end') target = itemStart - window.innerHeight + itemSize; - elementRef.scrollTo({ top: target, behavior: 'smooth' }); + // 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 {