// Composables import { useDisplay } from "./display.mjs"; import { useResizeObserver } from "./resizeObserver.mjs"; // Utilities import { computed, nextTick, onScopeDispose, ref, shallowRef, watch, watchEffect } from 'vue'; import { clamp, debounce, IN_BROWSER, propsFactory } from "../util/index.mjs"; // Types const UP = -1; const DOWN = 1; /** Determines how large each batch of items should be */ const BUFFER_PX = 100; export const makeVirtualProps = propsFactory({ itemHeight: { type: [Number, String], default: null }, height: [Number, String] }, 'virtual'); export function useVirtual(props, items) { const display = useDisplay(); const itemHeight = shallowRef(0); watchEffect(() => { itemHeight.value = parseFloat(props.itemHeight || 0); }); const first = shallowRef(0); const last = shallowRef(Math.ceil( // Assume 16px items filling the entire screen height if // not provided. This is probably incorrect but it minimises // the chance of ending up with empty space at the bottom. // The default value is set here to avoid poisoning getSize() (parseInt(props.height) || display.height.value) / (itemHeight.value || 16)) || 1); const paddingTop = shallowRef(0); const paddingBottom = shallowRef(0); /** The scrollable element */ const containerRef = ref(); /** An element marking the top of the scrollable area, * used to add an offset if there's padding or other elements above the virtual list */ const markerRef = ref(); /** markerRef's offsetTop, lazily evaluated */ let markerOffset = 0; const { resizeRef, contentRect } = useResizeObserver(); watchEffect(() => { resizeRef.value = containerRef.value; }); const viewportHeight = computed(() => { return containerRef.value === document.documentElement ? display.height.value : contentRect.value?.height || parseInt(props.height) || 0; }); /** All static elements have been rendered and we have an assumed item height */ const hasInitialRender = computed(() => { return !!(containerRef.value && markerRef.value && viewportHeight.value && itemHeight.value); }); let sizes = Array.from({ length: items.value.length }); let offsets = Array.from({ length: items.value.length }); const updateTime = shallowRef(0); let targetScrollIndex = -1; function getSize(index) { return sizes[index] || itemHeight.value; } const updateOffsets = debounce(() => { const start = performance.now(); offsets[0] = 0; const length = items.value.length; for (let i = 1; i <= length - 1; i++) { offsets[i] = (offsets[i - 1] || 0) + getSize(i - 1); } updateTime.value = Math.max(updateTime.value, performance.now() - start); }, updateTime); const unwatch = watch(hasInitialRender, v => { if (!v) return; // First render is complete, update offsets and visible // items in case our assumed item height was incorrect unwatch(); markerOffset = markerRef.value.offsetTop; updateOffsets.immediate(); calculateVisibleItems(); if (!~targetScrollIndex) return; nextTick(() => { IN_BROWSER && window.requestAnimationFrame(() => { scrollToIndex(targetScrollIndex); targetScrollIndex = -1; }); }); }); watch(viewportHeight, (val, oldVal) => { oldVal && calculateVisibleItems(); }); onScopeDispose(() => { updateOffsets.clear(); }); function handleItemResize(index, height) { const prevHeight = sizes[index]; const prevMinHeight = itemHeight.value; itemHeight.value = prevMinHeight ? Math.min(itemHeight.value, height) : height; if (prevHeight !== height || prevMinHeight !== itemHeight.value) { sizes[index] = height; updateOffsets(); } } function calculateOffset(index) { index = clamp(index, 0, items.value.length - 1); return offsets[index] || 0; } function calculateIndex(scrollTop) { return binaryClosest(offsets, scrollTop); } let lastScrollTop = 0; let scrollVelocity = 0; let lastScrollTime = 0; function handleScroll() { if (!containerRef.value || !markerRef.value) return; const scrollTop = containerRef.value.scrollTop; const scrollTime = performance.now(); const scrollDeltaT = scrollTime - lastScrollTime; if (scrollDeltaT > 500) { scrollVelocity = Math.sign(scrollTop - lastScrollTop); // Not super important, only update at the // start of a scroll sequence to avoid reflows markerOffset = markerRef.value.offsetTop; } else { scrollVelocity = scrollTop - lastScrollTop; } lastScrollTop = scrollTop; lastScrollTime = scrollTime; calculateVisibleItems(); } function handleScrollend() { if (!containerRef.value || !markerRef.value) return; scrollVelocity = 0; lastScrollTime = 0; calculateVisibleItems(); } let raf = -1; function calculateVisibleItems() { cancelAnimationFrame(raf); raf = requestAnimationFrame(_calculateVisibleItems); } function _calculateVisibleItems() { if (!containerRef.value || !viewportHeight.value) return; const scrollTop = lastScrollTop - markerOffset; const direction = Math.sign(scrollVelocity); const startPx = Math.max(0, scrollTop - BUFFER_PX); const start = clamp(calculateIndex(startPx), 0, items.value.length); const endPx = scrollTop + viewportHeight.value + BUFFER_PX; const end = clamp(calculateIndex(endPx) + 1, start + 1, items.value.length); if ( // Only update the side we're scrolling towards, // the other side will be updated incidentally (direction !== UP || start < first.value) && (direction !== DOWN || end > last.value)) { const topOverflow = calculateOffset(first.value) - calculateOffset(start); const bottomOverflow = calculateOffset(end) - calculateOffset(last.value); const bufferOverflow = Math.max(topOverflow, bottomOverflow); if (bufferOverflow > BUFFER_PX) { first.value = start; last.value = end; } else { // Only update the side that's reached its limit if there's still buffer left if (start <= 0) first.value = start; if (end >= items.value.length) last.value = end; } } paddingTop.value = calculateOffset(first.value); paddingBottom.value = calculateOffset(items.value.length) - calculateOffset(last.value); } function scrollToIndex(index) { const offset = calculateOffset(index); if (!containerRef.value || index && !offset) { targetScrollIndex = index; } else { containerRef.value.scrollTop = offset; } } const computedItems = computed(() => { return items.value.slice(first.value, last.value).map((item, index) => ({ raw: item, index: index + first.value })); }); watch(items, () => { sizes = Array.from({ length: items.value.length }); offsets = Array.from({ length: items.value.length }); updateOffsets.immediate(); calculateVisibleItems(); }, { deep: true }); return { containerRef, markerRef, computedItems, paddingTop, paddingBottom, scrollToIndex, handleScroll, handleScrollend, handleItemResize }; } // https://gist.github.com/robertleeplummerjr/1cc657191d34ecd0a324 function binaryClosest(arr, val) { let high = arr.length - 1; let low = 0; let mid = 0; let item = null; let target = -1; if (arr[high] < val) { return high; } while (low <= high) { mid = low + high >> 1; item = arr[mid]; if (item > val) { high = mid - 1; } else if (item < val) { target = mid; low = mid + 1; } else if (item === val) { return mid; } else { return low; } } return target; } //# sourceMappingURL=virtual.mjs.map