diff --git a/src/composables/useVirtualScrolling.js b/src/composables/useVirtualScrolling.js index fe4b27633..e08d70304 100644 --- a/src/composables/useVirtualScrolling.js +++ b/src/composables/useVirtualScrolling.js @@ -3,13 +3,25 @@ import { computed, ref, toValue, watch } from 'vue' import { useWindowSize } from 'src/composables/useWindowSize.js' export function useVirtualScrolling({ + // List of items list, + // Container of items, used for measuring scroll position body, + // useScrollPosition composable, used to prevent dupicating instances scrollPositionInstance, - scrollCompensation, + // ID of anchor element, used for scroll compensation. + // Omitting it makes last element the anchor anchorId, + // whether to use scroll compensation when elements above anchor change + scrollCompensation, + // Placeholder height specification. Must be a function. + // function will be called either: + // - without arguments (for generic placeholder, i.e. buffer zone size) + // - with id (for specific item placeholders) + // function must return ref pointing to height getPlaceholderHeight, }) { + // # Suspension const unsuspendibleIds = ref(new Set()) const changeSuspendState = ({ id, suspend }) => { if (!suspend) { @@ -19,10 +31,7 @@ export function useVirtualScrolling({ } } - // Add buffer zone to boundary, equal to approx 3 items heights - const buffer = computed(() => getPlaceholderHeight().value * 3) - - // Heights map. + // # Heights mapping. const heights = ref(new Map()) const heightChart = computed(() => { // Map every height and suspendable state @@ -48,8 +57,16 @@ export function useVirtualScrolling({ return chart }) + const updateVirtualHeight = ({ id, height }) => { + heights.value.set(id, height) + } - // Scroll compensation + // ## Scroll compensation + const { + y: scrollY, + inProgress: scrollInProgress, + scrollBy, + } = scrollPositionInstance watch(heightChart, async (newVal, oldVal) => { if (!toValue(scrollCompensation)) return if (scrollInProgress.value) return @@ -69,27 +86,18 @@ export function useVirtualScrolling({ const diff = newOffset - oldOffset // Positive = down, Negative = up if (diff !== 0) { + // Scroll by amount offset changed to keep it in view topScrollBoundary.value += diff bottomScrollBoundary.value += diff scrollBy(0, diff) } - updateBoundaries() resumeWatchers() }) - const updateVirtualHeight = ({ id, height }) => { - heights.value.set(id, height) - } - // Scrolling - const { - y: scrollY, - inProgress: scrollInProgress, - scrollBy, - } = scrollPositionInstance const { height: windowHeight } = useWindowSize() - // Real scroll boundary relative to body's bounds + // Real scroll boundary, relative to body's bounds const topScrollBoundary = ref(0) const bottomScrollBoundary = ref(0) @@ -101,6 +109,11 @@ export function useVirtualScrolling({ const distanceItemTopToWindowTop = 0 - top const distanceItemTopToWindowBottom = windowHeight.value - top + // Technically, bottom scroll boundary should be distance + // from element's top border to window's bottom border, + // but it just so happens that it is equal to this. + // You can verify it by drawing the boxes and measuring + // distances yourself, I know I did. Geometry, man... topScrollBoundary.value = distanceItemTopToWindowTop bottomScrollBoundary.value = distanceItemTopToWindowBottom } @@ -116,19 +129,26 @@ export function useVirtualScrolling({ heightWatcher.pause() bodyWatcher.pause() } - const resumeWatchers = () => { + const resumeWatchers = (skipUpdate = false) => { windowWatcher.resume() scrollWatcher.resume() heightWatcher.resume() bodyWatcher.resume() + if (skipUpdate) return + updateBoundaries() } + // # Visiblity + // Add buffer zone to boundary, equal to approx 3 items heights + const buffer = computed(() => getPlaceholderHeight().value * 3) + const heightChartGrouped = computed(() => { // Determine visibility state const chart = heightChart.value.map((heightChartItem) => { - const itemBottomBoundary = heightChartItem.top + heightChartItem.height const itemTopBoundary = heightChartItem.top + const itemBottomBoundary = heightChartItem.top + heightChartItem.height + // Include buffer zone const finalTopScrollBoundary = topScrollBoundary.value - buffer.value const finalBottomScrollBoundary = bottomScrollBoundary.value + buffer.value @@ -149,29 +169,35 @@ export function useVirtualScrolling({ return chart.reduce((acc, heightChartItem) => { const { suspendable, visible, height, top, bottom, id, item } = heightChartItem + // Bottom value isn't really used otherwise for debugging const present = visible || !suspendable if (present) { return [...acc, { type: 'item', height, top, bottom, id, item }] } else { + // Reusing previous item if possible const previousItem = acc[acc.length - 1] - const spacer = - previousItem?.type === 'spacer' - ? previousItem - : { - type: 'spacer', - top: Number.POSITIVE_INFINITY, - bottom: Number.POSITIVE_INFINITY, - height: 0, - ids: new Set(), - } + const usingPreviousItem = previousItem?.type === 'spacer' + // We only really care for height and id of spacer, everything else + // is just for debugging + const spacer = usingPreviousItem + ? previousItem + : { + type: 'spacer', + top: Number.POSITIVE_INFINITY, + bottom: Number.POSITIVE_INFINITY, + height: 0, + ids: new Set(), + } spacer.ids.add(id) - spacer.id = [...spacer.ids].join() + spacer.id = [...spacer.ids].join() // used for v-for key attribute spacer.height += height + if (top < spacer.top) spacer.top = top if (bottom < spacer.bottom) spacer.bottom = bottom - if (previousItem?.type === 'spacer') { + // If we used previous item there is no need to push it to array + if (usingPreviousItem) { return acc } else { return [...acc, spacer]