feat(infinite-canvas): add infinite canvas component with drag and zoom functionality

- Implemented InfiniteCanvas.vue for rendering an infinite canvas with drag and zoom capabilities.
- Created useInfiniteCanvas composable for managing canvas state and interactions.
- Added useImagePreloader composable for preloading images and videos.
- Introduced constants for physics, touch interactions, viewport settings, and zoom defaults.
- Developed utility functions for touch handling and media type detection.
- Defined TypeScript types for canvas items, grid items, and composables.
- Registered components and composables in the Nuxt module.
- Added screenshot generation functionality for content files.
- Updated package.json to include capture-website dependency.
This commit is contained in:
2025-09-05 11:01:11 +02:00
parent 97d7cddaa5
commit 5dadb20607
21 changed files with 2031 additions and 13 deletions

View File

@@ -45,10 +45,15 @@ defineShortcuts({
t: () => toggleDark({ clientX: window.innerWidth / 2, clientY: window.innerHeight }),
})
const isMobile = computed(() => {
if (!import.meta.client)
return false
return isMobileDevice(navigator.userAgent, window.innerWidth)
})
const activeElement = useActiveElement()
watch(openMessageModal, async () => {
await nextTick()
if (activeElement.value instanceof HTMLElement) {
if (activeElement.value instanceof HTMLElement && isMobile.value) {
activeElement.value.blur()
}
})

View File

@@ -10,12 +10,12 @@ const { t } = useI18n()
</h1>
<p class="text-center flex gap-0.5">
{{ t('error.main') }}
<NuxtLink href="/" class="sofia flex items-center group">
<span class="duration-300 underline-offset-2 font-semibold text-md text-black dark:text-white underline decoration-gray-300 dark:decoration-neutral-700 group-hover:decoration-black dark:group-hover:decoration-white">
{{ t('error.redirect') }}
</span>
</NuxtLink>
</p>
<NuxtLink href="/" class="sofia flex items-center group">
<span class="duration-300 underline-offset-2 font-semibold text-md text-black dark:text-white underline decoration-gray-300 dark:decoration-neutral-700 group-hover:decoration-black dark:group-hover:decoration-white">
{{ t('error.redirect') }}
</span>
</NuxtLink>
</div>
</UApp>
</template>

View File

@@ -1,7 +1,263 @@
<script lang="ts" setup>
import type { CanvasItem } from '~~/modules/infinite-canvas/types'
const { data, error } = await useAsyncData(`canva`, async () => {
const items: CanvasItem[] = []
const projects = await queryCollection('projects')
.select('title', 'description', 'cover', 'tags', 'slug', 'canva')
.all()
projects.forEach((project) => {
if (!project.cover)
return
items.push({
title: project.title,
description: project.description || '',
image: `/projects/${project.cover}`,
link: `/projects/${project.slug}`,
tags: project.tags || [],
height: project.canva?.height ?? 270,
width: project.canva?.width ?? 480,
})
})
const writings = await queryCollection('writings')
.select('title', 'description', 'cover', 'slug', 'tags', 'canva')
.all()
writings.forEach((writing) => {
if (!writing.cover)
return
items.push({
title: writing.title,
description: writing.description || '',
image: `/writings/${writing.cover}`,
link: `/writings/${writing.slug}`,
tags: writing.tags || [],
height: writing.canva?.height ?? 270,
width: writing.canva?.width ?? 480,
})
})
return items
})
const { t } = useI18n()
const isMobile = computed(() => {
if (!import.meta.client)
return false
return isMobileDevice(navigator.userAgent, window.innerWidth)
})
const router = useRouter()
function handleItemClick(item: CanvasItem) {
if (import.meta.client) {
router.push(item.link)
}
}
const imageUrls = computed(() => data.value?.map(item => item.cover))
const loaderProgress = ref(0)
const showLoader = ref(true)
const isImagesLoaded = ref(false)
const hoveredItemIndex = ref<number | null>(null)
const { startPreloading } = useImagePreloader({
images: imageUrls.value || [],
onProgress: (newProgress) => {
loaderProgress.value = newProgress
},
onComplete: () => {
isImagesLoaded.value = true
setTimeout(() => {
showLoader.value = false
}, 500)
},
})
const canvasRef = ref<any>(null)
onMounted(() => {
startPreloading()
nextTick(() => {
if (canvasRef.value?.updateDimensions) {
canvasRef.value.updateDimensions()
}
})
})
// Prevent browser navigation gestures
if (import.meta.client) {
document.documentElement.style.overscrollBehavior = 'none'
document.body.style.overscrollBehavior = 'none'
document.body.style.touchAction = 'manipulation'
}
</script>
<template>
<div />
<main v-if="data" class="canvas-page relative h-screen w-screen overflow-hidden" style="touch-action: none; overscroll-behavior: none;">
<Canvas
ref="canvasRef"
:items="data || []"
:base-gap="50"
:zoom-options="{
minZoom: 0.4,
maxZoom: 2.2,
zoomFactor: 1.08,
enableCtrl: true,
enableMeta: true,
enableAlt: true,
}"
class="absolute inset-0"
@item-click="handleItemClick"
>
<template #default="{ item, index, onItemClick }">
<Motion
:initial="{
opacity: 0,
filter: 'blur(20px)',
scale: 0.8,
}"
:animate="isImagesLoaded ? {
opacity: 1,
filter: 'blur(0px)',
scale: 1,
} : {
opacity: 0,
filter: 'blur(20px)',
scale: 0.8,
}"
:transition="{
duration: 0.8,
delay: isImagesLoaded ? Math.random() * 0.8 : 0,
ease: 'easeOut',
}"
class="group relative size-full select-none overflow-hidden hover:scale-105 active:scale-95 transition-all duration-300"
:class="[
isMobile ? 'cursor-default' : 'cursor-pointer',
index % 2 === 0 ? 'rotate-1' : '-rotate-1',
]"
data-canvas-item
@click="onItemClick"
@mouseenter="hoveredItemIndex = index"
@mouseleave="hoveredItemIndex = null"
>
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br p-1 border-2 border-default/50">
<div class="relative size-full overflow-hidden rounded-xl">
<video
v-if="item && isVideo(item.image)"
:src="item.image"
class="size-full object-cover"
autoplay
loop
muted
playsinline
:draggable="false"
/>
<img
v-else-if="item"
:src="item.image"
:alt="item.title"
class="size-full object-cover"
:draggable="false"
>
<!-- Item details overlay -->
<Motion
v-if="!isMobile"
:initial="{ opacity: 0, scale: 0.95 }"
:animate="hoveredItemIndex === index ? {
opacity: 1,
scale: 1,
transformOrigin: 'bottom left',
} : {
opacity: 0,
scale: 0.95,
transformOrigin: 'bottom left',
}"
:transition="{
duration: 0.2,
ease: 'easeOut',
}"
class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/90 via-black/60 to-transparent backdrop-blur-[2px] p-4 rounded-xl"
>
<Motion
:initial="{ y: 20, opacity: 0 }"
:animate="hoveredItemIndex === index ? {
y: 0,
opacity: 1,
} : {
y: 20,
opacity: 0,
}"
:transition="{
duration: 0.25,
delay: 0.05,
ease: 'easeOut',
}"
>
<div class="flex items-start gap-2">
<div class="flex-1">
<h3 class="font-medium text-white mb-1 line-clamp-2 leading-tight">
{{ item?.title }}
</h3>
<p
v-if="item?.description"
class="text-sm text-white/85 line-clamp-2"
>
{{ item.description }}
</p>
</div>
<UIcon name="i-heroicons-arrow-top-right-on-square" class="size-4 text-white/70 flex-shrink-0 mt-0.5" />
</div>
</Motion>
</Motion>
</div>
</div>
</Motion>
</template>
</Canvas>
<div v-if="canvasRef" class="fixed bottom-2 right-2 sm:bottom-4 sm:right-4 pointer-events-none">
<CanvasMinimap
:items="data || []"
:grid-items="canvasRef.gridItems || []"
:offset="canvasRef.offset || { x: 0, y: 0 }"
:zoom="canvasRef.zoom || 1"
:container-dimensions="canvasRef.containerDimensions || { width: 0, height: 0 }"
:canvas-bounds="canvasRef.canvasBounds || { width: 0, height: 0 }"
class="scale-85 sm:scale-100 origin-bottom-right"
/>
</div>
<div class="pointer-events-none absolute bottom-2 left-2 sm:bottom-4 sm:left-4 z-40 flex flex-col gap-2">
<div class="rounded-lg bg-default/80 px-3 py-2 text-highlighted backdrop-blur-sm">
<p class="text-xs opacity-75">
<span class="sm:hidden">{{ data?.length }} items</span><span class="hidden sm:inline">Click items to open links {{ data?.length }} items</span>
<span v-if="canvasRef?.zoom" class="ml-2 opacity-60">
{{ Math.round((canvasRef.zoom || 1) * 100) }}%
</span>
</p>
</div>
<div class="hidden sm:block rounded-lg bg-default/80 px-3 py-2 text-highlighted backdrop-blur-sm">
<p class="text-xs opacity-75">
Hold Ctrl//Alt + scroll to zoom (40%-220%)
</p>
</div>
</div>
<CanvasLoader
:progress="loaderProgress"
:is-visible="showLoader"
:title="t('canva.title')"
/>
</main>
<main v-else>
{{ error }}
</main>
</template>

