From 7e9675be80bd6870f5efb2dbb104d39d43f508b3 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 22 Jan 2026 15:39:29 +0300 Subject: [PATCH] feat(createVirtualizer): add isVisible and proximity properties to VirtualItem, add filckering prevention check --- .../createVirtualizer.svelte.ts | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index af9e7ba..7129e52 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -14,6 +14,10 @@ export interface VirtualItem { end: number; /** 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 */ + proximity: number; } /** @@ -136,6 +140,8 @@ export function createVirtualizer( let endIdx = startIdx; const viewportEnd = scrollOffset + containerHeight; + const viewportCenter = scrollOffset + (containerHeight / 2); + while (endIdx < count && offsets[endIdx] < viewportEnd) { endIdx++; } @@ -144,13 +150,31 @@ export function createVirtualizer( 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 isVisible = itemStart < viewportEnd && itemEnd > scrollOffset; + // Fully visible + const isVisible = 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: offsets[i], - size: measuredSizes[i] ?? options.estimateSize(i), - end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), + start: itemStart, + size: itemSize, + end: itemEnd, key: options.getItemKey?.(i) ?? i, + isVisible, + proximity, }); } @@ -207,21 +231,23 @@ export function createVirtualizer( const index = parseInt(node.dataset.index || '', 10); const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; - if (!isNaN(index) && measuredSizes[index] !== height) { - // 1. Stuff the measurement into a temporary buffer - measurementBuffer[index] = height; + if (!isNaN(index)) { + const oldHeight = measuredSizes[index]; + // Only update if the height difference is significant (> 0.5px) + // This prevents "jitter" from focus rings or sub-pixel border changes + if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) { + // Stuff the measurement into a temporary buffer + measurementBuffer[index] = height; - // 2. Schedule a single update for the next animation frame - if (frameId === null) { - frameId = requestAnimationFrame(() => { - // 3. Update the state once for all collected measurements - // We use spread to trigger a single fine-grained update - measuredSizes = { ...measuredSizes, ...measurementBuffer }; - - // 4. Reset the buffer - measurementBuffer = {}; - frameId = null; - }); + // Schedule a single update for the next animation frame + if (frameId === null) { + frameId = requestAnimationFrame(() => { + measuredSizes = { ...measuredSizes, ...measurementBuffer }; + // Reset the buffer + measurementBuffer = {}; + frameId = null; + }); + } } } });