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 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 * @param {Number} options.viewportHeight Height of each viewport (defaults to window height) * @param {Number} options.bufferSize Number of viewports to keep in memory outside visible area * @param {Number} options.initialIndex Starting viewport index * @returns {Object} Infinite scroll state and methods */ export const useInfiniteScroll = (options = {}) => { const { generateContent = (index) => ({ index, content: `Viewport ${index}` }), viewportHeight = null, bufferSize = 1, initialIndex = 0, } = options // Computed viewport height (use provided or window height) const vpHeight = computed(() => viewportHeight || wHeight.value) // Container reference const containerRef = ref(null) // Core state const state = reactive({ // 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 } } viewports: new Map(), // Loading states isLoadingUp: false, isLoadingDown: false, // Error handling errors: new Map(), hasError: false, retryCount: new Map(), }) // Initialize with starting viewport const initializeViewports = async () => { // Seed scrollY once vpHeight is available state.scrollY = initialIndex * vpHeight.value const initialViewport = { index: initialIndex, content: await generateContent(initialIndex), height: vpHeight.value, offsetY: initialIndex * vpHeight.value, } state.viewports.set(initialIndex, initialViewport) await loadViewportsInRange( initialIndex - bufferSize, initialIndex + bufferSize, ) updateViewportPositions() } // Load viewports in a range const loadViewportsInRange = async (startIndex, endIndex) => { const loadPromises = [] for (let i = startIndex; i <= endIndex; i++) { if (!state.viewports.has(i)) { loadPromises.push(loadViewport(i)) } } await Promise.all(loadPromises.filter(Boolean)) updateViewportPositions() } // Load a single viewport with retry logic const loadViewport = async (index, isRetry = false) => { const maxRetries = 3 const currentRetryCount = state.retryCount.get(index) || 0 try { if (index < state.currentViewportIndex) { state.isLoadingUp = true } else if (index > state.currentViewportIndex) { state.isLoadingDown = true } const content = await generateContent(index) const viewport = { index, content, height: vpHeight.value, offsetY: index * vpHeight.value, } state.viewports.set(index, viewport) state.errors.delete(index) state.retryCount.delete(index) state.hasError = state.errors.size > 0 return viewport } catch (error) { console.error(`Failed to load viewport ${index}:`, error) state.errors.set(index, { message: error.message || 'Failed to load content', timestamp: Date.now(), retryCount: currentRetryCount, }) state.hasError = true if (currentRetryCount < maxRetries && !isRetry) { state.retryCount.set(index, currentRetryCount + 1) const delay = Math.pow(2, currentRetryCount) * 1000 setTimeout(() => loadViewport(index, true), delay) } return null } finally { state.isLoadingUp = false state.isLoadingDown = false } } // Update viewport positions based on their index const updateViewportPositions = () => { for (const [index, viewport] of state.viewports) { viewport.offsetY = index * vpHeight.value } } // Remove viewports outside buffer range const cleanupViewports = () => { const currentIndex = state.currentViewportIndex const minKeepIndex = currentIndex - bufferSize - 2 const maxKeepIndex = currentIndex + bufferSize + 2 for (const [index] of state.viewports) { if (index < minKeepIndex || index > maxKeepIndex) { state.viewports.delete(index) } } } // Calculate which viewport should be visible based on virtual scroll position const calculateVisibleViewport = (scrollY) => { return Math.floor(scrollY / vpHeight.value) } // Handle virtual-scroll delta events from Lenis const onVirtualScroll = async ({ deltaY, event }) => { if (!state.active) return const isTouch = event.type.includes('touch') if (isTouch) { deltaY *= 10 } else { deltaY *= 3 } gsap.to(state, { scrollY: state.scrollY + deltaY, duration: 1, ease: 'expo.out', onUpdate: async () => { const newViewportIndex = calculateVisibleViewport(state.scrollY) if (newViewportIndex !== state.currentViewportIndex) { state.currentViewportIndex = newViewportIndex const rangeStart = newViewportIndex - bufferSize const rangeEnd = newViewportIndex + bufferSize await loadViewportsInRange(rangeStart, rangeEnd) cleanupViewports() } }, }) } // Attach the virtual-scroll listener once Lenis is ready const lenis = useLenis() 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(() => { const current = state.currentViewportIndex const range = bufferSize + 1 const start = current - range const end = current + range const viewports = [] for (let i = start; i <= end; i++) { if (state.viewports.has(i)) { viewports.push(state.viewports.get(i)) } } return viewports.sort((a, b) => a.index - b.index) }) // Retry failed viewport const retryViewport = async (index) => { state.errors.delete(index) state.retryCount.delete(index) state.hasError = state.errors.size > 0 return await loadViewport(index) } // Reset to initial state const reset = async () => { state.viewports.clear() state.errors.clear() 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() }) return { // State containerRef, currentViewportIndex: computed(() => state.currentViewportIndex), visibleViewports, isLoadingUp: computed(() => state.isLoadingUp), isLoadingDown: computed(() => state.isLoadingDown), scrollY: computed(() => state.scrollY), vpHeight, // Methods reset, retryViewport, stop, start, // Error state hasError: computed(() => state.hasError), errors: computed(() => Array.from(state.errors.entries()).map(([index, error]) => ({ index, ...error, })), ), // Internal state for debugging _state: state, } }