/** * @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({ x: 0, y: 0 }) const velocity = ref({ 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({ x: 0, y: 0 }) const dragStartOffset = ref({ 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({ 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(() => { 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([]) 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, } }