From 4b79a5af0f89c792c79c379c681dc85414da7c76 Mon Sep 17 00:00:00 2001 From: nicwands Date: Fri, 19 Jun 2026 15:14:20 -0400 Subject: [PATCH] move to virtual scrolling --- src/App.vue | 2 +- src/components/InfiniteScrollContainer.vue | 32 +++--- src/components/Layout.vue | 4 +- src/components/MosaicScroll.vue | 19 +++- src/components/MosaicViewport.vue | 1 + src/components/site/Header.vue | 3 +- src/components/strapi/Content.vue | 5 + src/composables/useInfiniteScroll.js | 124 ++++++++++----------- src/plugins/gsap.js | 15 +++ 9 files changed, 120 insertions(+), 85 deletions(-) create mode 100644 src/plugins/gsap.js diff --git a/src/App.vue b/src/App.vue index 18ffa7c..9f9bf57 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ diff --git a/src/composables/useInfiniteScroll.js b/src/composables/useInfiniteScroll.js index 981502b..929d546 100644 --- a/src/composables/useInfiniteScroll.js +++ b/src/composables/useInfiniteScroll.js @@ -1,13 +1,16 @@ -import { ref, reactive, computed, nextTick } from 'vue' +import gsap from 'gsap' import { useWindowSize } from '@vueuse/core' import useLenis from '@/composables/useLenis' +import { ref, reactive, computed, nextTick, onBeforeUnmount, watch } from 'vue' // Window size for viewport calculations const { height: wHeight } = useWindowSize() /** - * Bidirectional infinite scroll composable - * Manages virtual viewports with on-demand content generation + * Bidirectional infinite virtual scroll composable + * Manages virtual viewports with on-demand content generation. + * The page stays at 100vh — scroll position is tracked via Lenis + * virtual-scroll events and viewports are positioned with CSS transforms. * * @param {Object} options Configuration options * @param {Function} options.generateContent Function to generate content for a viewport @@ -32,8 +35,10 @@ export const useInfiniteScroll = (options = {}) => { // Core state const state = reactive({ - // Current scroll position - scrollY: 0, + // Accumulated virtual scroll position (not the real DOM scroll) + scrollY: initialIndex * (viewportHeight || 0), + // Active state to stop/start scroll + active: true, // Index of the currently visible viewport currentViewportIndex: initialIndex, // Map of loaded viewports { index: { content, element, height, offsetY } } @@ -41,8 +46,6 @@ export const useInfiniteScroll = (options = {}) => { // Loading states isLoadingUp: false, isLoadingDown: false, - // Total virtual height (for scrollbar) - totalHeight: 0, // Error handling errors: new Map(), hasError: false, @@ -51,7 +54,9 @@ export const useInfiniteScroll = (options = {}) => { // Initialize with starting viewport const initializeViewports = async () => { - // Create initial viewport + // Seed scrollY once vpHeight is available + state.scrollY = initialIndex * vpHeight.value + const initialViewport = { index: initialIndex, content: await generateContent(initialIndex), @@ -61,15 +66,12 @@ export const useInfiniteScroll = (options = {}) => { state.viewports.set(initialIndex, initialViewport) - // Load buffer viewports above and below await loadViewportsInRange( initialIndex - bufferSize, initialIndex + bufferSize, ) - // Update positions and total height updateViewportPositions() - updateTotalHeight() } // Load viewports in a range @@ -92,7 +94,6 @@ export const useInfiniteScroll = (options = {}) => { const currentRetryCount = state.retryCount.get(index) || 0 try { - // Set loading state based on direction if (index < state.currentViewportIndex) { state.isLoadingUp = true } else if (index > state.currentViewportIndex) { @@ -109,7 +110,6 @@ export const useInfiniteScroll = (options = {}) => { state.viewports.set(index, viewport) - // Clear any previous errors for this viewport state.errors.delete(index) state.retryCount.delete(index) state.hasError = state.errors.size > 0 @@ -118,7 +118,6 @@ export const useInfiniteScroll = (options = {}) => { } catch (error) { console.error(`Failed to load viewport ${index}:`, error) - // Store error state.errors.set(index, { message: error.message || 'Failed to load content', timestamp: Date.now(), @@ -126,54 +125,26 @@ export const useInfiniteScroll = (options = {}) => { }) state.hasError = true - // Retry logic if (currentRetryCount < maxRetries && !isRetry) { state.retryCount.set(index, currentRetryCount + 1) - console.log( - `Retrying viewport ${index} (attempt ${currentRetryCount + 1}/${maxRetries})`, - ) - - // Exponential backoff const delay = Math.pow(2, currentRetryCount) * 1000 - setTimeout(() => { - loadViewport(index, true) - }, delay) + setTimeout(() => loadViewport(index, true), delay) } return null } finally { - // Clear loading states state.isLoadingUp = false state.isLoadingDown = false } } - // Update viewport positions based on their order + // Update viewport positions based on their index const updateViewportPositions = () => { - // Each viewport is positioned at index * viewport height for (const [index, viewport] of state.viewports) { viewport.offsetY = index * vpHeight.value } } - // Update total virtual height for scrollbar - const updateTotalHeight = () => { - if (state.viewports.size === 0) { - state.totalHeight = wHeight.value * bufferSize // Minimum height - return - } - - const indexes = Array.from(state.viewports.keys()) - // const minIndex = Math.min(...indexes) - const maxIndex = Math.max(...indexes) - - // Calculate total height needed to accommodate all viewports - // Add extra buffer above and below for infinite scroll - const bufferViewports = 10 - const totalRange = maxIndex + bufferViewports - state.totalHeight = totalRange * vpHeight.value - } - // Remove viewports outside buffer range const cleanupViewports = () => { const currentIndex = state.currentViewportIndex @@ -185,37 +156,56 @@ export const useInfiniteScroll = (options = {}) => { state.viewports.delete(index) } } - - updateTotalHeight() } - // Calculate which viewport should be visible based on scroll position + // Calculate which viewport should be visible based on virtual scroll position const calculateVisibleViewport = (scrollY) => { return Math.floor(scrollY / vpHeight.value) } - // Handle scroll events - const handleScroll = async (scrollData) => { - state.scrollY = scrollData.scroll + // Handle virtual-scroll delta events from Lenis + const onVirtualScroll = async ({ deltaY }) => { + if (!state.active) return + + deltaY *= 3 + + gsap.to(state, { + scrollY: state.scrollY + deltaY, + duration: 1, + ease: 'expo.out', + }) const newViewportIndex = calculateVisibleViewport(state.scrollY) if (newViewportIndex !== state.currentViewportIndex) { state.currentViewportIndex = newViewportIndex - // Load new viewports in range const rangeStart = newViewportIndex - bufferSize const rangeEnd = newViewportIndex + bufferSize await loadViewportsInRange(rangeStart, rangeEnd) - - // Cleanup old viewports cleanupViewports() } } - // Setup scroll listener with Lenis - const lenis = useLenis(handleScroll) + // Setup Lenis — we use virtual-scroll instead of scroll so the DOM + // height can stay at 100vh with overflow: hidden. + const lenis = useLenis() + + // Attach the virtual-scroll listener once Lenis is ready + watch( + lenis, + (instance) => { + if (instance) { + instance.on('virtual-scroll', onVirtualScroll) + } + }, + { immediate: true }, + ) + + onBeforeUnmount(() => { + lenis.value?.off('virtual-scroll', onVirtualScroll) + }) // Get viewports to render (current + buffer) const visibleViewports = computed(() => { @@ -234,14 +224,6 @@ export const useInfiniteScroll = (options = {}) => { return viewports.sort((a, b) => a.index - b.index) }) - // Scroll to a specific viewport - const scrollToViewport = (index) => { - if (lenis.value) { - const targetY = index * vpHeight.value - lenis.value.scrollTo(targetY, { immediate: false }) - } - } - // Retry failed viewport const retryViewport = async (index) => { state.errors.delete(index) @@ -257,11 +239,22 @@ export const useInfiniteScroll = (options = {}) => { state.retryCount.clear() state.hasError = false state.currentViewportIndex = initialIndex + state.scrollY = initialIndex * vpHeight.value state.isLoadingUp = false state.isLoadingDown = false await initializeViewports() } + // Start/stop scrolling + const stop = () => { + state.active = false + + gsap.killTweensOf(state) + } + const start = () => { + state.active = true + } + // Initialize on creation nextTick(() => { initializeViewports() @@ -274,13 +267,14 @@ export const useInfiniteScroll = (options = {}) => { visibleViewports, isLoadingUp: computed(() => state.isLoadingUp), isLoadingDown: computed(() => state.isLoadingDown), - totalHeight: computed(() => state.totalHeight), scrollY: computed(() => state.scrollY), + vpHeight, // Methods - scrollToViewport, reset, retryViewport, + stop, + start, // Error state hasError: computed(() => state.hasError), diff --git a/src/plugins/gsap.js b/src/plugins/gsap.js new file mode 100644 index 0000000..cb72813 --- /dev/null +++ b/src/plugins/gsap.js @@ -0,0 +1,15 @@ +import gsap from 'gsap' +import Tempus from 'tempus' + +export default { + install: () => { + gsap.defaults({ ease: 'none' }) + + // merge rafs + gsap.ticker.lagSmoothing(0) + gsap.ticker.remove(gsap.updateRoot) + Tempus?.add((time) => { + gsap.updateRoot(time / 1000) + }, 0) + }, +}