feature(VirtualList): remove tanstack virtual list solution, add self written one
This commit is contained in:
@@ -1,15 +1,159 @@
|
||||
import {
|
||||
createVirtualizer as coreCreateVirtualizer,
|
||||
observeElementRect,
|
||||
} from '@tanstack/svelte-virtual';
|
||||
import type { VirtualItem as CoreVirtualItem } from '@tanstack/virtual-core';
|
||||
import { get } from 'svelte/store';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
||||
// Reactive State
|
||||
let scrollOffset = $state(0);
|
||||
let containerHeight = $state(0);
|
||||
let measuredSizes = $state<Record<number, number>>({});
|
||||
|
||||
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
|
||||
let elementRef: HTMLElement | null = null;
|
||||
|
||||
// Reactive Options
|
||||
const options = $derived(optionsGetter());
|
||||
|
||||
// Optimized Memoization (The Cache Layer)
|
||||
// Only recalculates when item count or measured sizes change.
|
||||
const offsets = $derived.by(() => {
|
||||
const count = options.count;
|
||||
const result = new Array(count);
|
||||
let accumulated = 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
result[i] = accumulated;
|
||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const totalSize = $derived(
|
||||
options.count > 0
|
||||
? offsets[options.count - 1]
|
||||
+ (measuredSizes[options.count - 1] ?? options.estimateSize(options.count - 1))
|
||||
: 0,
|
||||
);
|
||||
|
||||
// Visible Range Calculation
|
||||
// Svelte tracks dependencies automatically here.
|
||||
const items = $derived.by((): VirtualItem[] => {
|
||||
const count = options.count;
|
||||
if (count === 0 || containerHeight === 0) return [];
|
||||
|
||||
const overscan = options.overscan ?? 5;
|
||||
const viewportStart = scrollOffset;
|
||||
const viewportEnd = scrollOffset + containerHeight;
|
||||
|
||||
// Find Start (Linear Scan)
|
||||
let startIdx = 0;
|
||||
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
|
||||
startIdx++;
|
||||
}
|
||||
|
||||
// Find End
|
||||
let endIdx = startIdx;
|
||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||
endIdx++;
|
||||
}
|
||||
|
||||
const start = Math.max(0, startIdx - overscan);
|
||||
const end = Math.min(count, endIdx + overscan);
|
||||
|
||||
const result: VirtualItem[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const size = measuredSizes[i] ?? options.estimateSize(i);
|
||||
result.push({
|
||||
index: i,
|
||||
start: offsets[i],
|
||||
size,
|
||||
end: offsets[i] + size,
|
||||
key: options.getItemKey?.(i) ?? i,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Svelte Actions (The DOM Interface)
|
||||
function container(node: HTMLElement) {
|
||||
elementRef = node;
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function measureElement(node: HTMLElement) {
|
||||
// Use a ResizeObserver on individual items for dynamic height support
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (entry) {
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
|
||||
// Only update if height actually changed to prevent loops
|
||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||
measuredSizes[index] = height;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
return {
|
||||
destroy: () => resizeObserver.disconnect(),
|
||||
};
|
||||
}
|
||||
|
||||
// Programmatic Scroll
|
||||
function scrollToIndex(index: number, align: 'start' | 'center' | 'end' | 'auto' = 'auto') {
|
||||
if (!elementRef || index < 0 || index >= options.count) return;
|
||||
|
||||
const itemStart = offsets[index];
|
||||
const itemSize = measuredSizes[index] ?? options.estimateSize(index);
|
||||
let target = itemStart;
|
||||
|
||||
if (align === 'center') target = itemStart - containerHeight / 2 + itemSize / 2;
|
||||
if (align === 'end') target = itemStart - containerHeight + itemSize;
|
||||
|
||||
elementRef.scrollTo({ top: target, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return items;
|
||||
},
|
||||
get totalSize() {
|
||||
return totalSize;
|
||||
},
|
||||
container,
|
||||
measureElement,
|
||||
scrollToIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VirtualItem {
|
||||
/** Index of the item in the data array */
|
||||
index: number;
|
||||
/** Offset from the top of the list */
|
||||
start: number;
|
||||
/** Height of the item */
|
||||
size: number;
|
||||
/** End position (start + size) */
|
||||
end: number;
|
||||
/** Unique key for the item (for Svelte's {#each} keying) */
|
||||
key: string | number;
|
||||
}
|
||||
|
||||
@@ -26,91 +170,4 @@ export interface VirtualizerOptions {
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a reactive virtualizer using Svelte 5 runes and TanStack's core library.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const virtualizer = createVirtualizer(() => ({
|
||||
* count: items.length,
|
||||
* estimateSize: () => 80,
|
||||
* overscan: 5,
|
||||
* }));
|
||||
*
|
||||
* // In template:
|
||||
* // <div bind:this={virtualizer.scrollElement}>
|
||||
* // {#each virtualizer.items as item}
|
||||
* // <div style="transform: translateY({item.start}px)">
|
||||
* // {items[item.index]}
|
||||
* // </div>
|
||||
* // {/each}
|
||||
* // </div>
|
||||
* ```
|
||||
*/
|
||||
export function createVirtualizer(
|
||||
optionsGetter: () => VirtualizerOptions,
|
||||
) {
|
||||
let element = $state<HTMLElement | null>(null);
|
||||
|
||||
const internalStore = coreCreateVirtualizer({
|
||||
get count() {
|
||||
return optionsGetter().count;
|
||||
},
|
||||
get estimateSize() {
|
||||
return optionsGetter().estimateSize;
|
||||
},
|
||||
get overscan() {
|
||||
return optionsGetter().overscan ?? 5;
|
||||
},
|
||||
get scrollMargin() {
|
||||
return optionsGetter().scrollMargin;
|
||||
},
|
||||
get getItemKey() {
|
||||
return optionsGetter().getItemKey ?? (i => i);
|
||||
},
|
||||
getScrollElement: () => element,
|
||||
observeElementRect: observeElementRect,
|
||||
});
|
||||
|
||||
const state = $derived(get(internalStore));
|
||||
|
||||
const virtualItems = $derived(
|
||||
state.getVirtualItems().map((item: CoreVirtualItem): VirtualItem => ({
|
||||
index: item.index,
|
||||
start: item.start,
|
||||
size: item.size,
|
||||
end: item.end,
|
||||
key: typeof item.key === 'bigint' ? Number(item.key) : item.key,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
get items() {
|
||||
return virtualItems;
|
||||
},
|
||||
|
||||
get totalSize() {
|
||||
return state.getTotalSize();
|
||||
},
|
||||
|
||||
get scrollOffset() {
|
||||
return state.scrollOffset ?? 0;
|
||||
},
|
||||
|
||||
get scrollElement() {
|
||||
return element;
|
||||
},
|
||||
set scrollElement(el) {
|
||||
element = el;
|
||||
},
|
||||
|
||||
scrollToIndex: (idx: number, opt?: { align?: 'start' | 'center' | 'end' | 'auto' }) =>
|
||||
state.scrollToIndex(idx, opt),
|
||||
|
||||
scrollToOffset: (off: number) => state.scrollToOffset(off),
|
||||
|
||||
measureElement: (el: HTMLElement) => state.measureElement(el),
|
||||
};
|
||||
}
|
||||
|
||||
export type Virtualizer = ReturnType<typeof createVirtualizer>;
|
||||
|
||||
Reference in New Issue
Block a user