Added comprehensive touch support for mobile/tablet usage: Touch Gestures Hook: - Created useTouchGestures hook for pinch-to-zoom and two-finger pan - Handles multi-touch events with distance calculation - Integrated with canvas store for zoom and pan state - Prevents default touch behaviors (pull-to-refresh, page scroll) Features: - Pinch-to-zoom: Two-finger pinch gesture for zoom in/out - Two-finger pan: Pan canvas with two fingers - Touch drawing: Single touch works for all drawing tools (pointer events already supported) - Min/max zoom limits (0.1x - 32x) - Smooth gesture handling with distance thresholds UI Improvements: - Added touch-action: none CSS to prevent default touch behaviors - Added touch-none Tailwind class for better touch handling - Canvas container properly handles touch events Mobile Experience: - Drawing tools work with single touch - Zoom/pan gestures feel natural - No interference with browser touch behaviors - Optimized for tablets and touch-enabled devices 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
153 lines
4.4 KiB
TypeScript
153 lines
4.4 KiB
TypeScript
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<HTMLElement | null>,
|
|
options: TouchGestureOptions = {}
|
|
) {
|
|
const {
|
|
onPinchZoom,
|
|
onTwoFingerPan,
|
|
minScale = 0.1,
|
|
maxScale = 32,
|
|
} = options;
|
|
|
|
const { setZoom, setPanOffset, zoom } = useCanvasStore();
|
|
|
|
const lastTouches = useRef<TouchPoint[]>([]);
|
|
const initialDistance = useRef<number>(0);
|
|
const initialZoom = useRef<number>(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]);
|
|
}
|