mosaic infinite scroll wip

This commit is contained in:
nicwands 2026-06-02 15:00:59 -04:00
parent c679e0b967
commit 1d4a72da63
9 changed files with 864 additions and 18 deletions

View file

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

4
src/components.d.ts vendored
View file

@ -11,9 +11,13 @@ export {}
/* prettier-ignore */
declare module 'vue' {
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']
Lenis: typeof import('./components/Lenis.vue')['default']
LoadingSpinner: typeof import('./components/LoadingSpinner.vue')['default']
MosaicViewport: typeof import('./components/MosaicViewport.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View 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>

View 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>

View file

@ -1,20 +1,30 @@
<template>
<div class="layout">
<h1>nicwands.com</h1>
<main class="main-content">
<infinite-scroll-demo />
</main>
</div>
</template>
<script setup>
import { useSeoMeta } from '@unhead/vue'
import { computed } from 'vue'
// SEO
// const seo = computed(() => content.value?.seo?.[0])
// useSeoMeta({
// title: seo.value?.title || '',
// description: seo.value.description || '',
// ogImage: {
// url: seo.value.og_image?.filename || '',
// },
// })
useSeoMeta({
title: 'nicwands.com - Infinite Grid Gallery',
description:
'Interactive infinite scrolling grid gallery with bidirectional content generation',
})
</script>
<style lang="scss" scoped>
.layout {
position: relative;
min-height: 100vh;
.main-content {
position: relative;
width: 100%;
}
}
</style>

View 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>

View 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,
}
}

View file

@ -1,12 +1,18 @@
import { inject, onBeforeUnmount } from 'vue'
import { inject, onBeforeUnmount, watch } from 'vue'
export default (callback = () => {}, instanceId) => {
const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}`
const lenis = inject(instanceKey)
if (lenis.value) {
lenis.value.on('scroll', callback)
}
watch(
lenis,
() => {
if (lenis.value) {
lenis.value.on('scroll', callback)
}
},
{ immediate: true },
)
onBeforeUnmount(() => lenis.value?.off('scroll', callback))

View 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,
}
}