View File

@@ -3,6 +3,13 @@ const route = useRoute()
const { data: project } = await useAsyncData(`projects/${route.params.slug}`, () =>
queryCollection('projects').path(`/projects/${route.params.slug}`).first())
if (!project.value) {
throw createError({
statusCode: 404,
statusMessage: `Project "${route.params.slug}" not found`,
})
}
useSeoMeta({
title: project.value?.title,
description: project.value?.description,
@@ -11,7 +18,7 @@ useSeoMeta({
</script>
<template>
<main v-if="project" class="mt-8 md:mt-16 md:mb-36 mb-20">
<UContainer v-if="project" class="mt-8 md:mt-16 md:mb-36 mb-20">
<PostAlert class="mb-8" />
<div>
<div class="flex items-end justify-between gap-2 flex-wrap">
@@ -51,7 +58,7 @@ useSeoMeta({
/>
</ClientOnly>
<PostFooter />
</main>
</UContainer>
</template>
<style scoped>

View File

@@ -3,6 +3,13 @@ const route = useRoute()
const { data: writing } = await useAsyncData(`writings/${route.params.slug}`, () =>
queryCollection('writings').path(`/writings/${route.params.slug}`).first())
if (!writing.value) {
throw createError({
statusCode: 404,
statusMessage: `Writing "${route.params.slug}" not found`,
})
}
useSeoMeta({
title: writing.value?.title,
description: writing.value?.description,