fix(createVirtualizer): fix scroll issues that make scroll position jump when new page of fonts loads. Add some optimizations e.g. common ResizeObserver
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 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
|
||||||
@@ -132,6 +133,7 @@ export function createVirtualizer<T>(
|
|||||||
// Accessing measuredSizes here creates the subscription
|
// Accessing measuredSizes here creates the subscription
|
||||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,10 +191,13 @@ export function createVirtualizer<T>(
|
|||||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||||
|
|
||||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
// 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 itemCenter = itemStart + (itemSize / 2);
|
||||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||||
const maxDistance = containerHeight / 2;
|
const maxDistance = containerHeight / 2;
|
||||||
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
|
const proximity = maxDistance > 0
|
||||||
|
? Math.max(0, 1 - (distanceToCenter / maxDistance))
|
||||||
|
: 0;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -206,16 +211,6 @@ export function createVirtualizer<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('🎯 Virtual Items Calculation:', {
|
|
||||||
// scrollOffset,
|
|
||||||
// containerHeight,
|
|
||||||
// viewportEnd,
|
|
||||||
// startIdx,
|
|
||||||
// endIdx,
|
|
||||||
// withOverscan: { start, end },
|
|
||||||
// itemCount: end - start,
|
|
||||||
// });
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
// Svelte Actions (The DOM Interface)
|
// Svelte Actions (The DOM Interface)
|
||||||
@@ -256,25 +251,19 @@ export function createVirtualizer<T>(
|
|||||||
scrollOffset = scrolledPastTop;
|
scrollOffset = scrolledPastTop;
|
||||||
rafId = null;
|
rafId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 DIAGNOSTIC
|
|
||||||
// console.log('📜 Scroll Event:', {
|
|
||||||
// windowScrollY: window.scrollY,
|
|
||||||
// elementRectTop: rect.top,
|
|
||||||
// scrolledPastTop,
|
|
||||||
// containerHeight
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = window.innerHeight;
|
||||||
cachedOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
handleScroll();
|
handleScroll();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial setup
|
// Initial setup
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
cachedOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
handleScroll();
|
handleScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,6 +282,11 @@ export function createVirtualizer<T>(
|
|||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
rafId = null;
|
rafId = null;
|
||||||
}
|
}
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -314,6 +308,11 @@ export function createVirtualizer<T>(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('scroll', handleScroll);
|
node.removeEventListener('scroll', handleScroll);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -325,51 +324,64 @@ export function createVirtualizer<T>(
|
|||||||
// Signal to trigger updates when mutating measuredSizes in place
|
// Signal to trigger updates when mutating measuredSizes in place
|
||||||
let _version = $state(0);
|
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.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
* Attaches a ResizeObserver to track actual element height and updates
|
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
* Requires `data-index` attribute on the element.
|
||||||
*
|
*
|
||||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||||
* @returns Object with destroy method for cleanup
|
* @returns Object with destroy method for cleanup
|
||||||
*/
|
*/
|
||||||
function measureElement(node: HTMLElement) {
|
function measureElement(node: HTMLElement) {
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
// Initialize shared observer on first use
|
||||||
if (!entry) return;
|
if (!sharedResizeObserver) {
|
||||||
const index = parseInt(node.dataset.index || '', 10);
|
sharedResizeObserver = new ResizeObserver(entries => {
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
// 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)) {
|
if (!isNaN(index)) {
|
||||||
// Accessing the version ensures we have the latest state if needed,
|
const oldHeight = measuredSizes[index];
|
||||||
// though here we just read the raw object.
|
|
||||||
const oldHeight = measuredSizes[index];
|
|
||||||
|
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
// Stuff the measurement into a temporary buffer to batch updates
|
measurementBuffer[index] = height;
|
||||||
measurementBuffer[index] = height;
|
}
|
||||||
|
|
||||||
// Schedule a single update for the next animation frame
|
|
||||||
if (frameId === null) {
|
|
||||||
frameId = requestAnimationFrame(() => {
|
|
||||||
// Mutation in place for performance
|
|
||||||
Object.assign(measuredSizes, measurementBuffer);
|
|
||||||
|
|
||||||
// Trigger reactivity
|
|
||||||
_version += 1;
|
|
||||||
|
|
||||||
// Reset buffer
|
|
||||||
measurementBuffer = {};
|
|
||||||
frameId = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
// Schedule a single update for the next animation frame
|
||||||
return { destroy: () => resizeObserver.disconnect() };
|
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
|
// Programmatic Scroll
|
||||||
@@ -409,6 +421,28 @@ export function createVirtualizer<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
return {
|
||||||
get scrollOffset() {
|
get scrollOffset() {
|
||||||
return scrollOffset;
|
return scrollOffset;
|
||||||
@@ -430,6 +464,8 @@ export function createVirtualizer<T>(
|
|||||||
measureElement,
|
measureElement,
|
||||||
/** Programmatic scroll method to scroll to a specific item */
|
/** Programmatic scroll method to scroll to a specific item */
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
|
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||||
|
scrollToOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user