mosaic infinite scroll wip
This commit is contained in:
parent
c679e0b967
commit
1d4a72da63
9 changed files with 864 additions and 18 deletions
|
|
@ -1,9 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<lenis root :options="{ duration: 1 }">
|
<lenis root :options="{ duration: 1 }">
|
||||||
<div :class="classes" :style="styles">
|
<div :class="classes" :style="styles" id="container">
|
||||||
<suspense>
|
<layout />
|
||||||
<layout />
|
|
||||||
</suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</lenis>
|
</lenis>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
4
src/components.d.ts
vendored
4
src/components.d.ts
vendored
|
|
@ -11,9 +11,13 @@ export {}
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
GridViewport: typeof import('./components/GridViewport.vue')['default']
|
||||||
|
InfiniteScrollContainer: typeof import('./components/InfiniteScrollContainer.vue')['default']
|
||||||
|
InfiniteScrollDemo: typeof import('./components/InfiniteScrollDemo.vue')['default']
|
||||||
Layout: typeof import('./components/Layout.vue')['default']
|
Layout: typeof import('./components/Layout.vue')['default']
|
||||||
Lenis: typeof import('./components/Lenis.vue')['default']
|
Lenis: typeof import('./components/Lenis.vue')['default']
|
||||||
LoadingSpinner: typeof import('./components/LoadingSpinner.vue')['default']
|
LoadingSpinner: typeof import('./components/LoadingSpinner.vue')['default']
|
||||||
|
MosaicViewport: typeof import('./components/MosaicViewport.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
137
src/components/InfiniteScrollContainer.vue
Normal file
137
src/components/InfiniteScrollContainer.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<template>
|
||||||
|
<div class="infinite-scroll-container">
|
||||||
|
<div class="wrapper" :style="{ height: totalHeight + 'px' }">
|
||||||
|
<!-- Render visible viewports -->
|
||||||
|
<div
|
||||||
|
v-for="viewport in visibleViewports"
|
||||||
|
class="viewport"
|
||||||
|
:style="{
|
||||||
|
top: viewport.offsetY + 'px',
|
||||||
|
height: viewport.height + 'px',
|
||||||
|
}"
|
||||||
|
:key="viewport.index"
|
||||||
|
>
|
||||||
|
<slot
|
||||||
|
name="viewport"
|
||||||
|
:viewport="viewport"
|
||||||
|
:index="viewport.index"
|
||||||
|
:content="viewport.content"
|
||||||
|
:isActive="viewport.index === currentViewportIndex"
|
||||||
|
>
|
||||||
|
<!-- Default viewport content -->
|
||||||
|
<div class="default-viewport">
|
||||||
|
<h2>Viewport {{ viewport.index }}</h2>
|
||||||
|
<pre>{{
|
||||||
|
JSON.stringify(viewport.content, null, 2)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useInfiniteScroll } from '@/composables/useInfiniteScroll'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* Function to generate content for each viewport
|
||||||
|
* @param {number} index - Viewport index
|
||||||
|
* @returns {Promise|Object} Content for the viewport
|
||||||
|
*/
|
||||||
|
generateContent: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height of each viewport in pixels
|
||||||
|
* If not provided, will use window height
|
||||||
|
*/
|
||||||
|
viewportHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of viewports to keep loaded outside visible area
|
||||||
|
*/
|
||||||
|
bufferSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starting viewport index
|
||||||
|
*/
|
||||||
|
initialIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['viewport-change', 'content-loaded'])
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentViewportIndex,
|
||||||
|
visibleViewports,
|
||||||
|
totalHeight,
|
||||||
|
scrollY,
|
||||||
|
scrollToViewport,
|
||||||
|
reset,
|
||||||
|
retryViewport,
|
||||||
|
hasError,
|
||||||
|
errors,
|
||||||
|
_state,
|
||||||
|
} = useInfiniteScroll({
|
||||||
|
generateContent: async (index) => {
|
||||||
|
const content = await props.generateContent(index)
|
||||||
|
emit('content-loaded', { index, content })
|
||||||
|
return content
|
||||||
|
},
|
||||||
|
viewportHeight: props.viewportHeight,
|
||||||
|
bufferSize: props.bufferSize,
|
||||||
|
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,
|
||||||
|
currentViewportIndex,
|
||||||
|
visibleViewports,
|
||||||
|
totalHeight,
|
||||||
|
scrollY,
|
||||||
|
hasError,
|
||||||
|
errors,
|
||||||
|
// Debug access
|
||||||
|
_state,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.infinite-scroll-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/components/InfiniteScrollDemo.vue
Normal file
67
src/components/InfiniteScrollDemo.vue
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<div class="infinite-scroll-demo">
|
||||||
|
<infinite-scroll-container
|
||||||
|
:bufferSize="2"
|
||||||
|
:initialIndex="0"
|
||||||
|
:generateContent="generateMosaicContent"
|
||||||
|
>
|
||||||
|
<template #viewport="{ viewport, content, isActive }">
|
||||||
|
<mosaic-viewport
|
||||||
|
:viewport="viewport"
|
||||||
|
:index="viewport.index"
|
||||||
|
:content="content"
|
||||||
|
:isActive="isActive"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</infinite-scroll-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useMosaicLayout } from '@/composables/useMosaicLayout'
|
||||||
|
|
||||||
|
// Initialize mosaic layout generator
|
||||||
|
const { generateLayout, populateCells } = useMosaicLayout()
|
||||||
|
|
||||||
|
const generateGifContent = (cell) => {
|
||||||
|
return {
|
||||||
|
id: `gif-${cell.id}`,
|
||||||
|
width: Math.floor(cell.width),
|
||||||
|
height: Math.floor(cell.height),
|
||||||
|
note: 'Curabitur euismod ultrices porttitor. Vivamus magna sapien, pretium at ullamcorper semper, sollicitudin et magna. Curabitur eget dolor a neque facilisis semper. Pellentesque euismod luctus urna, non aliquam justo aliquet id. Vestibulum quis lobortis diam.Nulla facilisi. Phasellus et rutrum lectus.Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Praesent mattis felis condimentum, gravida lacus id, pretium neque. Donec cursus augue a dui placerat porttitor.Vestibulum rhoncus lectus nisl, nec gravida ex sollicitudin vel. Etiam dictum ac lacus ac elementum. Donec turpis eros, aliquam in nibh a, posuere tristique neque. Nunc cursus molestie consectetur.Integer placerat lorem eu nunc semper porta.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate mosaic content for each viewport
|
||||||
|
const generateMosaicContent = async (index) => {
|
||||||
|
// Calculate target number of cells based on viewport size and index
|
||||||
|
const baseCellCount = 9
|
||||||
|
const variance = Math.abs(index % 3) * 2
|
||||||
|
const targetCells = baseCellCount + variance
|
||||||
|
|
||||||
|
// Generate layout with different parameters based on viewport index
|
||||||
|
// This adds variety to each viewport
|
||||||
|
const layoutOptions = {
|
||||||
|
minCellWidth: 200,
|
||||||
|
minCellHeight: 200,
|
||||||
|
targetCells: targetCells,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate the space-filling layout
|
||||||
|
const cells = generateLayout(layoutOptions)
|
||||||
|
|
||||||
|
// Populate cells with GIF content
|
||||||
|
const populatedCells = populateCells(cells, generateGifContent)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cells: populatedCells,
|
||||||
|
viewportIndex: index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.infinite-scroll-demo {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<h1>nicwands.com</h1>
|
<main class="main-content">
|
||||||
|
<infinite-scroll-demo />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useSeoMeta } from '@unhead/vue'
|
import { useSeoMeta } from '@unhead/vue'
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
// SEO
|
// SEO
|
||||||
// const seo = computed(() => content.value?.seo?.[0])
|
useSeoMeta({
|
||||||
// useSeoMeta({
|
title: 'nicwands.com - Infinite Grid Gallery',
|
||||||
// title: seo.value?.title || '',
|
description:
|
||||||
// description: seo.value.description || '',
|
'Interactive infinite scrolling grid gallery with bidirectional content generation',
|
||||||
// ogImage: {
|
})
|
||||||
// url: seo.value.og_image?.filename || '',
|
|
||||||
// },
|
|
||||||
// })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.layout {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
140
src/components/MosaicViewport.vue
Normal file
140
src/components/MosaicViewport.vue
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
<template>
|
||||||
|
<div class="mosaic-viewport" :class="{ active: isActive }">
|
||||||
|
<div class="mosaic-container">
|
||||||
|
<div
|
||||||
|
v-for="cell in content.cells"
|
||||||
|
class="mosaic-cell"
|
||||||
|
:style="{
|
||||||
|
left: cell.x + 'px',
|
||||||
|
top: cell.y + 'px',
|
||||||
|
width: cell.width + 'px',
|
||||||
|
height: cell.height + 'px',
|
||||||
|
}"
|
||||||
|
:key="`${viewport.index}-${cell.id}`"
|
||||||
|
@click="openNote(cell.content)"
|
||||||
|
>
|
||||||
|
<!-- GIF placeholder - you'll replace with actual GIFs -->
|
||||||
|
<div class="gif-placeholder">
|
||||||
|
<div class="gif-info">
|
||||||
|
<span class="gif-id">{{ cell.content.id }}</span>
|
||||||
|
<span class="gif-size"
|
||||||
|
>{{ Math.round(cell.width) }}x{{
|
||||||
|
Math.round(cell.height)
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<!-- This would be your actual GIF -->
|
||||||
|
<!-- <img :src="cell.content.gifUrl" :alt="cell.content.title" /> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note modal -->
|
||||||
|
<teleport to="#container">
|
||||||
|
<div v-if="selectedNote" class="note-modal-backdrop" @click="closeNote">
|
||||||
|
<div class="note-modal" @click.stop>
|
||||||
|
<button class="note-close" @click="closeNote">×</button>
|
||||||
|
<div class="note-content">
|
||||||
|
<p>{{ selectedNote.note }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
viewport: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedNote = ref(null)
|
||||||
|
|
||||||
|
const openNote = (cellContent) => {
|
||||||
|
if (cellContent.note) {
|
||||||
|
selectedNote.value = cellContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeNote = () => {
|
||||||
|
selectedNote.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close note on Escape key
|
||||||
|
const handleKeydown = (event) => {
|
||||||
|
if (event.key === 'Escape' && selectedNote.value) {
|
||||||
|
closeNote()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEventListener('keydown', handleKeydown)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mosaic-viewport {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.mosaic-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.mosaic-cell {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--theme-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.gif-placeholder {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gif-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.note-modal {
|
||||||
|
background: var(--theme-bg);
|
||||||
|
width: 50vw;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
297
src/composables/useInfiniteScroll.js
Normal file
297
src/composables/useInfiniteScroll.js
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
|
import { useWindowSize } from '@vueuse/core'
|
||||||
|
import useLenis from '@/composables/useLenis'
|
||||||
|
|
||||||
|
// Window size for viewport calculations
|
||||||
|
const { height: wHeight } = useWindowSize()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bidirectional infinite scroll composable
|
||||||
|
* Manages virtual viewports with on-demand content generation
|
||||||
|
*
|
||||||
|
* @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({
|
||||||
|
// Current scroll position
|
||||||
|
scrollY: 0,
|
||||||
|
// 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,
|
||||||
|
// Total virtual height (for scrollbar)
|
||||||
|
totalHeight: 0,
|
||||||
|
// Error handling
|
||||||
|
errors: new Map(),
|
||||||
|
hasError: false,
|
||||||
|
retryCount: new Map(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize with starting viewport
|
||||||
|
const initializeViewports = async () => {
|
||||||
|
// Create initial viewport
|
||||||
|
const initialViewport = {
|
||||||
|
index: initialIndex,
|
||||||
|
content: await generateContent(initialIndex),
|
||||||
|
height: vpHeight.value,
|
||||||
|
offsetY: initialIndex * vpHeight.value,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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 {
|
||||||
|
// Set loading state based on direction
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Clear any previous errors for this 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)
|
||||||
|
|
||||||
|
// Store error
|
||||||
|
state.errors.set(index, {
|
||||||
|
message: error.message || 'Failed to load content',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
retryCount: currentRetryCount,
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
// Clear loading states
|
||||||
|
state.isLoadingUp = false
|
||||||
|
state.isLoadingDown = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update viewport positions based on their order
|
||||||
|
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 - minIndex + 1 + bufferViewports * 2
|
||||||
|
state.totalHeight = totalRange * 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTotalHeight()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate which viewport should be visible based on scroll position
|
||||||
|
const calculateVisibleViewport = (scrollY) => {
|
||||||
|
return Math.floor(scrollY / vpHeight.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scroll events
|
||||||
|
const handleScroll = async (scrollData) => {
|
||||||
|
state.scrollY = scrollData.scroll
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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.isLoadingUp = false
|
||||||
|
state.isLoadingDown = false
|
||||||
|
await initializeViewports()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on creation
|
||||||
|
nextTick(() => {
|
||||||
|
initializeViewports()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
containerRef,
|
||||||
|
currentViewportIndex: computed(() => state.currentViewportIndex),
|
||||||
|
visibleViewports,
|
||||||
|
isLoadingUp: computed(() => state.isLoadingUp),
|
||||||
|
isLoadingDown: computed(() => state.isLoadingDown),
|
||||||
|
totalHeight: computed(() => state.totalHeight),
|
||||||
|
scrollY: computed(() => state.scrollY),
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
scrollToViewport,
|
||||||
|
reset,
|
||||||
|
retryViewport,
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
hasError: computed(() => state.hasError),
|
||||||
|
errors: computed(() =>
|
||||||
|
Array.from(state.errors.entries()).map(([index, error]) => ({
|
||||||
|
index,
|
||||||
|
...error,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Internal state for debugging
|
||||||
|
_state: state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { inject, onBeforeUnmount } from 'vue'
|
import { inject, onBeforeUnmount, watch } from 'vue'
|
||||||
|
|
||||||
export default (callback = () => {}, instanceId) => {
|
export default (callback = () => {}, instanceId) => {
|
||||||
const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}`
|
const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}`
|
||||||
const lenis = inject(instanceKey)
|
const lenis = inject(instanceKey)
|
||||||
|
|
||||||
if (lenis.value) {
|
watch(
|
||||||
lenis.value.on('scroll', callback)
|
lenis,
|
||||||
}
|
() => {
|
||||||
|
if (lenis.value) {
|
||||||
|
lenis.value.on('scroll', callback)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onBeforeUnmount(() => lenis.value?.off('scroll', callback))
|
onBeforeUnmount(() => lenis.value?.off('scroll', callback))
|
||||||
|
|
||||||
|
|
|
||||||
187
src/composables/useMosaicLayout.js
Normal file
187
src/composables/useMosaicLayout.js
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { useWindowSize } from '@vueuse/core'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mosaic Layout Generator
|
||||||
|
* Creates a space-filling layout by recursively subdividing areas
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { width: wWidth, height: wHeight } = useWindowSize()
|
||||||
|
|
||||||
|
export const useMosaicLayout = () => {
|
||||||
|
/**
|
||||||
|
* Generate a space-filling mosaic layout
|
||||||
|
* @param {Object} options Layout configuration
|
||||||
|
* @param {number} options.width Viewport width
|
||||||
|
* @param {number} options.height Viewport height
|
||||||
|
* @param {number} options.minCellWidth Minimum cell width
|
||||||
|
* @param {number} options.maxCellWidth Maximum cell width
|
||||||
|
* @param {number} options.minCellHeight Minimum cell height
|
||||||
|
* @param {number} options.maxCellHeight Maximum cell height
|
||||||
|
* @param {number} options.targetCells Target number of cells to create (if specified, overrides subdivisionProbability)
|
||||||
|
* @param {number} options.subdivisionProbability Probability of subdivision (0-1) - used when targetCells is not specified
|
||||||
|
* @returns {Array} Array of cell objects with x, y, width, height, id
|
||||||
|
*/
|
||||||
|
const generateLayout = (options = {}) => {
|
||||||
|
const {
|
||||||
|
width = wWidth.value,
|
||||||
|
height = wHeight.value,
|
||||||
|
minCellWidth = 150,
|
||||||
|
minCellHeight = 150,
|
||||||
|
targetCells = 5,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
return generateLayoutWithCellCount(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
targetCells,
|
||||||
|
minCellWidth,
|
||||||
|
minCellHeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate layout with a specific number of cells
|
||||||
|
* @param {number} width Viewport width
|
||||||
|
* @param {number} height Viewport height
|
||||||
|
* @param {number} targetCells Target number of cells
|
||||||
|
* @param {number} minCellWidth Minimum cell width
|
||||||
|
* @param {number} minCellHeight Minimum cell height
|
||||||
|
* @returns {Array} Array of cell objects
|
||||||
|
*/
|
||||||
|
const generateLayoutWithCellCount = (
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
targetCells,
|
||||||
|
minCellWidth,
|
||||||
|
minCellHeight,
|
||||||
|
) => {
|
||||||
|
const areas = [{ x: 0, y: 0, width, height, id: 0 }]
|
||||||
|
let cellId = 0
|
||||||
|
|
||||||
|
// Keep subdividing until we have enough cells or can't subdivide further
|
||||||
|
while (areas.length < targetCells) {
|
||||||
|
// Find areas that can be subdivided
|
||||||
|
const subdividableAreas = areas.filter((area) => {
|
||||||
|
const canSubdivideWidth = area.width >= minCellWidth * 2
|
||||||
|
const canSubdivideHeight = area.height >= minCellHeight * 2
|
||||||
|
return canSubdivideWidth || canSubdivideHeight
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subdividableAreas.length === 0) {
|
||||||
|
// No more areas can be subdivided, break
|
||||||
|
console.log(
|
||||||
|
`Stopped at ${areas.length} cells - no more areas can be subdivided`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose area to subdivide - prefer larger areas
|
||||||
|
subdividableAreas.sort(
|
||||||
|
(a, b) => b.width * b.height - a.width * a.height,
|
||||||
|
)
|
||||||
|
const areaToSplit = subdividableAreas[0]
|
||||||
|
const areaIndex = areas.indexOf(areaToSplit)
|
||||||
|
|
||||||
|
// Remove the area we're about to split
|
||||||
|
areas.splice(areaIndex, 1)
|
||||||
|
|
||||||
|
// Determine split direction
|
||||||
|
const canSubdivideWidth = areaToSplit.width >= minCellWidth * 2
|
||||||
|
const canSubdivideHeight = areaToSplit.height >= minCellHeight * 2
|
||||||
|
|
||||||
|
let splitVertical = true
|
||||||
|
if (canSubdivideWidth && canSubdivideHeight) {
|
||||||
|
const aspectRatio = areaToSplit.width / areaToSplit.height
|
||||||
|
if (aspectRatio > 1.5) {
|
||||||
|
splitVertical = true // Split wide areas vertically
|
||||||
|
} else if (aspectRatio < 0.67) {
|
||||||
|
splitVertical = false // Split tall areas horizontally
|
||||||
|
} else {
|
||||||
|
splitVertical = Math.random() > 0.5 // Random for square-ish areas
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
splitVertical = canSubdivideWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the area
|
||||||
|
if (splitVertical) {
|
||||||
|
const splitRatio = 0.3 + Math.random() * 0.4 // Between 30% and 70%
|
||||||
|
const leftWidth = Math.max(
|
||||||
|
minCellWidth,
|
||||||
|
Math.min(
|
||||||
|
areaToSplit.width - minCellWidth,
|
||||||
|
areaToSplit.width * splitRatio,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const rightWidth = areaToSplit.width - leftWidth
|
||||||
|
|
||||||
|
areas.push({
|
||||||
|
x: areaToSplit.x,
|
||||||
|
y: areaToSplit.y,
|
||||||
|
width: leftWidth,
|
||||||
|
height: areaToSplit.height,
|
||||||
|
id: cellId++,
|
||||||
|
})
|
||||||
|
areas.push({
|
||||||
|
x: areaToSplit.x + leftWidth,
|
||||||
|
y: areaToSplit.y,
|
||||||
|
width: rightWidth,
|
||||||
|
height: areaToSplit.height,
|
||||||
|
id: cellId++,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const splitRatio = 0.3 + Math.random() * 0.4 // Between 30% and 70%
|
||||||
|
const topHeight = Math.max(
|
||||||
|
minCellHeight,
|
||||||
|
Math.min(
|
||||||
|
areaToSplit.height - minCellHeight,
|
||||||
|
areaToSplit.height * splitRatio,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const bottomHeight = areaToSplit.height - topHeight
|
||||||
|
|
||||||
|
areas.push({
|
||||||
|
x: areaToSplit.x,
|
||||||
|
y: areaToSplit.y,
|
||||||
|
width: areaToSplit.width,
|
||||||
|
height: topHeight,
|
||||||
|
id: cellId++,
|
||||||
|
})
|
||||||
|
areas.push({
|
||||||
|
x: areaToSplit.x,
|
||||||
|
y: areaToSplit.y + topHeight,
|
||||||
|
width: areaToSplit.width,
|
||||||
|
height: bottomHeight,
|
||||||
|
id: cellId++,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert areas to cells
|
||||||
|
return areas.map((area) => ({
|
||||||
|
id: area.id,
|
||||||
|
x: area.x,
|
||||||
|
y: area.y,
|
||||||
|
width: area.width,
|
||||||
|
height: area.height,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate content for each cell in the layout
|
||||||
|
* @param {Array} cells Array of cell objects
|
||||||
|
* @param {Function} contentGenerator Function to generate content for each cell
|
||||||
|
* @returns {Array} Cells with content attached
|
||||||
|
*/
|
||||||
|
const populateCells = (cells, contentGenerator) => {
|
||||||
|
return cells.map((cell) => ({
|
||||||
|
...cell,
|
||||||
|
content: contentGenerator(cell),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
generateLayout,
|
||||||
|
populateCells,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue