import { useEffect, useRef, useCallback } from 'react'; import { useCanvasStore } from '@/store'; interface TouchGestureOptions { onPinchZoom?: (scale: number, centerX: number, centerY: number) => 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]); }