Files
paint-ui/hooks/use-touch-gestures.ts
Sebastian Krüger 513b865b1f 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>
2025-11-21 16:48:19 +01:00

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]);
}