move to virtual scrolling
This commit is contained in:
parent
49ef3db243
commit
4b79a5af0f
9 changed files with 120 additions and 85 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<div v-if="global" class="layout">
|
||||
<site-header :copy="headerCopy" :dl="headerDl" />
|
||||
|
||||
<client-only>
|
||||
<client-only>
|
||||
<mosaic-scroll :items="mosaicItems" />
|
||||
</client-only>
|
||||
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
</template async>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed} from 'vue';
|
||||
import { useSeoMeta } from '@unhead/vue'
|
||||
import { useStrapiGlobal } from '@/composables/useStrapi'
|
||||
import { useRoute } from 'vue-router';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ const props = defineProps({
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--theme-bg);
|
||||
|
||||
&.layout-portrait {
|
||||
.strapi-media {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ const props = defineProps({
|
|||
strong {
|
||||
@include h6;
|
||||
}
|
||||
p {
|
||||
p,
|
||||
a {
|
||||
margin: desktop-vw(6px) 0;
|
||||
@include p-small;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
15
src/plugins/gsap.js
Normal 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)
|
||||
},
|
||||
}
|
||||
Loading…
Reference in a new issue