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:
@@ -10,6 +10,7 @@ import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils';
|
|||||||
import { renderText } from '@/lib/text-utils';
|
import { renderText } from '@/lib/text-utils';
|
||||||
import { DrawCommand } from '@/core/commands';
|
import { DrawCommand } from '@/core/commands';
|
||||||
import { getTool, preloadCommonTools } from '@/lib/tool-loader';
|
import { getTool, preloadCommonTools } from '@/lib/tool-loader';
|
||||||
|
import { useTouchGestures } from '@/hooks/use-touch-gestures';
|
||||||
import type { BaseTool } from '@/tools';
|
import type { BaseTool } from '@/tools';
|
||||||
import type { PointerState } from '@/types';
|
import type { PointerState } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -52,6 +53,12 @@ export function CanvasWithTools() {
|
|||||||
pressure: 1,
|
pressure: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Touch gesture support for mobile
|
||||||
|
useTouchGestures(containerRef, {
|
||||||
|
minScale: 0.1,
|
||||||
|
maxScale: 32,
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to get tool (lazy load if not in cache)
|
// Helper to get tool (lazy load if not in cache)
|
||||||
const getToolInstance = useCallback(async (toolKey: string): Promise<BaseTool | null> => {
|
const getToolInstance = useCallback(async (toolKey: string): Promise<BaseTool | null> => {
|
||||||
if (toolsCache.current[toolKey]) {
|
if (toolsCache.current[toolKey]) {
|
||||||
@@ -418,9 +425,10 @@ export function CanvasWithTools() {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-full w-full overflow-hidden bg-canvas-bg',
|
'relative h-full w-full overflow-hidden bg-canvas-bg touch-none',
|
||||||
isPanning ? 'cursor-grabbing' : `cursor-${toolsCache.current[activeTool]?.getCursor(settings) || 'default'}`
|
isPanning ? 'cursor-grabbing' : `cursor-${toolsCache.current[activeTool]?.getCursor(settings) || 'default'}`
|
||||||
)}
|
)}
|
||||||
|
style={{ touchAction: 'none' }}
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
|
|||||||
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