mirror of
https://github.com/ArthurDanjou/artchat.git
synced 2026-01-28 20:55:59 +01:00
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:
116
modules/infinite-canvas/components/CanvasLoader.vue
Normal file
116
modules/infinite-canvas/components/CanvasLoader.vue
Normal 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>
|
||||
116
modules/infinite-canvas/components/CanvasMinimap.vue
Normal file
116
modules/infinite-canvas/components/CanvasMinimap.vue
Normal 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>
|
||||
142
modules/infinite-canvas/components/InfiniteCanvas.vue
Normal file
142
modules/infinite-canvas/components/InfiniteCanvas.vue
Normal 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>
|
||||
Reference in New Issue
Block a user