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> <template>
<lenis root :options="{ duration: 1 }"> <lenis root :options="{ duration: 1, infinite: true, syncTouch: true }">
<div :class="classes" :style="styles" id="container"> <div :class="classes" :style="styles" id="container">
<suspense> <suspense>
<layout /> <layout />

View file

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

View file

@ -2,7 +2,7 @@
<div v-if="global" class="layout"> <div v-if="global" class="layout">
<site-header :copy="headerCopy" :dl="headerDl" /> <site-header :copy="headerCopy" :dl="headerDl" />
<client-only> <client-only>
<mosaic-scroll :items="mosaicItems" /> <mosaic-scroll :items="mosaicItems" />
</client-only> </client-only>
@ -17,7 +17,7 @@
</template async> </template async>
<script setup> <script setup>
import { computed } from 'vue'; import { computed} from 'vue';
import { useSeoMeta } from '@unhead/vue' import { useSeoMeta } from '@unhead/vue'
import { useStrapiGlobal } from '@/composables/useStrapi' import { useStrapiGlobal } from '@/composables/useStrapi'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';

View file

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

View file

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

View file

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

View file

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

View file

@ -1,13 +1,16 @@
import { ref, reactive, computed, nextTick } from 'vue' import gsap from 'gsap'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import useLenis from '@/composables/useLenis' import useLenis from '@/composables/useLenis'
import { ref, reactive, computed, nextTick, onBeforeUnmount, watch } from 'vue'
// Window size for viewport calculations // Window size for viewport calculations
const { height: wHeight } = useWindowSize() const { height: wHeight } = useWindowSize()
/** /**
* Bidirectional infinite scroll composable * Bidirectional infinite virtual scroll composable
* Manages virtual viewports with on-demand content generation * 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 {Object} options Configuration options
* @param {Function} options.generateContent Function to generate content for a viewport * @param {Function} options.generateContent Function to generate content for a viewport
@ -32,8 +35,10 @@ export const useInfiniteScroll = (options = {}) => {
// Core state // Core state
const state = reactive({ const state = reactive({
// Current scroll position // Accumulated virtual scroll position (not the real DOM scroll)
scrollY: 0, scrollY: initialIndex * (viewportHeight || 0),
// Active state to stop/start scroll
active: true,
// Index of the currently visible viewport // Index of the currently visible viewport
currentViewportIndex: initialIndex, currentViewportIndex: initialIndex,
// Map of loaded viewports { index: { content, element, height, offsetY } } // Map of loaded viewports { index: { content, element, height, offsetY } }
@ -41,8 +46,6 @@ export const useInfiniteScroll = (options = {}) => {
// Loading states // Loading states
isLoadingUp: false, isLoadingUp: false,
isLoadingDown: false, isLoadingDown: false,
// Total virtual height (for scrollbar)
totalHeight: 0,
// Error handling // Error handling
errors: new Map(), errors: new Map(),
hasError: false, hasError: false,
@ -51,7 +54,9 @@ export const useInfiniteScroll = (options = {}) => {
// Initialize with starting viewport // Initialize with starting viewport
const initializeViewports = async () => { const initializeViewports = async () => {
// Create initial viewport // Seed scrollY once vpHeight is available
state.scrollY = initialIndex * vpHeight.value
const initialViewport = { const initialViewport = {
index: initialIndex, index: initialIndex,
content: await generateContent(initialIndex), content: await generateContent(initialIndex),
@ -61,15 +66,12 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.set(initialIndex, initialViewport) state.viewports.set(initialIndex, initialViewport)
// Load buffer viewports above and below
await loadViewportsInRange( await loadViewportsInRange(
initialIndex - bufferSize, initialIndex - bufferSize,
initialIndex + bufferSize, initialIndex + bufferSize,
) )
// Update positions and total height
updateViewportPositions() updateViewportPositions()
updateTotalHeight()
} }
// Load viewports in a range // Load viewports in a range
@ -92,7 +94,6 @@ export const useInfiniteScroll = (options = {}) => {
const currentRetryCount = state.retryCount.get(index) || 0 const currentRetryCount = state.retryCount.get(index) || 0
try { try {
// Set loading state based on direction
if (index < state.currentViewportIndex) { if (index < state.currentViewportIndex) {
state.isLoadingUp = true state.isLoadingUp = true
} else if (index > state.currentViewportIndex) { } else if (index > state.currentViewportIndex) {
@ -109,7 +110,6 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.set(index, viewport) state.viewports.set(index, viewport)
// Clear any previous errors for this viewport
state.errors.delete(index) state.errors.delete(index)
state.retryCount.delete(index) state.retryCount.delete(index)
state.hasError = state.errors.size > 0 state.hasError = state.errors.size > 0
@ -118,7 +118,6 @@ export const useInfiniteScroll = (options = {}) => {
} catch (error) { } catch (error) {
console.error(`Failed to load viewport ${index}:`, error) console.error(`Failed to load viewport ${index}:`, error)
// Store error
state.errors.set(index, { state.errors.set(index, {
message: error.message || 'Failed to load content', message: error.message || 'Failed to load content',
timestamp: Date.now(), timestamp: Date.now(),
@ -126,54 +125,26 @@ export const useInfiniteScroll = (options = {}) => {
}) })
state.hasError = true state.hasError = true
// Retry logic
if (currentRetryCount < maxRetries && !isRetry) { if (currentRetryCount < maxRetries && !isRetry) {
state.retryCount.set(index, currentRetryCount + 1) state.retryCount.set(index, currentRetryCount + 1)
console.log(
`Retrying viewport ${index} (attempt ${currentRetryCount + 1}/${maxRetries})`,
)
// Exponential backoff
const delay = Math.pow(2, currentRetryCount) * 1000 const delay = Math.pow(2, currentRetryCount) * 1000
setTimeout(() => { setTimeout(() => loadViewport(index, true), delay)
loadViewport(index, true)
}, delay)
} }
return null return null
} finally { } finally {
// Clear loading states
state.isLoadingUp = false state.isLoadingUp = false
state.isLoadingDown = false state.isLoadingDown = false
} }
} }
// Update viewport positions based on their order // Update viewport positions based on their index
const updateViewportPositions = () => { const updateViewportPositions = () => {
// Each viewport is positioned at index * viewport height
for (const [index, viewport] of state.viewports) { for (const [index, viewport] of state.viewports) {
viewport.offsetY = index * vpHeight.value 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 // Remove viewports outside buffer range
const cleanupViewports = () => { const cleanupViewports = () => {
const currentIndex = state.currentViewportIndex const currentIndex = state.currentViewportIndex
@ -185,37 +156,56 @@ export const useInfiniteScroll = (options = {}) => {
state.viewports.delete(index) 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) => { const calculateVisibleViewport = (scrollY) => {
return Math.floor(scrollY / vpHeight.value) return Math.floor(scrollY / vpHeight.value)
} }
// Handle scroll events // Handle virtual-scroll delta events from Lenis
const handleScroll = async (scrollData) => { const onVirtualScroll = async ({ deltaY }) => {
state.scrollY = scrollData.scroll if (!state.active) return
deltaY *= 3
gsap.to(state, {
scrollY: state.scrollY + deltaY,
duration: 1,
ease: 'expo.out',
})
const newViewportIndex = calculateVisibleViewport(state.scrollY) const newViewportIndex = calculateVisibleViewport(state.scrollY)
if (newViewportIndex !== state.currentViewportIndex) { if (newViewportIndex !== state.currentViewportIndex) {
state.currentViewportIndex = newViewportIndex state.currentViewportIndex = newViewportIndex
// Load new viewports in range
const rangeStart = newViewportIndex - bufferSize const rangeStart = newViewportIndex - bufferSize
const rangeEnd = newViewportIndex + bufferSize const rangeEnd = newViewportIndex + bufferSize
await loadViewportsInRange(rangeStart, rangeEnd) await loadViewportsInRange(rangeStart, rangeEnd)
// Cleanup old viewports
cleanupViewports() cleanupViewports()
} }
} }
// Setup scroll listener with Lenis // Setup Lenis — we use virtual-scroll instead of scroll so the DOM
const lenis = useLenis(handleScroll) // 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) // Get viewports to render (current + buffer)
const visibleViewports = computed(() => { const visibleViewports = computed(() => {
@ -234,14 +224,6 @@ export const useInfiniteScroll = (options = {}) => {
return viewports.sort((a, b) => a.index - b.index) 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 // Retry failed viewport
const retryViewport = async (index) => { const retryViewport = async (index) => {
state.errors.delete(index) state.errors.delete(index)
@ -257,11 +239,22 @@ export const useInfiniteScroll = (options = {}) => {
state.retryCount.clear() state.retryCount.clear()
state.hasError = false state.hasError = false
state.currentViewportIndex = initialIndex state.currentViewportIndex = initialIndex
state.scrollY = initialIndex * vpHeight.value
state.isLoadingUp = false state.isLoadingUp = false
state.isLoadingDown = false state.isLoadingDown = false
await initializeViewports() await initializeViewports()
} }
// Start/stop scrolling
const stop = () => {
state.active = false
gsap.killTweensOf(state)
}
const start = () => {
state.active = true
}
// Initialize on creation // Initialize on creation
nextTick(() => { nextTick(() => {
initializeViewports() initializeViewports()
@ -274,13 +267,14 @@ export const useInfiniteScroll = (options = {}) => {
visibleViewports, visibleViewports,
isLoadingUp: computed(() => state.isLoadingUp), isLoadingUp: computed(() => state.isLoadingUp),
isLoadingDown: computed(() => state.isLoadingDown), isLoadingDown: computed(() => state.isLoadingDown),
totalHeight: computed(() => state.totalHeight),
scrollY: computed(() => state.scrollY), scrollY: computed(() => state.scrollY),
vpHeight,
// Methods // Methods
scrollToViewport,
reset, reset,
retryViewport, retryViewport,
stop,
start,
// Error state // Error state
hasError: computed(() => state.hasError), 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)
},
}