Files
artchat/modules/infinite-canvas/components/InfiniteCanvas.vue
Arthur DANJOU 5dadb20607 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.
2025-09-05 11:01:11 +02:00

143 lines
3.8 KiB
Vue

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