nicwands.com/src/composables/useInfiniteScroll.js
2026-06-25 13:02:03 -04:00

293 lines
9 KiB
JavaScript

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,
}
}