mirror of
https://github.com/ArthurDanjou/artchat.git
synced 2026-01-24 07:52:02 +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>
|
||||
116
modules/infinite-canvas/composables/useImagePreloader.ts
Normal file
116
modules/infinite-canvas/composables/useImagePreloader.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* eslint-disable ts/no-use-before-define */
|
||||
/**
|
||||
* @fileoverview Composable for preloading images and videos
|
||||
*/
|
||||
|
||||
import type { ImagePreloaderOptions, UseImagePreloaderReturn } from '../types'
|
||||
import { isVideo } from '../utils'
|
||||
|
||||
/**
|
||||
* Preloads a single media file (image or video)
|
||||
* @param src Media URL to preload
|
||||
* @returns Promise that resolves when loaded
|
||||
*/
|
||||
function preloadMedia(src: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (isVideo(src)) {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.muted = true
|
||||
|
||||
const handleLoad = () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoad)
|
||||
video.removeEventListener('error', handleError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
video.removeEventListener('loadedmetadata', handleLoad)
|
||||
video.removeEventListener('error', handleError)
|
||||
resolve() // Don't reject to avoid breaking the flow
|
||||
}
|
||||
|
||||
video.addEventListener('loadedmetadata', handleLoad)
|
||||
video.addEventListener('error', handleError)
|
||||
video.src = src
|
||||
}
|
||||
else {
|
||||
const img = new Image()
|
||||
|
||||
const handleLoad = () => {
|
||||
img.removeEventListener('load', handleLoad)
|
||||
img.removeEventListener('error', handleError)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
img.removeEventListener('load', handleLoad)
|
||||
img.removeEventListener('error', handleError)
|
||||
resolve() // Don't reject to avoid breaking the flow
|
||||
}
|
||||
|
||||
img.addEventListener('load', handleLoad)
|
||||
img.addEventListener('error', handleError)
|
||||
img.src = src
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for preloading multiple media files with progress tracking
|
||||
* @param options Configuration options
|
||||
* @returns Preloader state and controls
|
||||
*/
|
||||
export function useImagePreloader(options: ImagePreloaderOptions): UseImagePreloaderReturn {
|
||||
const { images, onProgress, onComplete } = options
|
||||
|
||||
const progress = ref(0)
|
||||
const loadedCount = ref(0)
|
||||
const isLoading = ref(true)
|
||||
const isComplete = ref(false)
|
||||
|
||||
/**
|
||||
* Updates progress and triggers callbacks
|
||||
*/
|
||||
const updateProgress = () => {
|
||||
const newProgress = loadedCount.value / images.length
|
||||
progress.value = newProgress
|
||||
onProgress?.(newProgress)
|
||||
|
||||
if (loadedCount.value === images.length) {
|
||||
isComplete.value = true
|
||||
isLoading.value = false
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the preloading process for all media files
|
||||
*/
|
||||
const startPreloading = async () => {
|
||||
if (images.length === 0) {
|
||||
progress.value = 1
|
||||
isComplete.value = true
|
||||
isLoading.value = false
|
||||
onComplete?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Preload all media in parallel with individual progress tracking
|
||||
const preloadPromises = images.map(async (src) => {
|
||||
await preloadMedia(src)
|
||||
loadedCount.value++
|
||||
updateProgress()
|
||||
})
|
||||
|
||||
await Promise.all(preloadPromises)
|
||||
}
|
||||
|
||||
return {
|
||||
progress: readonly(progress),
|
||||
loadedCount: readonly(loadedCount),
|
||||
isLoading: readonly(isLoading),
|
||||
isComplete: readonly(isComplete),
|
||||
startPreloading,
|
||||
}
|
||||
}
|
||||
576
modules/infinite-canvas/composables/useInfiniteCanvas.ts
Normal file
576
modules/infinite-canvas/composables/useInfiniteCanvas.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* @fileoverview Main composable for infinite canvas functionality
|
||||
*/
|
||||
|
||||
import type {
|
||||
GridItem,
|
||||
Position,
|
||||
UseInfiniteCanvasOptions,
|
||||
UseInfiniteCanvasReturn,
|
||||
} from '../types'
|
||||
import { PHYSICS, TOUCH, VIEWPORT, ZOOM_DEFAULTS } from '../constants'
|
||||
import { clamp, getTouchCenter, getTouchDistance } from '../utils'
|
||||
|
||||
/**
|
||||
* Creates an infinite canvas with drag, zoom, and virtualization support
|
||||
* @param options Configuration options
|
||||
* @returns Canvas state and event handlers
|
||||
*/
|
||||
export function useInfiniteCanvas(options: UseInfiniteCanvasOptions): UseInfiniteCanvasReturn {
|
||||
const { items, baseGap = 40, zoomOptions, containerRef } = options
|
||||
|
||||
// Canvas state
|
||||
const offset = ref<Position>({ x: 0, y: 0 })
|
||||
const velocity = ref<Position>({ x: 0, y: 0 })
|
||||
const zoom = ref(1)
|
||||
const containerDimensions = ref({ width: 0, height: 0 })
|
||||
|
||||
// Interaction state
|
||||
const isDragging = ref(false)
|
||||
const justFinishedDragging = ref(false)
|
||||
const dragStart = ref<Position>({ x: 0, y: 0 })
|
||||
const dragStartOffset = ref<Position>({ x: 0, y: 0 })
|
||||
const totalDragDistance = ref(0)
|
||||
const dragStartTime = ref(0)
|
||||
|
||||
// Touch state
|
||||
const isPinching = ref(false)
|
||||
const initialPinchDistance = ref(0)
|
||||
const initialPinchZoom = ref(1)
|
||||
const pinchCenter = ref<Position & { canvasX?: number, canvasY?: number }>({ x: 0, y: 0 })
|
||||
const touchStartTime = ref(0)
|
||||
const wasPinching = ref(false)
|
||||
|
||||
/**
|
||||
* Calculate canvas bounds based on item count
|
||||
*/
|
||||
const canvasBounds = computed(() => {
|
||||
const canvasSize = Math.max(4000, items.length * 100)
|
||||
return {
|
||||
width: canvasSize,
|
||||
height: canvasSize,
|
||||
centerX: canvasSize / 2,
|
||||
centerY: canvasSize / 2,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Check collision between two positioned items
|
||||
*/
|
||||
const checkCollision = (
|
||||
pos1: Position,
|
||||
size1: { width: number, height: number },
|
||||
pos2: Position,
|
||||
size2: { width: number, height: number },
|
||||
): boolean => {
|
||||
const gap = baseGap
|
||||
return !(
|
||||
pos1.x + size1.width + gap < pos2.x
|
||||
|| pos2.x + size2.width + gap < pos1.x
|
||||
|| pos1.y + size1.height + gap < pos2.y
|
||||
|| pos2.y + size2.height + gap < pos1.y
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a valid position for an item using spiral placement
|
||||
*/
|
||||
const findValidPosition = (index: number, placedItems: GridItem[]): Position => {
|
||||
const { centerX = 0, centerY = 0 } = canvasBounds.value
|
||||
const itemWidth = items[index]?.width || 300
|
||||
const itemHeight = items[index]?.height || 300
|
||||
|
||||
if (index === 0) {
|
||||
return {
|
||||
x: centerX - itemWidth / 2,
|
||||
y: centerY - itemHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
let radius = 200
|
||||
const maxRadius = 2000
|
||||
const angleStep = 0.5
|
||||
|
||||
while (radius < maxRadius) {
|
||||
for (let angle = 0; angle < Math.PI * 2; angle += angleStep) {
|
||||
const x = centerX + Math.cos(angle) * radius - itemWidth / 2
|
||||
const y = centerY + Math.sin(angle) * radius - itemHeight / 2
|
||||
const newPosition = { x, y }
|
||||
|
||||
const hasCollision = placedItems.some(placedItem =>
|
||||
checkCollision(
|
||||
newPosition,
|
||||
{ width: itemWidth, height: itemHeight },
|
||||
placedItem.position,
|
||||
{ width: placedItem.width, height: placedItem.height },
|
||||
),
|
||||
)
|
||||
|
||||
if (!hasCollision) {
|
||||
return newPosition
|
||||
}
|
||||
}
|
||||
radius += 150
|
||||
}
|
||||
|
||||
return { x: centerX, y: centerY }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate positions for all grid items
|
||||
*/
|
||||
const gridItems = computed<GridItem[]>(() => {
|
||||
const placedItems: GridItem[] = []
|
||||
|
||||
return items.map((item, index) => {
|
||||
const position = findValidPosition(index, placedItems)
|
||||
const gridItem: GridItem = {
|
||||
position,
|
||||
index,
|
||||
width: item.width || 300,
|
||||
height: item.height || 300,
|
||||
}
|
||||
placedItems.push(gridItem)
|
||||
return gridItem
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Constrain offset to canvas bounds
|
||||
*/
|
||||
const constrainOffset = (newOffset: Position): Position => {
|
||||
const { width, height } = containerDimensions.value
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasBounds.value
|
||||
const currentZoom = zoom.value
|
||||
|
||||
const maxOffsetX = width - canvasWidth * currentZoom
|
||||
const maxOffsetY = height - canvasHeight * currentZoom
|
||||
|
||||
return {
|
||||
x: clamp(newOffset.x, maxOffsetX, 0),
|
||||
y: clamp(newOffset.y, maxOffsetY, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttled visible items calculation to reduce flickering during zoom
|
||||
*/
|
||||
const _visibleItemsCache = ref<GridItem[]>([])
|
||||
let _lastVisibleItemsUpdate = 0
|
||||
|
||||
const calculateVisibleItems = () => {
|
||||
const { width, height } = containerDimensions.value
|
||||
const currentZoom = zoom.value
|
||||
|
||||
// Increase buffer during pinching to reduce flickering
|
||||
const baseBuffer = isPinching.value
|
||||
? VIEWPORT.BASE_BUFFER * 1.5
|
||||
: VIEWPORT.BASE_BUFFER
|
||||
|
||||
const buffer = Math.max(
|
||||
VIEWPORT.MIN_BUFFER,
|
||||
baseBuffer / Math.max(currentZoom, 1),
|
||||
)
|
||||
|
||||
const viewportLeft = (-offset.value.x) / currentZoom - buffer
|
||||
const viewportRight = (-offset.value.x + width) / currentZoom + buffer
|
||||
const viewportTop = (-offset.value.y) / currentZoom - buffer
|
||||
const viewportBottom = (-offset.value.y + height) / currentZoom + buffer
|
||||
|
||||
const maxVisibleItems = Math.min(
|
||||
VIEWPORT.MAX_VISIBLE_ITEMS,
|
||||
Math.ceil(VIEWPORT.VISIBLE_ITEMS_FACTOR / currentZoom),
|
||||
)
|
||||
|
||||
const visibleItemsList = gridItems.value.filter((gridItem) => {
|
||||
const itemRight = gridItem.position.x + gridItem.width
|
||||
const itemBottom = gridItem.position.y + gridItem.height
|
||||
|
||||
return (
|
||||
gridItem.position.x < viewportRight
|
||||
&& itemRight > viewportLeft
|
||||
&& gridItem.position.y < viewportBottom
|
||||
&& itemBottom > viewportTop
|
||||
)
|
||||
})
|
||||
|
||||
if (visibleItemsList.length > maxVisibleItems) {
|
||||
const centerX = (-offset.value.x + width / 2) / currentZoom
|
||||
const centerY = (-offset.value.y + height / 2) / currentZoom
|
||||
|
||||
return visibleItemsList
|
||||
.map(item => ({
|
||||
...item,
|
||||
distanceToCenter: Math.sqrt(
|
||||
(item.position.x + item.width / 2 - centerX) ** 2
|
||||
+ (item.position.y + item.height / 2 - centerY) ** 2,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distanceToCenter - b.distanceToCenter)
|
||||
.slice(0, maxVisibleItems)
|
||||
}
|
||||
|
||||
return visibleItemsList
|
||||
}
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const now = Date.now()
|
||||
const updateDelay = isPinching.value ? 50 : 16 // Throttle more during pinching for stability
|
||||
|
||||
// Throttle updates during rapid changes to reduce flickering
|
||||
if (now - _lastVisibleItemsUpdate > updateDelay || !isPinching.value) {
|
||||
_lastVisibleItemsUpdate = now
|
||||
const newVisibleItems = calculateVisibleItems()
|
||||
|
||||
// During pinching, only update if there's a significant change
|
||||
if (isPinching.value) {
|
||||
const currentCount = _visibleItemsCache.value.length
|
||||
const newCount = newVisibleItems.length
|
||||
const changeThreshold = Math.max(2, Math.floor(currentCount * 0.1))
|
||||
|
||||
if (Math.abs(newCount - currentCount) >= changeThreshold) {
|
||||
_visibleItemsCache.value = newVisibleItems
|
||||
}
|
||||
}
|
||||
else {
|
||||
_visibleItemsCache.value = newVisibleItems
|
||||
}
|
||||
}
|
||||
|
||||
return _visibleItemsCache.value
|
||||
})
|
||||
|
||||
/**
|
||||
* Center the view on the canvas
|
||||
*/
|
||||
const centerView = () => {
|
||||
const { width, height } = containerDimensions.value
|
||||
const { centerX = 0, centerY = 0 } = canvasBounds.value
|
||||
|
||||
offset.value = {
|
||||
x: -centerX + width / 2,
|
||||
y: -centerY + height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update container dimensions and center view if needed
|
||||
*/
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.value) {
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
containerDimensions.value = { width: rect.width, height: rect.height }
|
||||
|
||||
if (offset.value.x === 0 && offset.value.y === 0 && rect.width > 0) {
|
||||
centerView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animation loop for momentum
|
||||
*/
|
||||
const animate = () => {
|
||||
if (Math.abs(velocity.value.x) > PHYSICS.MIN_VELOCITY
|
||||
|| Math.abs(velocity.value.y) > PHYSICS.MIN_VELOCITY) {
|
||||
offset.value = constrainOffset({
|
||||
x: offset.value.x + velocity.value.x,
|
||||
y: offset.value.y + velocity.value.y,
|
||||
})
|
||||
|
||||
velocity.value.x *= PHYSICS.FRICTION
|
||||
velocity.value.y *= PHYSICS.FRICTION
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
const handlePointerDown = (clientX: number, clientY: number) => {
|
||||
dragStart.value = { x: clientX, y: clientY }
|
||||
dragStartOffset.value = { ...offset.value }
|
||||
totalDragDistance.value = 0
|
||||
dragStartTime.value = Date.now()
|
||||
isDragging.value = false
|
||||
justFinishedDragging.value = false
|
||||
}
|
||||
|
||||
const handlePointerMove = (clientX: number, clientY: number) => {
|
||||
if (dragStart.value.x === 0 && dragStart.value.y === 0)
|
||||
return
|
||||
|
||||
const deltaX = clientX - dragStart.value.x
|
||||
const deltaY = clientY - dragStart.value.y
|
||||
|
||||
totalDragDistance.value = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
||||
|
||||
if (totalDragDistance.value > PHYSICS.DRAG_THRESHOLD) {
|
||||
isDragging.value = true
|
||||
|
||||
offset.value = constrainOffset({
|
||||
x: dragStartOffset.value.x + deltaX,
|
||||
y: dragStartOffset.value.y + deltaY,
|
||||
})
|
||||
|
||||
velocity.value = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (clientX: number, clientY: number) => {
|
||||
if (isDragging.value) {
|
||||
const deltaTime = Date.now() - dragStartTime.value
|
||||
const deltaX = clientX - dragStart.value.x
|
||||
const deltaY = clientY - dragStart.value.y
|
||||
|
||||
if (deltaTime > 0 && totalDragDistance.value > PHYSICS.DRAG_THRESHOLD) {
|
||||
const velocityMultiplier = Math.min(deltaTime, 100) / 100
|
||||
velocity.value = {
|
||||
x: (deltaX / deltaTime) * 16 * velocityMultiplier,
|
||||
y: (deltaY / deltaTime) * 16 * velocityMultiplier,
|
||||
}
|
||||
animate()
|
||||
}
|
||||
|
||||
justFinishedDragging.value = true
|
||||
setTimeout(() => {
|
||||
justFinishedDragging.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
dragStart.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
const opts = zoomOptions || {}
|
||||
const minZoom = opts.minZoom ?? ZOOM_DEFAULTS.MIN
|
||||
const maxZoom = opts.maxZoom ?? ZOOM_DEFAULTS.MAX
|
||||
const zoomFactor = opts.zoomFactor ?? ZOOM_DEFAULTS.FACTOR
|
||||
const zoomFactorOut = 1 / zoomFactor
|
||||
|
||||
const isZoomModifier = (
|
||||
(opts.enableCtrl !== false && event.ctrlKey)
|
||||
|| (opts.enableMeta !== false && event.metaKey)
|
||||
|| (opts.enableAlt !== false && event.altKey)
|
||||
)
|
||||
|
||||
if (isZoomModifier) {
|
||||
const newZoom = clamp(
|
||||
zoom.value * (event.deltaY > 0 ? zoomFactorOut : zoomFactor),
|
||||
minZoom,
|
||||
maxZoom,
|
||||
)
|
||||
|
||||
const rect = containerRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const mouseX = event.clientX - rect.left
|
||||
const mouseY = event.clientY - rect.top
|
||||
|
||||
const oldZoom = zoom.value
|
||||
const pointX = (mouseX - offset.value.x) / oldZoom
|
||||
const pointY = (mouseY - offset.value.y) / oldZoom
|
||||
|
||||
zoom.value = newZoom
|
||||
|
||||
offset.value = constrainOffset({
|
||||
x: mouseX - pointX * newZoom,
|
||||
y: mouseY - pointY * newZoom,
|
||||
})
|
||||
}
|
||||
else {
|
||||
zoom.value = newZoom
|
||||
}
|
||||
}
|
||||
else {
|
||||
offset.value = constrainOffset({
|
||||
x: offset.value.x - event.deltaX,
|
||||
y: offset.value.y - event.deltaY,
|
||||
})
|
||||
}
|
||||
|
||||
velocity.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
const navigateTo = (position: Position) => {
|
||||
const { width, height } = containerDimensions.value
|
||||
offset.value = constrainOffset({
|
||||
x: -position.x + width / 2,
|
||||
y: -position.y + height / 2,
|
||||
})
|
||||
velocity.value = { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
// Touch handlers
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
// Only prevent default for multi-touch to allow single touch scrolling
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const touches = Array.from(event.touches)
|
||||
touchStartTime.value = Date.now()
|
||||
wasPinching.value = false
|
||||
|
||||
if (touches.length === 1 && touches[0]) {
|
||||
const [touch] = touches
|
||||
handlePointerDown(touch.clientX, touch.clientY)
|
||||
isPinching.value = false
|
||||
}
|
||||
else if (touches.length === 2 && touches[0] && touches[1]) {
|
||||
const [touch1, touch2] = touches
|
||||
isPinching.value = true
|
||||
wasPinching.value = true
|
||||
initialPinchDistance.value = getTouchDistance(touch1, touch2)
|
||||
initialPinchZoom.value = zoom.value
|
||||
|
||||
const rect = containerRef.value?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
const center = getTouchCenter(touch1, touch2)
|
||||
// Store the center in canvas coordinates (accounting for current transform)
|
||||
const canvasX = (center.x - rect.left - offset.value.x) / zoom.value
|
||||
const canvasY = (center.y - rect.top - offset.value.y) / zoom.value
|
||||
pinchCenter.value = {
|
||||
x: center.x - rect.left,
|
||||
y: center.y - rect.top,
|
||||
canvasX,
|
||||
canvasY,
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
velocity.value = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
let lastTouchMoveCall = 0
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
// Prevent default only for multi-touch (pinch/zoom)
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
// Reduce throttling during pinch for smoother zoom
|
||||
const throttleDelay = isPinching.value
|
||||
? 8 // More frequent updates during pinch
|
||||
: (zoom.value > ZOOM_DEFAULTS.HIGH_ZOOM_THRESHOLD
|
||||
? TOUCH.THROTTLE_MS_HIGH_ZOOM
|
||||
: TOUCH.THROTTLE_MS)
|
||||
|
||||
if (now - lastTouchMoveCall < throttleDelay)
|
||||
return
|
||||
lastTouchMoveCall = now
|
||||
|
||||
const touches = Array.from(event.touches)
|
||||
|
||||
if (touches.length === 1 && !isPinching.value && touches[0]) {
|
||||
const [touch] = touches
|
||||
handlePointerMove(touch.clientX, touch.clientY)
|
||||
}
|
||||
else if (touches.length === 2 && isPinching.value && touches[0] && touches[1]) {
|
||||
const [touch1, touch2] = touches
|
||||
const currentDistance = getTouchDistance(touch1, touch2)
|
||||
const distanceChange = currentDistance - initialPinchDistance.value
|
||||
|
||||
if (Math.abs(distanceChange) > TOUCH.PINCH_THRESHOLD) {
|
||||
const opts = zoomOptions || {}
|
||||
const minZoom = opts.minZoom ?? ZOOM_DEFAULTS.MIN
|
||||
const maxZoom = opts.maxZoom ?? ZOOM_DEFAULTS.MAX
|
||||
|
||||
const zoomFactor = 1 + (distanceChange / initialPinchDistance.value) * 1.2
|
||||
const newZoom = clamp(initialPinchZoom.value * zoomFactor, minZoom, maxZoom)
|
||||
|
||||
// Use the stored canvas coordinates if available for more stable zooming
|
||||
let pointX, pointY
|
||||
if (pinchCenter.value.canvasX !== undefined && pinchCenter.value.canvasY !== undefined) {
|
||||
pointX = pinchCenter.value.canvasX
|
||||
pointY = pinchCenter.value.canvasY
|
||||
}
|
||||
else {
|
||||
// Fallback to the old method
|
||||
const oldZoom = zoom.value
|
||||
pointX = (pinchCenter.value.x - offset.value.x) / oldZoom
|
||||
pointY = (pinchCenter.value.y - offset.value.y) / oldZoom
|
||||
}
|
||||
|
||||
// Batch zoom and offset updates to reduce flickering
|
||||
const newOffset = constrainOffset({
|
||||
x: pinchCenter.value.x - pointX * newZoom,
|
||||
y: pinchCenter.value.y - pointY * newZoom,
|
||||
})
|
||||
|
||||
// Apply changes in a single frame
|
||||
requestAnimationFrame(() => {
|
||||
zoom.value = newZoom
|
||||
offset.value = newOffset
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (event: TouchEvent) => {
|
||||
// Only prevent default if we were handling multi-touch
|
||||
if (wasPinching.value || isPinching.value) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const touches = Array.from(event.touches)
|
||||
const touchDuration = Date.now() - touchStartTime.value
|
||||
|
||||
if (touches.length === 0) {
|
||||
if (isPinching.value || wasPinching.value) {
|
||||
isPinching.value = false
|
||||
wasPinching.value = false
|
||||
// Force a clean update of visible items after pinching ends
|
||||
setTimeout(() => {
|
||||
_visibleItemsCache.value = calculateVisibleItems()
|
||||
}, 100)
|
||||
}
|
||||
else {
|
||||
const changedTouches = Array.from(event.changedTouches)
|
||||
const [touch] = changedTouches
|
||||
if (touch) {
|
||||
if (touchDuration < TOUCH.TAP_DURATION
|
||||
&& totalDragDistance.value <= TOUCH.TAP_DISTANCE) {
|
||||
isDragging.value = false
|
||||
justFinishedDragging.value = false
|
||||
totalDragDistance.value = 0
|
||||
}
|
||||
handlePointerUp(touch.clientX, touch.clientY)
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (touches.length === 1 && isPinching.value && touches[0]) {
|
||||
isPinching.value = false
|
||||
const [touch] = touches
|
||||
handlePointerDown(touch.clientX, touch.clientY)
|
||||
}
|
||||
}
|
||||
|
||||
const canClick = computed(() =>
|
||||
!justFinishedDragging.value
|
||||
&& !isPinching.value
|
||||
&& totalDragDistance.value <= PHYSICS.DRAG_THRESHOLD,
|
||||
)
|
||||
|
||||
return {
|
||||
offset: readonly(offset),
|
||||
zoom: readonly(zoom),
|
||||
visibleItems,
|
||||
gridItems,
|
||||
containerDimensions: readonly(containerDimensions),
|
||||
canvasBounds: readonly(canvasBounds),
|
||||
canClick: readonly(canClick),
|
||||
updateDimensions,
|
||||
handlePointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
handleWheel,
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
navigateTo,
|
||||
}
|
||||
}
|
||||
55
modules/infinite-canvas/constants/index.ts
Normal file
55
modules/infinite-canvas/constants/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Physics constants for drag and scroll behavior
|
||||
*/
|
||||
export const PHYSICS = {
|
||||
/** Minimum velocity threshold for momentum animation */
|
||||
MIN_VELOCITY: 0.1,
|
||||
/** Friction coefficient for momentum decay */
|
||||
FRICTION: 0.92,
|
||||
/** Minimum distance to register as drag (px) */
|
||||
DRAG_THRESHOLD: 5,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Touch interaction constants
|
||||
*/
|
||||
export const TOUCH = {
|
||||
/** Minimum distance to register as pinch gesture (px) */
|
||||
PINCH_THRESHOLD: 10,
|
||||
/** Throttle delay for touch events at normal zoom (ms) */
|
||||
THROTTLE_MS: 16,
|
||||
/** Throttle delay for touch events at high zoom (ms) */
|
||||
THROTTLE_MS_HIGH_ZOOM: 32,
|
||||
/** Maximum tap duration (ms) */
|
||||
TAP_DURATION: 300,
|
||||
/** Maximum tap movement distance (px) */
|
||||
TAP_DISTANCE: 10,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Viewport and performance constants
|
||||
*/
|
||||
export const VIEWPORT = {
|
||||
/** Base buffer around viewport for item culling (px) */
|
||||
BASE_BUFFER: 300,
|
||||
/** Minimum buffer size (px) */
|
||||
MIN_BUFFER: 100,
|
||||
/** Maximum visible items */
|
||||
MAX_VISIBLE_ITEMS: 100,
|
||||
/** Base visible items calculation factor */
|
||||
VISIBLE_ITEMS_FACTOR: 120,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Default zoom configuration
|
||||
*/
|
||||
export const ZOOM_DEFAULTS = {
|
||||
/** Minimum zoom level (40%) */
|
||||
MIN: 0.4,
|
||||
/** Maximum zoom level (220%) */
|
||||
MAX: 2.2,
|
||||
/** Zoom factor per step */
|
||||
FACTOR: 1.08,
|
||||
/** High zoom threshold for performance optimizations */
|
||||
HIGH_ZOOM_THRESHOLD: 1.5,
|
||||
} as const
|
||||
110
modules/infinite-canvas/index.ts
Normal file
110
modules/infinite-canvas/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @fileoverview Nuxt module for infinite canvas functionality
|
||||
*/
|
||||
|
||||
import { addComponent, addImports, createResolver, defineNuxtModule } from '@nuxt/kit'
|
||||
|
||||
/**
|
||||
* Module configuration options
|
||||
*/
|
||||
export interface ModuleOptions {
|
||||
/** Component name prefix (default: '') */
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Infinite Canvas Nuxt Module
|
||||
*
|
||||
* Provides components and composables for creating infinite, zoomable canvases
|
||||
* with drag interactions, touch support, and performance optimizations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // nuxt.config.ts
|
||||
* export default defineNuxtConfig({
|
||||
* modules: ['./modules/infinite-canvas']
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export default defineNuxtModule<ModuleOptions>({
|
||||
meta: {
|
||||
name: '@nuxt/infinite-canvas',
|
||||
configKey: 'infiniteCanvas',
|
||||
},
|
||||
defaults: {
|
||||
prefix: '',
|
||||
},
|
||||
setup(options, nuxt) {
|
||||
const resolver = createResolver(import.meta.url)
|
||||
|
||||
// Register components
|
||||
addComponent({
|
||||
name: `${options.prefix}Canvas`,
|
||||
filePath: resolver.resolve('./components/InfiniteCanvas.vue'),
|
||||
export: 'default',
|
||||
})
|
||||
|
||||
addComponent({
|
||||
name: `${options.prefix}CanvasMinimap`,
|
||||
filePath: resolver.resolve('./components/CanvasMinimap.vue'),
|
||||
export: 'default',
|
||||
})
|
||||
|
||||
addComponent({
|
||||
name: `${options.prefix}CanvasLoader`,
|
||||
filePath: resolver.resolve('./components/CanvasLoader.vue'),
|
||||
export: 'default',
|
||||
})
|
||||
|
||||
// Register composables
|
||||
addImports([
|
||||
{
|
||||
name: 'useInfiniteCanvas',
|
||||
from: resolver.resolve('./composables/useInfiniteCanvas'),
|
||||
as: 'useInfiniteCanvas',
|
||||
},
|
||||
{
|
||||
name: 'useImagePreloader',
|
||||
from: resolver.resolve('./composables/useImagePreloader'),
|
||||
as: 'useImagePreloader',
|
||||
},
|
||||
])
|
||||
|
||||
// Register utilities (optional, for advanced usage)
|
||||
addImports([
|
||||
{
|
||||
name: 'getTouchDistance',
|
||||
from: resolver.resolve('./utils'),
|
||||
as: 'getTouchDistance',
|
||||
},
|
||||
{
|
||||
name: 'getTouchCenter',
|
||||
from: resolver.resolve('./utils'),
|
||||
as: 'getTouchCenter',
|
||||
},
|
||||
{
|
||||
name: 'isVideo',
|
||||
from: resolver.resolve('./utils'),
|
||||
as: 'isVideo',
|
||||
},
|
||||
{
|
||||
name: 'isMobileDevice',
|
||||
from: resolver.resolve('./utils'),
|
||||
as: 'isMobileDevice',
|
||||
},
|
||||
])
|
||||
|
||||
// Add type definitions
|
||||
nuxt.hook('prepare:types', (options) => {
|
||||
options.references.push({
|
||||
path: resolver.resolve('./types'),
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
declare module '@nuxt/schema' {
|
||||
interface NuxtConfig {
|
||||
infiniteCanvas?: ModuleOptions
|
||||
}
|
||||
}
|
||||
221
modules/infinite-canvas/types/index.ts
Normal file
221
modules/infinite-canvas/types/index.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @fileoverview Type definitions for the Infinite Canvas module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Basic item that can be displayed on the canvas
|
||||
*/
|
||||
export interface CanvasItem {
|
||||
/** URL to the image or video */
|
||||
image: string
|
||||
/** Display title */
|
||||
title: string
|
||||
/** External link URL */
|
||||
link: string
|
||||
/** Item width in pixels (optional, defaults to 300) */
|
||||
width?: number
|
||||
/** Item height in pixels (optional, defaults to 300) */
|
||||
height?: number
|
||||
/** Additional properties */
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 2D coordinate position
|
||||
*/
|
||||
export interface Position {
|
||||
/** X coordinate */
|
||||
x: number
|
||||
/** Y coordinate */
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid item with position and dimensions
|
||||
*/
|
||||
export interface GridItem {
|
||||
/** Position on the canvas */
|
||||
position: Position
|
||||
/** Index in the original items array */
|
||||
index: number
|
||||
/** Item width in pixels */
|
||||
width: number
|
||||
/** Item height in pixels */
|
||||
height: number
|
||||
/** Whether the item is currently visible (computed) */
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom configuration options
|
||||
*/
|
||||
export interface ZoomOptions {
|
||||
/** Minimum zoom level (default: 0.4) */
|
||||
minZoom?: number
|
||||
/** Maximum zoom level (default: 2.2) */
|
||||
maxZoom?: number
|
||||
/** Zoom factor per step (default: 1.08) */
|
||||
zoomFactor?: number
|
||||
/** Enable Ctrl key for zooming (default: true) */
|
||||
enableCtrl?: boolean
|
||||
/** Enable Meta key for zooming (default: true) */
|
||||
enableMeta?: boolean
|
||||
/** Enable Alt key for zooming (default: true) */
|
||||
enableAlt?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas boundaries and dimensions
|
||||
*/
|
||||
export interface CanvasBounds {
|
||||
/** Canvas width in pixels */
|
||||
width: number
|
||||
/** Canvas height in pixels */
|
||||
height: number
|
||||
/** Center X coordinate (computed) */
|
||||
centerX?: number
|
||||
/** Center Y coordinate (computed) */
|
||||
centerY?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Container dimensions
|
||||
*/
|
||||
export interface ContainerDimensions {
|
||||
/** Container width in pixels */
|
||||
width: number
|
||||
/** Container height in pixels */
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the useInfiniteCanvas composable
|
||||
*/
|
||||
export interface UseInfiniteCanvasOptions {
|
||||
/** Array of items to display */
|
||||
items: CanvasItem[]
|
||||
/** Minimum gap between items in pixels (default: 40) */
|
||||
baseGap?: number
|
||||
/** Zoom configuration */
|
||||
zoomOptions?: ZoomOptions
|
||||
/** Reference to the container element */
|
||||
containerRef: Ref<HTMLElement | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the useInfiniteCanvas composable
|
||||
*/
|
||||
export interface UseInfiniteCanvasReturn {
|
||||
/** Current canvas offset */
|
||||
offset: Readonly<Ref<Position>>
|
||||
/** Current zoom level */
|
||||
zoom: Readonly<Ref<number>>
|
||||
/** Currently visible items */
|
||||
visibleItems: ComputedRef<GridItem[]>
|
||||
/** All positioned grid items */
|
||||
gridItems: ComputedRef<GridItem[]>
|
||||
/** Container dimensions */
|
||||
containerDimensions: Readonly<Ref<ContainerDimensions>>
|
||||
/** Canvas boundaries */
|
||||
canvasBounds: Readonly<Ref<CanvasBounds>>
|
||||
/** Whether clicks are allowed (not dragging) */
|
||||
canClick: Readonly<Ref<boolean>>
|
||||
/** Update container dimensions */
|
||||
updateDimensions: () => void
|
||||
/** Handle pointer down events */
|
||||
handlePointerDown: (clientX: number, clientY: number) => void
|
||||
/** Handle pointer move events */
|
||||
handlePointerMove: (clientX: number, clientY: number) => void
|
||||
/** Handle pointer up events */
|
||||
handlePointerUp: (clientX: number, clientY: number) => void
|
||||
/** Handle wheel events */
|
||||
handleWheel: (event: WheelEvent) => void
|
||||
/** Handle touch start events */
|
||||
handleTouchStart: (event: TouchEvent) => void
|
||||
/** Handle touch move events */
|
||||
handleTouchMove: (event: TouchEvent) => void
|
||||
/** Handle touch end events */
|
||||
handleTouchEnd: (event: TouchEvent) => void
|
||||
/** Navigate to a specific position */
|
||||
navigateTo: (position: Position) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the image preloader
|
||||
*/
|
||||
export interface ImagePreloaderOptions {
|
||||
/** Array of image/video URLs to preload */
|
||||
images: string[]
|
||||
/** Progress callback (0-1) */
|
||||
onProgress?: (progress: number) => void
|
||||
/** Completion callback */
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the useImagePreloader composable
|
||||
*/
|
||||
export interface UseImagePreloaderReturn {
|
||||
/** Loading progress (0-1) */
|
||||
progress: Readonly<Ref<number>>
|
||||
/** Number of loaded items */
|
||||
loadedCount: Readonly<Ref<number>>
|
||||
/** Whether currently loading */
|
||||
isLoading: Readonly<Ref<boolean>>
|
||||
/** Whether loading is complete */
|
||||
isComplete: Readonly<Ref<boolean>>
|
||||
/** Start the preloading process */
|
||||
startPreloading: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the minimap component
|
||||
*/
|
||||
export interface MinimapProps {
|
||||
/** Canvas items */
|
||||
items: CanvasItem[]
|
||||
/** Grid items with positions */
|
||||
gridItems: GridItem[]
|
||||
/** Current canvas offset */
|
||||
offset: Position
|
||||
/** Current zoom level */
|
||||
zoom: number
|
||||
/** Container dimensions */
|
||||
containerDimensions: ContainerDimensions
|
||||
/** Canvas boundaries */
|
||||
canvasBounds: CanvasBounds
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the loader component
|
||||
*/
|
||||
export interface LoaderProps {
|
||||
/** Loading progress (0-1) */
|
||||
progress: number
|
||||
/** Whether the loader is visible */
|
||||
isVisible: boolean
|
||||
/** Optional title */
|
||||
title?: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the InfiniteCanvas component
|
||||
*/
|
||||
export interface InfiniteCanvasProps<T = any> {
|
||||
/** Array of items to display */
|
||||
items: T[]
|
||||
/** Minimum gap between items (default: 40) */
|
||||
baseGap?: number
|
||||
/** Zoom configuration */
|
||||
zoomOptions?: ZoomOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the InfiniteCanvas component
|
||||
*/
|
||||
export interface InfiniteCanvasEmits<T = any> {
|
||||
/** Emitted when an item is clicked */
|
||||
itemClick: [item: T, index: number]
|
||||
}
|
||||
73
modules/infinite-canvas/utils/index.ts
Normal file
73
modules/infinite-canvas/utils/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Calculates the distance between two touch points
|
||||
* @param touch1 First touch point
|
||||
* @param touch2 Second touch point
|
||||
* @returns Distance in pixels
|
||||
*/
|
||||
export function getTouchDistance(touch1: Touch, touch2: Touch): number {
|
||||
const dx = touch1.clientX - touch2.clientX
|
||||
const dy = touch1.clientY - touch2.clientY
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the center point between two touches
|
||||
* @param touch1 First touch point
|
||||
* @param touch2 Second touch point
|
||||
* @returns Center coordinates
|
||||
*/
|
||||
export function getTouchCenter(touch1: Touch, touch2: Touch): { x: number, y: number } {
|
||||
return {
|
||||
x: (touch1.clientX + touch2.clientX) / 2,
|
||||
y: (touch1.clientY + touch2.clientY) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled version of a function
|
||||
* @param func Function to throttle
|
||||
* @param delay Delay in milliseconds
|
||||
* @returns Throttled function
|
||||
*/
|
||||
export function throttle<T extends(...args: any[]) => any>(func: T, delay: number): T {
|
||||
let lastCall = 0
|
||||
return ((...args: any[]) => {
|
||||
const now = Date.now()
|
||||
if (now - lastCall >= delay) {
|
||||
lastCall = now
|
||||
return func(...args)
|
||||
}
|
||||
}) as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a file URL is a video based on its extension
|
||||
* @param url File URL to check
|
||||
* @returns True if the file is a video
|
||||
*/
|
||||
export function isVideo(url: string): boolean {
|
||||
const extension = url.split('.').pop()?.toLowerCase()
|
||||
return ['mp4', 'webm', 'mov'].includes(extension || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if the current device is mobile
|
||||
* @param userAgent Navigator user agent string
|
||||
* @param windowWidth Current window width
|
||||
* @returns True if device is mobile
|
||||
*/
|
||||
export function isMobileDevice(userAgent: string = '', windowWidth: number = 0): boolean {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent)
|
||||
|| windowWidth <= 768
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps a value between min and max
|
||||
* @param value Value to clamp
|
||||
* @param min Minimum value
|
||||
* @param max Maximum value
|
||||
* @returns Clamped value
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
59
modules/screenshots.ts
Normal file
59
modules/screenshots.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable no-console */
|
||||
import { existsSync } from 'node:fs'
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
import captureWebsite from 'capture-website'
|
||||
import { join } from 'pathe'
|
||||
|
||||
interface ContentFile {
|
||||
id?: string
|
||||
body?: {
|
||||
items: TemplateItem[]
|
||||
}
|
||||
}
|
||||
|
||||
interface TemplateItem {
|
||||
name: string
|
||||
url?: string
|
||||
screenshotUrl?: string
|
||||
screenshotOptions?: Record<string, any>
|
||||
}
|
||||
|
||||
export default defineNuxtModule((_, nuxt) => {
|
||||
nuxt.hook('content:file:afterParse', async ({ content: file }: { content: ContentFile }) => {
|
||||
if (file.id?.includes('works/')) {
|
||||
const template = file as TemplateItem
|
||||
const url = template.screenshotUrl || template.url
|
||||
if (!url) {
|
||||
console.error(`Work ${template.name} has no "url" or "screenshotUrl" to take a screenshot from`)
|
||||
return
|
||||
}
|
||||
if (template.screenshotUrl)
|
||||
return
|
||||
|
||||
const name = template.name.toLowerCase().replace(/\s/g, '-')
|
||||
// eslint-disable-next-line node/prefer-global/process
|
||||
const filename = join(process.cwd(), 'public/assets/works', `${name}.png`)
|
||||
|
||||
if (existsSync(filename))
|
||||
return
|
||||
|
||||
console.log(`Generating screenshot for work ${template.name} hitting ${url}...`)
|
||||
|
||||
try {
|
||||
await captureWebsite.file(url, filename, {
|
||||
...(template.screenshotOptions || {
|
||||
darkMode: true,
|
||||
}),
|
||||
launchOptions: {
|
||||
headless: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Screenshot for ${template.name} generated successfully`)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error generating screenshot for ${template.name}:`, error)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user