293 lines
9 KiB
JavaScript
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,
|
|
}
|
|
}
|