/** * Mobile Gesture Utilities * Provides touch gesture detection and handling */ export interface GestureEvent { type: 'swipe' | 'pinch' | 'rotate' | 'tap' | 'longpress' | 'doubletap'; direction?: 'up' | 'down' | 'left' | 'right'; scale?: number; rotation?: number; center?: { x: number; y: number }; velocity?: { x: number; y: number }; } export interface GestureHandlers { onSwipe?: (direction: 'up' | 'down' | 'left' | 'right', velocity: number) => void; onPinch?: (scale: number) => void; onRotate?: (angle: number) => void; onTap?: (x: number, y: number) => void; onLongPress?: (x: number, y: number) => void; onDoubleTap?: (x: number, y: number) => void; } export interface GestureConfig { swipeThreshold?: number; swipeVelocityThreshold?: number; longPressDelay?: number; doubleTapDelay?: number; pinchThreshold?: number; } const defaultConfig: Required = { swipeThreshold: 50, swipeVelocityThreshold: 0.3, longPressDelay: 500, doubleTapDelay: 300, pinchThreshold: 0.1, }; export function createGestureHandler( element: HTMLElement, handlers: GestureHandlers, config: GestureConfig = {} ): () => void { const cfg = { ...defaultConfig, ...config }; let startX = 0; let startY = 0; let startTime = 0; let longPressTimer: NodeJS.Timeout | null = null; let lastTapTime = 0; let initialDistance = 0; let initialAngle = 0; const getDistance = (touches: TouchList): number => { if (touches.length < 2) return 0; const dx = touches[1].clientX - touches[0].clientX; const dy = touches[1].clientY - touches[0].clientY; return Math.sqrt(dx * dx + dy * dy); }; const getAngle = (touches: TouchList): number => { if (touches.length < 2) return 0; const dx = touches[1].clientX - touches[0].clientX; const dy = touches[1].clientY - touches[0].clientY; return (Math.atan2(dy, dx) * 180) / Math.PI; }; const handleTouchStart = (e: TouchEvent) => { const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; startTime = Date.now(); // Long press detection if (handlers.onLongPress) { longPressTimer = setTimeout(() => { handlers.onLongPress!(startX, startY); // Vibrate on long press if available if ('vibrate' in navigator) { navigator.vibrate(50); } }, cfg.longPressDelay); } // Pinch/rotate initialization if (e.touches.length === 2) { initialDistance = getDistance(e.touches); initialAngle = getAngle(e.touches); } }; const handleTouchMove = (e: TouchEvent) => { // Cancel long press on move if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } // Pinch detection if (e.touches.length === 2 && (handlers.onPinch || handlers.onRotate)) { const currentDistance = getDistance(e.touches); const currentAngle = getAngle(e.touches); if (handlers.onPinch && initialDistance > 0) { const scale = currentDistance / initialDistance; if (Math.abs(scale - 1) > cfg.pinchThreshold) { handlers.onPinch(scale); } } if (handlers.onRotate) { const rotation = currentAngle - initialAngle; handlers.onRotate(rotation); } } }; const handleTouchEnd = (e: TouchEvent) => { // Clear long press timer if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } const touch = e.changedTouches[0]; const endX = touch.clientX; const endY = touch.clientY; const endTime = Date.now(); const deltaX = endX - startX; const deltaY = endY - startY; const deltaTime = endTime - startTime; // Swipe detection if (handlers.onSwipe) { const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime; if (velocity > cfg.swipeVelocityThreshold) { if (Math.abs(deltaX) > Math.abs(deltaY)) { if (Math.abs(deltaX) > cfg.swipeThreshold) { handlers.onSwipe(deltaX > 0 ? 'right' : 'left', velocity); } } else { if (Math.abs(deltaY) > cfg.swipeThreshold) { handlers.onSwipe(deltaY > 0 ? 'down' : 'up', velocity); } } } } // Tap / Double tap detection if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) { const now = Date.now(); if (handlers.onDoubleTap && now - lastTapTime < cfg.doubleTapDelay) { handlers.onDoubleTap(endX, endY); lastTapTime = 0; } else if (handlers.onTap) { lastTapTime = now; // Delay tap to wait for potential double tap if (handlers.onDoubleTap) { setTimeout(() => { if (lastTapTime === now) { handlers.onTap!(endX, endY); } }, cfg.doubleTapDelay); } else { handlers.onTap(endX, endY); } } } }; const handleTouchCancel = () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }; // Add event listeners element.addEventListener('touchstart', handleTouchStart, { passive: true }); element.addEventListener('touchmove', handleTouchMove, { passive: true }); element.addEventListener('touchend', handleTouchEnd, { passive: true }); element.addEventListener('touchcancel', handleTouchCancel, { passive: true }); // Return cleanup function return () => { element.removeEventListener('touchstart', handleTouchStart); element.removeEventListener('touchmove', handleTouchMove); element.removeEventListener('touchend', handleTouchEnd); element.removeEventListener('touchcancel', handleTouchCancel); if (longPressTimer) { clearTimeout(longPressTimer); } }; } // React hook for gestures export function useGestures( ref: React.RefObject, handlers: GestureHandlers, config?: GestureConfig ): void { if (typeof window === 'undefined') return; const handlersRef = { current: handlers }; handlersRef.current = handlers; const element = ref.current; if (!element) return; // Note: In actual React usage, this should be inside a useEffect createGestureHandler(element, handlersRef.current, config); } // Haptic feedback utility export function triggerHaptic(type: 'light' | 'medium' | 'heavy' = 'light'): void { if (!('vibrate' in navigator)) return; const patterns: Record = { light: 10, medium: 25, heavy: [50, 50, 50], }; navigator.vibrate(patterns[type]); } // Prevent pull-to-refresh on specific elements export function preventPullToRefresh(element: HTMLElement): () => void { let startY = 0; const handleTouchStart = (e: TouchEvent) => { startY = e.touches[0].clientY; }; const handleTouchMove = (e: TouchEvent) => { const currentY = e.touches[0].clientY; const scrollTop = element.scrollTop; // Prevent if at top and pulling down if (scrollTop === 0 && currentY > startY) { e.preventDefault(); } }; element.addEventListener('touchstart', handleTouchStart, { passive: true }); element.addEventListener('touchmove', handleTouchMove, { passive: false }); return () => { element.removeEventListener('touchstart', handleTouchStart); element.removeEventListener('touchmove', handleTouchMove); }; }