move to virtual scrolling

This commit is contained in:
nicwands 2026-06-19 15:14:20 -04:00
parent 49ef3db243
commit 4b79a5af0f
9 changed files with 120 additions and 85 deletions

View file

@ -1,5 +1,5 @@
<template>
<lenis root :options="{ duration: 1 }">
<lenis root :options="{ duration: 1, infinite: true, syncTouch: true }">
<div :class="classes" :style="styles" id="container">
<suspense>
<layout />

View file

@ -1,13 +1,12 @@
<template>
<div class="infinite-scroll-container">
<div class="wrapper" :style="{ height: totalHeight + 'px' }">
<!-- Render visible viewports -->
<transition-group name="fade" tag="div" class="viewport-stage">
<div
v-for="viewport in visibleViewports"
class="viewport"
:style="{
top: viewport.offsetY + 'px',
height: viewport.height + 'px',
transform: `translateY(${viewport.offsetY - scrollY}px)`,
}"
:key="viewport.index"
>
@ -25,11 +24,12 @@
</div>
</slot>
</div>
</div>
</transition-group>
</div>
</template>
<script setup>
import { watch } from 'vue'
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
const props = defineProps({
@ -74,11 +74,11 @@ const emit = defineEmits(['viewport-change', 'content-loaded'])
const {
currentViewportIndex,
visibleViewports,
totalHeight,
scrollY,
scrollToViewport,
reset,
retryViewport,
start,
stop,
hasError,
errors,
_state,
@ -93,43 +93,45 @@ const {
initialIndex: props.initialIndex,
})
// Watch for viewport changes and emit event
import { watch } from 'vue'
watch(currentViewportIndex, (newIndex, oldIndex) => {
emit('viewport-change', { newIndex, oldIndex })
})
// Expose methods to parent component
defineExpose({
scrollToViewport,
reset,
retryViewport,
start,
stop,
currentViewportIndex,
visibleViewports,
totalHeight,
scrollY,
hasError,
errors,
// Debug access
_state,
})
</script>
<style lang="scss">
.infinite-scroll-container {
position: relative;
position: fixed;
inset: 0;
width: 100%;
height: 100vh;
overflow: hidden;
.wrapper {
.viewport-stage {
position: relative;
width: 100%;
min-height: 100vh;
height: 100%;
}
.viewport {
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform;
}
}
</style>

View file

@ -4,6 +4,7 @@
:bufferSize="2"
:initialIndex="0"
:generateContent="generateMosaicContent"
ref="scrollContainer"
>
<template #viewport="{ viewport, content }">
<mosaic-viewport :viewport="viewport" :content="content" />
@ -13,9 +14,10 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, watch } from 'vue'
import _shuffle from 'lodash/shuffle'
import { useMosaicLayout } from '@/composables/useMosaicLayout'
import { useRoute } from 'vue-router'
const props = defineProps({
items: Array,
@ -23,8 +25,10 @@ const props = defineProps({
const itemIndex = ref(0)
const shuffledItems = ref(_shuffle(props.items))
const scrollContainer = ref(null)
const { generateLayout, populateCells } = useMosaicLayout()
const route = useRoute()
const generateCellContent = () => {
if (itemIndex.value >= shuffledItems.value.length - 1) {
@ -61,6 +65,19 @@ const generateMosaicContent = async (index) => {
cells: populatedCells,
}
}
// Start/stop scroll when page is open
watch(
() => route.name,
() => {
if (route.name === 'mosaic-item') {
scrollContainer.value?.stop?.()
} else {
scrollContainer.value?.start?.()
}
},
{ immediate: true },
)
</script>
<style lang="scss">

View file

@ -61,6 +61,7 @@ const props = defineProps({
display: flex;
justify-content: center;
align-items: center;
background: var(--theme-bg);
&.layout-portrait {
.strapi-media {

View file

@ -50,7 +50,8 @@ const props = defineProps({
strong {
@include h6;
}
p {
p,
a {
margin: desktop-vw(6px) 0;
@include p-small;
}

View file

@ -30,6 +30,11 @@ const props = defineProps({
background: var(--grey);
border-radius: desktop-vw(2px);
padding: desktop-vw(20px);
overflow-x: auto;
}
a {
text-decoration: underline;
}
}
</style>

View file

@ -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),

15
src/plugins/gsap.js Normal file
View file

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