feat(createVirtualizer): refine virtualizer logic, add useWindowScroll flag to use window scroll

This commit is contained in:
Ilia Mashkov
2026-02-02 12:04:19 +03:00
parent ca161dfbd4
commit f90f1e39e0

View File

@@ -4,19 +4,37 @@
* Used to render visible items with absolute positioning based on computed offsets. * Used to render visible items with absolute positioning based on computed offsets.
*/ */
export interface VirtualItem { export interface VirtualItem {
/** Index of the item in the data array */ /**
* Index of the item in the data array
*/
index: number; index: number;
/** Offset from the top of the list in pixels */ /**
* Offset from the top of the list in pixels
*/
start: number; start: number;
/** Height/size of the item in pixels */ /**
* Height/size of the item in pixels
*/
size: number; size: number;
/** End position in pixels (start + size) */ /**
* End position in pixels (start + size)
*/
end: number; 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; key: string | number;
/** Whether the item is currently visible in the viewport */ /**
isVisible: boolean; * Whether the item is currently fully visible in the viewport
/** Proximity of the item to the center of 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; proximity: number;
} }
@@ -45,6 +63,11 @@ export interface VirtualizerOptions {
* Can be useful for handling sticky headers or other UI elements. * Can be useful for handling sticky headers or other UI elements.
*/ */
scrollMargin?: number; scrollMargin?: number;
/**
* Whether to use the window as the scroll container.
* @default false
*/
useWindowScroll?: boolean;
} }
/** /**
@@ -92,6 +115,7 @@ export function createVirtualizer<T>(
let containerHeight = $state(0); let containerHeight = $state(0);
let measuredSizes = $state<Record<number, number>>({}); let measuredSizes = $state<Record<number, number>>({});
let elementRef: HTMLElement | null = null; let elementRef: HTMLElement | null = null;
let elementOffsetTop = 0;
// By wrapping the getter in $derived, we track everything inside it // By wrapping the getter in $derived, we track everything inside it
const options = $derived(optionsGetter()); const options = $derived(optionsGetter());
@@ -157,9 +181,8 @@ export function createVirtualizer<T>(
const itemEnd = itemStart + itemSize; const itemEnd = itemStart + itemSize;
// Visibility check: Does the item overlap the viewport? // Visibility check: Does the item overlap the viewport?
// const isVisible = itemStart < viewportEnd && itemEnd > scrollOffset; const isPartiallyVisible = itemStart < viewportEnd && itemEnd > scrollOffset;
// Fully visible const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
const isVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
// Proximity calculation: 1.0 at center, 0.0 at edges // Proximity calculation: 1.0 at center, 0.0 at edges
const itemCenter = itemStart + (itemSize / 2); const itemCenter = itemStart + (itemSize / 2);
@@ -173,7 +196,8 @@ export function createVirtualizer<T>(
size: itemSize, size: itemSize,
end: itemEnd, end: itemEnd,
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
isVisible, isPartiallyVisible,
isFullyVisible,
proximity, proximity,
}); });
} }
@@ -192,26 +216,74 @@ export function createVirtualizer<T>(
*/ */
function container(node: HTMLElement) { function container(node: HTMLElement) {
elementRef = node; elementRef = node;
containerHeight = node.offsetHeight; const { useWindowScroll } = optionsGetter();
const handleScroll = () => { if (useWindowScroll) {
scrollOffset = node.scrollTop; // Calculate initial offset ONCE
}; const getElementOffset = () => {
const rect = node.getBoundingClientRect();
return rect.top + window.scrollY;
};
const resizeObserver = new ResizeObserver(([entry]) => { let cachedOffsetTop = getElementOffset();
if (entry) containerHeight = entry.contentRect.height; containerHeight = window.innerHeight;
});
node.addEventListener('scroll', handleScroll, { passive: true }); const handleScroll = () => {
resizeObserver.observe(node); // Use cached offset for scroll calculations
scrollOffset = Math.max(0, window.scrollY - cachedOffsetTop);
};
return { const handleResize = () => {
destroy() { const oldHeight = containerHeight;
node.removeEventListener('scroll', handleScroll); containerHeight = window.innerHeight;
resizeObserver.disconnect();
elementRef = null; // 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<number, number> = {}; let measurementBuffer: Record<number, number> = {};
@@ -275,11 +347,22 @@ export function createVirtualizer<T>(
const itemStart = offsets[index]; const itemStart = offsets[index];
const itemSize = measuredSizes[index] ?? options.estimateSize(index); const itemSize = measuredSizes[index] ?? options.estimateSize(index);
let target = itemStart; let target = itemStart;
const { useWindowScroll } = optionsGetter();
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2; if (useWindowScroll) {
if (align === 'end') target = itemStart - containerHeight + itemSize; 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 { return {