feat(createVirtualizer): add isVisible and proximity properties to VirtualItem, add filckering prevention check

This commit is contained in:
Ilia Mashkov
2026-01-22 15:39:29 +03:00
parent ac979c816c
commit 7e9675be80

View File

@@ -14,6 +14,10 @@ export interface VirtualItem {
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;
/** Proximity of the item to the center of the viewport */
proximity: number;
} }
/** /**
@@ -136,6 +140,8 @@ export function createVirtualizer<T>(
let endIdx = startIdx; let endIdx = startIdx;
const viewportEnd = scrollOffset + containerHeight; const viewportEnd = scrollOffset + containerHeight;
const viewportCenter = scrollOffset + (containerHeight / 2);
while (endIdx < count && offsets[endIdx] < viewportEnd) { while (endIdx < count && offsets[endIdx] < viewportEnd) {
endIdx++; endIdx++;
} }
@@ -144,13 +150,31 @@ export function createVirtualizer<T>(
const end = Math.min(count, endIdx + overscan); const end = Math.min(count, endIdx + overscan);
const result: VirtualItem[] = []; const result: VirtualItem[] = [];
for (let i = start; i < end; i++) { 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({ result.push({
index: i, index: i,
start: offsets[i], start: itemStart,
size: measuredSizes[i] ?? options.estimateSize(i), size: itemSize,
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)), end: itemEnd,
key: options.getItemKey?.(i) ?? i, key: options.getItemKey?.(i) ?? i,
isVisible,
proximity,
}); });
} }
@@ -207,23 +231,25 @@ export function createVirtualizer<T>(
const index = parseInt(node.dataset.index || '', 10); const index = parseInt(node.dataset.index || '', 10);
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight; const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
if (!isNaN(index) && measuredSizes[index] !== height) { if (!isNaN(index)) {
// 1. Stuff the measurement into a temporary buffer 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; measurementBuffer[index] = height;
// 2. Schedule a single update for the next animation frame // Schedule a single update for the next animation frame
if (frameId === null) { if (frameId === null) {
frameId = requestAnimationFrame(() => { frameId = requestAnimationFrame(() => {
// 3. Update the state once for all collected measurements
// We use spread to trigger a single fine-grained update
measuredSizes = { ...measuredSizes, ...measurementBuffer }; measuredSizes = { ...measuredSizes, ...measurementBuffer };
// Reset the buffer
// 4. Reset the buffer
measurementBuffer = {}; measurementBuffer = {};
frameId = null; frameId = null;
}); });
} }
} }
}
}); });
resizeObserver.observe(node); resizeObserver.observe(node);