feat(mobile): add touch gesture support for mobile devices
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>
This commit is contained in:
152
hooks/use-touch-gestures.ts
Normal file
152
hooks/use-touch-gestures.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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]);
|
||||
}
|
||||
Reference in New Issue
Block a user