diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index a4d4e56..35cc9f3 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -10,6 +10,7 @@ import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils'; import { renderText } from '@/lib/text-utils'; import { DrawCommand } from '@/core/commands'; import { getTool, preloadCommonTools } from '@/lib/tool-loader'; +import { useTouchGestures } from '@/hooks/use-touch-gestures'; import type { BaseTool } from '@/tools'; import type { PointerState } from '@/types'; import { cn } from '@/lib/utils'; @@ -52,6 +53,12 @@ export function CanvasWithTools() { pressure: 1, }); + // Touch gesture support for mobile + useTouchGestures(containerRef, { + minScale: 0.1, + maxScale: 32, + }); + // Helper to get tool (lazy load if not in cache) const getToolInstance = useCallback(async (toolKey: string): Promise => { if (toolsCache.current[toolKey]) { @@ -418,9 +425,10 @@ export function CanvasWithTools() {
void; + onTwoFingerPan?: (deltaX: number, deltaY: number) => void; + minScale?: number; + maxScale?: number; +} + +interface TouchPoint { + x: number; + y: number; +} + +/** + * Hook to handle touch gestures (pinch-to-zoom, two-finger pan) + */ +export function useTouchGestures( + elementRef: React.RefObject, + options: TouchGestureOptions = {} +) { + const { + onPinchZoom, + onTwoFingerPan, + minScale = 0.1, + maxScale = 32, + } = options; + + const { setZoom, setPanOffset, zoom } = useCanvasStore(); + + const lastTouches = useRef([]); + const initialDistance = useRef(0); + const initialZoom = useRef(1); + + const getTouchPoints = useCallback((touches: TouchList): TouchPoint[] => { + const points: TouchPoint[] = []; + for (let i = 0; i < touches.length; i++) { + points.push({ + x: touches[i].clientX, + y: touches[i].clientY, + }); + } + return points; + }, []); + + const getDistance = useCallback((p1: TouchPoint, p2: TouchPoint): number => { + const dx = p2.x - p1.x; + const dy = p2.y - p1.y; + return Math.sqrt(dx * dx + dy * dy); + }, []); + + const getCenter = useCallback((p1: TouchPoint, p2: TouchPoint): TouchPoint => { + return { + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + }; + }, []); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (e.touches.length === 2) { + // Two-finger gesture starting + e.preventDefault(); + + const touches = getTouchPoints(e.touches); + lastTouches.current = touches; + initialDistance.current = getDistance(touches[0], touches[1]); + initialZoom.current = zoom; + } + }, + [getTouchPoints, getDistance, zoom] + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (e.touches.length === 2) { + // Pinch-to-zoom and two-finger pan + e.preventDefault(); + + const touches = getTouchPoints(e.touches); + + if (lastTouches.current.length === 2) { + // Calculate zoom + const currentDistance = getDistance(touches[0], touches[1]); + const scaleChange = currentDistance / initialDistance.current; + const newZoom = Math.max( + minScale, + Math.min(maxScale, initialZoom.current * scaleChange) + ); + + // Get center point for zoom + const center = getCenter(touches[0], touches[1]); + + if (onPinchZoom) { + onPinchZoom(newZoom, center.x, center.y); + } else { + setZoom(newZoom); + } + + // Calculate pan + const lastCenter = getCenter(lastTouches.current[0], lastTouches.current[1]); + const deltaX = center.x - lastCenter.x; + const deltaY = center.y - lastCenter.y; + + if (onTwoFingerPan && (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1)) { + onTwoFingerPan(deltaX, deltaY); + } else if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { + const { offsetX, offsetY } = useCanvasStore.getState(); + setPanOffset(offsetX + deltaX, offsetY + deltaY); + } + } + + lastTouches.current = touches; + } + }, + [ + getTouchPoints, + getDistance, + getCenter, + minScale, + maxScale, + onPinchZoom, + onTwoFingerPan, + setZoom, + setPanOffset, + ] + ); + + const handleTouchEnd = useCallback(() => { + lastTouches.current = []; + initialDistance.current = 0; + }, []); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + // Add touch event listeners with passive: false to allow preventDefault + element.addEventListener('touchstart', handleTouchStart, { passive: false }); + element.addEventListener('touchmove', handleTouchMove, { passive: false }); + element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('touchcancel', handleTouchEnd); + + return () => { + element.removeEventListener('touchstart', handleTouchStart); + element.removeEventListener('touchmove', handleTouchMove); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('touchcancel', handleTouchEnd); + }; + }, [elementRef, handleTouchStart, handleTouchMove, handleTouchEnd]); +}