|
|
|
|
/**
|
|
|
|
|
* 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<number[]>,
|
|
|
|
|
viewportHeight: Ref<number>,
|
|
|
|
|
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<VirtualRange>(() => {
|
|
|
|
|
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<VirtualRow[]>(() => {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|