/** * useVirtualRows * * Custom virtualizer using prefix-sum offsets + binary search. * Inspired by pretext-table approach - lightweight, works perfectly * with pre-computed row heights from pretext. */ import { ref, computed, watch, type Ref } from "vue"; const DEFAULT_OVERSCAN = 5; export interface VirtualRow { index: number; offsetY: number; height: number; } export interface VirtualRange { startIndex: number; endIndex: number; offsetY: number; } export interface UseVirtualRowsOptions { overscan?: number; } /** * Build a prefix-sum array from row heights for O(1) offset lookups. */ export function buildOffsets(heights: number[]): number[] { const offsets = new Array(heights.length + 1); offsets[0] = 0; for (let i = 0; i < heights.length; i++) { offsets[i + 1] = offsets[i] + heights[i]; } return offsets; } /** * Binary search: find the first row index where bottom edge >= scrollTop. * Returns index in range [0, heights.length - 1]. */ function findStartIndex(offsets: number[], scrollTop: number): number { let lo = 0; let hi = offsets.length - 2; // last valid row index while (lo < hi) { const mid = (lo + hi) >>> 1; if (offsets[mid + 1] <= scrollTop) { lo = mid + 1; } else { hi = mid; } } return lo; } export function useVirtualRows( rowHeights: Ref, viewportHeight: Ref, options?: UseVirtualRowsOptions ) { const overscan = options?.overscan ?? DEFAULT_OVERSCAN; const scrollTop = ref(0); // Build prefix-sum offsets whenever rowHeights changes const offsets = computed(() => buildOffsets(rowHeights.value)); // Total scrollable height const totalHeight = computed(() => { const last = offsets.value[offsets.value.length - 1]; return last ?? 0; }); // Compute visible range const range = computed(() => { if (rowHeights.value.length === 0) { return { startIndex: 0, endIndex: 0, offsetY: 0 }; } const st = scrollTop.value; const vp = viewportHeight.value; const rawStart = findStartIndex(offsets.value, st); const startIndex = Math.max(0, rawStart - overscan); // Find end index: first row whose top is past scrollTop + viewportHeight let endIndex = rawStart; while ( endIndex < rowHeights.value.length && offsets.value[endIndex] < st + vp ) { endIndex++; } endIndex = Math.min(rowHeights.value.length, endIndex + overscan); return { startIndex, endIndex, offsetY: offsets.value[startIndex], }; }); // Get visible rows with their positions const visibleRows = computed(() => { const { startIndex, endIndex } = range.value; const rows: VirtualRow[] = []; // Safety check: ensure indices are valid if (startIndex < 0 || endIndex < startIndex) { return rows; } for (let i = startIndex; i < endIndex; i++) { const offsetY = offsets.value[i]; const height = rowHeights.value[i]; if (offsetY === undefined || height === undefined) { continue; // Skip invalid entries } rows.push({ index: i, offsetY, height, }); } return rows; }); // Scroll handler to be attached to the scroll container const onScroll = (newScrollTop: number) => { scrollTop.value = newScrollTop; }; // Scroll to a specific row index const scrollToIndex = (index: number) => { if (index < 0 || index >= offsets.value.length) return; scrollTop.value = offsets.value[index]; }; // Scroll to a specific position const scrollTo = (position: number) => { scrollTop.value = Math.max(0, position); }; return { scrollTop, totalHeight, range, visibleRows, onScroll, scrollToIndex, scrollTo, offsets, }; }