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

@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { LoaderProps } from '../types'
const props = withDefaults(defineProps<LoaderProps>(), {
title: 'Loading Canvas',
description: 'Preparing your visual journey...',
})
const RADIUS = 45
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
/**
* Calculate progress percentage for display
*/
const percent = computed(() => Math.round(props.progress * 100))
/**
* Calculate stroke dash offset for circular progress
*/
const dashOffset = computed(() => CIRCUMFERENCE * (1 - props.progress))
/**
* Animation props for the content reveal
*/
const contentAnimation = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.5, delay: 0.2 },
}
/**
* Check if loading is complete
*/
const isComplete = computed(() => props.progress >= 1)
</script>
<template>
<Transition
enter-active-class="transition-all duration-500 ease-out"
leave-active-class="transition-all duration-700 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed inset-0 z-50 flex items-center justify-center bg-default"
>
<div class="pointer-events-none fixed inset-0 size-full overflow-hidden">
<div class="noise pointer-events-none absolute inset-[-200%] size-[400%] bg-[url('/noise.png')] opacity-[4%]" />
</div>
<div class="relative flex flex-col items-center space-y-8">
<div class="relative">
<svg
class="size-24 -rotate-90 transform"
viewBox="0 0 100 100"
>
<circle
cx="50"
cy="50"
:r="RADIUS"
fill="none"
stroke="rgba(255, 255, 255, 0.1)"
stroke-width="2"
/>
<circle
cx="50"
cy="50"
:r="RADIUS"
fill="none"
stroke="rgba(255, 255, 255, 0.8)"
stroke-width="2"
stroke-linecap="round"
:stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="dashOffset"
class="transition-all duration-300 ease-out"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-xl font-medium text-highlighted">
{{ percent }}%
</span>
</div>
</div>
<div class="text-center">
<Motion v-bind="contentAnimation">
<h2 class="text-2xl font-light text-highlighted mb-2">
{{ title }}
</h2>
<p class="text-sm text-highlighted/60">
{{ description }}
</p>
</Motion>
</div>
<div class="flex space-x-2">
<div
v-for="i in 3"
:key="i"
class="size-2 rounded-full bg-inverted/40"
:class="{ 'animate-pulse': !isComplete }"
:style="{ animationDelay: `${i * 0.2}s` }"
/>
</div>
</div>
<div class="pointer-events-none absolute inset-0">
<div class="absolute left-1/2 top-1/2 size-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-inverted/5 blur-3xl" />
</div>
</div>
</Transition>
</template>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { MinimapProps } from '../types'
const props = defineProps<MinimapProps>()
const MINIMAP_SIZE = 120
/**
* Calculate the scale factor to fit canvas in minimap
*/
const scale = computed(() => {
const { width, height } = props.canvasBounds
return Math.min(MINIMAP_SIZE / width, MINIMAP_SIZE / height)
})
/**
* Actual minimap dimensions after scaling
*/
const dimensions = computed(() => ({
width: props.canvasBounds.width * scale.value,
height: props.canvasBounds.height * scale.value,
}))
/**
* Positioning to center the minimap content
*/
const centerOffset = computed(() => ({
x: (MINIMAP_SIZE - dimensions.value.width) / 2,
y: (MINIMAP_SIZE - dimensions.value.height) / 2,
}))
/**
* Transform grid items to minimap coordinates with proper scaling
*/
const items = computed(() =>
props.gridItems.map(item => ({
index: item.index,
x: item.position.x * scale.value,
y: item.position.y * scale.value,
width: Math.max(2, item.width * scale.value),
height: Math.max(2, item.height * scale.value),
})),
)
/**
* Calculate viewport rectangle in minimap space
*/
const viewport = computed(() => {
const { width, height } = props.containerDimensions
const { zoom } = props
// Canvas coordinates of current viewport
const viewX = -props.offset.x / zoom
const viewY = -props.offset.y / zoom
const viewWidth = width / zoom
const viewHeight = height / zoom
// Convert to minimap coordinates
const x = viewX * scale.value
const y = viewY * scale.value
const w = viewWidth * scale.value
const h = viewHeight * scale.value
return {
x: Math.max(0, Math.min(dimensions.value.width - w, x)),
y: Math.max(0, Math.min(dimensions.value.height - h, y)),
width: Math.min(dimensions.value.width, w),
height: Math.min(dimensions.value.height, h),
}
})
</script>
<template>
<div class="relative overflow-hidden rounded-lg border border-default bg-default/40 p-2 backdrop-blur-sm shadow-lg">
<!-- Minimap container -->
<div
class="relative"
:style="{ width: `${MINIMAP_SIZE}px`, height: `${MINIMAP_SIZE}px` }"
>
<!-- Centered minimap content -->
<div
class="absolute bg-muted/50"
:style="{
left: `${centerOffset.x}px`,
top: `${centerOffset.y}px`,
width: `${dimensions.width}px`,
height: `${dimensions.height}px`,
}"
>
<!-- Items with real shapes -->
<div
v-for="item in items"
:key="item.index"
class="absolute bg-accented border border-inverted/30 rounded-sm"
:style="{
width: `${item.width}px`,
height: `${item.height}px`,
left: `${item.x}px`,
top: `${item.y}px`,
}"
/>
<!-- Viewport indicator -->
<div
class="absolute border border-primary bg-primary/10"
:style="{
left: `${viewport.x}px`,
top: `${viewport.y}px`,
width: `${viewport.width}px`,
height: `${viewport.height}px`,
}"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts" generic="T extends CanvasItem">
import type { CanvasItem, InfiniteCanvasEmits, InfiniteCanvasProps } from '../types'
import { useInfiniteCanvas } from '../composables/useInfiniteCanvas'
import { PHYSICS } from '../constants'
const props = withDefaults(defineProps<InfiniteCanvasProps<T>>(), {
baseGap: 40,
})
const emit = defineEmits<InfiniteCanvasEmits<T>>()
const containerRef = ref<HTMLElement | null>(null)
const {
offset,
zoom,
visibleItems,
gridItems,
containerDimensions,
canvasBounds,
canClick,
updateDimensions,
handlePointerDown: handlePointerDownCore,
handlePointerMove: handlePointerMoveCore,
handlePointerUp: handlePointerUpCore,
handleWheel,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
navigateTo,
} = useInfiniteCanvas({
items: props.items as CanvasItem[],
baseGap: props.baseGap,
zoomOptions: props.zoomOptions,
containerRef,
})
// Track drag state for click handling
const isCurrentlyDragging = ref(false)
const dragStartPosition = ref<{ x: number, y: number }>({ x: 0, y: 0 })
const totalDragDistance = ref(0)
function handlePointerDown(event: PointerEvent) {
handlePointerDownCore(event.clientX, event.clientY)
isCurrentlyDragging.value = false
dragStartPosition.value = { x: event.clientX, y: event.clientY }
totalDragDistance.value = 0
}
function handlePointerMove(event: PointerEvent) {
const currentPos = { x: event.clientX, y: event.clientY }
const distance = Math.sqrt(
(currentPos.x - dragStartPosition.value.x) ** 2
+ (currentPos.y - dragStartPosition.value.y) ** 2,
)
if (distance > PHYSICS.DRAG_THRESHOLD) {
isCurrentlyDragging.value = true
}
totalDragDistance.value = distance
handlePointerMoveCore(event.clientX, event.clientY)
}
function handlePointerUp(event: PointerEvent) {
handlePointerUpCore(event.clientX, event.clientY)
if (isCurrentlyDragging.value) {
setTimeout(() => {
isCurrentlyDragging.value = false
}, 100)
}
}
function handleItemClick(item: T | undefined, index: number) {
if (item && canClick.value && !isCurrentlyDragging.value && totalDragDistance.value <= PHYSICS.DRAG_THRESHOLD) {
emit('itemClick', item, index)
}
}
onMounted(() => {
updateDimensions()
useResizeObserver(containerRef, updateDimensions)
})
defineExpose({
offset,
zoom,
visibleItems,
gridItems,
containerDimensions,
canvasBounds,
updateDimensions,
navigateTo,
})
</script>
<template>
<div
ref="containerRef"
class="relative size-full overflow-hidden touch-none select-none"
style="touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointercancel="handlePointerUp"
@wheel="handleWheel"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<div
class="absolute transform-gpu will-change-transform"
:style="{
transform: `translate3d(${offset.x}px, ${offset.y}px, 0) scale(${zoom})`,
backfaceVisibility: 'hidden',
perspective: '1000px',
}"
>
<div
v-for="(gridItem, visibleIndex) in visibleItems"
:key="gridItem.index"
class="absolute transform-gpu will-change-transform"
:style="{
left: `${gridItem.position.x}px`,
top: `${gridItem.position.y}px`,
width: `${gridItem.width}px`,
height: `${gridItem.height}px`,
backfaceVisibility: 'hidden',
}"
>
<slot
v-if="props.items[gridItem.index]"
:item="props.items[gridItem.index]"
:index="gridItem.index"
:visible-index
:on-item-click="() => handleItemClick(props.items[gridItem.index], gridItem.index)"
/>
</div>
</div>
</div>
</template>