'use client' import { useCallback, useRef, useState } from 'react'; export interface ZoomState { scale: number x: number y: number } export interface UseZoomOptions { minScale?: number maxScale?: number desktopScale?: number onZoomChange?: (zoomed: boolean) => void } export interface UseZoomReturn { state: ZoomState isZoomed: boolean isDragging: boolean handlers: { onTouchStart: (e: React.TouchEvent) => void onTouchMove: (e: React.TouchEvent) => void onTouchEnd: (e: React.TouchEvent) => void onClick: (e: React.MouseEvent) => void onMouseDown: (e: React.MouseEvent) => void onMouseMove: (e: React.MouseEvent) => void onMouseUp: () => void onMouseLeave: () => void } reset: () => void zoomTo: (scale: number, x?: number, y?: number) => void toggleZoom: () => void } const INITIAL_STATE: ZoomState = { scale: 1, x: 0, y: 0 } const ZOOM_THRESHOLD = 1.05 /** * Unified zoom hook for both desktop (click/drag) and mobile (pinch/tap) */ export function useZoom(options: UseZoomOptions = {}): UseZoomReturn { const { minScale = 1, maxScale = 3, desktopScale = 2, onZoomChange } = options const [state, setState] = useState(INITIAL_STATE) const [isDragging, setIsDragging] = useState(false) const touchRef = useRef<{ // Touch tracking initialDistance: number initialScale: number initialX: number initialY: number centerX: number centerY: number isPinching: boolean lastTap: number // Container dimensions containerWidth: number containerHeight: number // Mouse drag tracking dragStartX: number dragStartY: number }>({ initialDistance: 0, initialScale: 1, initialX: 0, initialY: 0, centerX: 0, centerY: 0, isPinching: false, lastTap: 0, containerWidth: 0, containerHeight: 0, dragStartX: 0, dragStartY: 0, }) const isZoomed = state.scale > ZOOM_THRESHOLD // Calculate distance between two touch points const getDistance = useCallback((touches: React.TouchList): number => { if (touches.length < 2) return 0 const dx = touches[0].clientX - touches[1].clientX const dy = touches[0].clientY - touches[1].clientY return Math.sqrt(dx * dx + dy * dy) }, []) // Get center point between two touches const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => { if (touches.length < 2) { return { x: touches[0].clientX, y: touches[0].clientY } } return { x: (touches[0].clientX + touches[1].clientX) / 2, y: (touches[0].clientY + touches[1].clientY) / 2, } }, []) // Calculate max pan based on container size and scale const getMaxPan = useCallback((scale: number) => { const { containerWidth, containerHeight } = touchRef.current return { x: (containerWidth * (scale - 1)) / 2, y: (containerHeight * (scale - 1)) / 2, } }, []) // Clamp pan position within bounds const clampPan = useCallback((x: number, y: number, scale: number) => { const maxPan = getMaxPan(scale) return { x: Math.max(-maxPan.x, Math.min(maxPan.x, x)), y: Math.max(-maxPan.y, Math.min(maxPan.y, y)), } }, [getMaxPan]) // ========== TOUCH HANDLERS (MOBILE) ========== const handleTouchStart = useCallback((e: React.TouchEvent) => { const touches = e.touches const rect = e.currentTarget.getBoundingClientRect() touchRef.current.containerWidth = rect.width touchRef.current.containerHeight = rect.height // Double tap to zoom if (touches.length === 1) { const now = Date.now() const timeSinceLastTap = now - touchRef.current.lastTap if (timeSinceLastTap < 300) { e.preventDefault() if (isZoomed) { setState(INITIAL_STATE) onZoomChange?.(false) } else { const x = touches[0].clientX - rect.left - rect.width / 2 const y = touches[0].clientY - rect.top - rect.height / 2 setState({ scale: desktopScale, x: -x * 0.5, y: -y * 0.5 }) onZoomChange?.(true) } touchRef.current.lastTap = 0 return } touchRef.current.lastTap = now // Single finger - start pan (if zoomed) if (isZoomed) { touchRef.current.initialX = touches[0].clientX - state.x touchRef.current.initialY = touches[0].clientY - state.y } } // Two finger pinch if (touches.length === 2) { e.preventDefault() touchRef.current.isPinching = true touchRef.current.initialDistance = getDistance(touches) touchRef.current.initialScale = state.scale const center = getCenter(touches) touchRef.current.centerX = center.x touchRef.current.centerY = center.y touchRef.current.initialX = state.x touchRef.current.initialY = state.y } }, [isZoomed, state, getDistance, getCenter, desktopScale, onZoomChange]) const handleTouchMove = useCallback((e: React.TouchEvent) => { const touches = e.touches // Pinch zoom if (touches.length === 2 && touchRef.current.isPinching) { e.preventDefault() const currentDistance = getDistance(touches) const scaleDelta = currentDistance / touchRef.current.initialDistance let newScale = touchRef.current.initialScale * scaleDelta newScale = Math.max(minScale, Math.min(maxScale, newScale)) const center = getCenter(touches) const dx = center.x - touchRef.current.centerX const dy = center.y - touchRef.current.centerY const { x: clampedX, y: clampedY } = clampPan( touchRef.current.initialX + dx, touchRef.current.initialY + dy, newScale ) setState({ scale: newScale, x: clampedX, y: clampedY }) if (newScale > ZOOM_THRESHOLD !== isZoomed) { onZoomChange?.(newScale > ZOOM_THRESHOLD) } } // Pan when zoomed (single finger) if (touches.length === 1 && isZoomed && !touchRef.current.isPinching) { e.preventDefault() const newX = touches[0].clientX - touchRef.current.initialX const newY = touches[0].clientY - touchRef.current.initialY const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale) setState((prev) => ({ ...prev, x: clampedX, y: clampedY })) } }, [isZoomed, state.scale, minScale, maxScale, getDistance, getCenter, clampPan, onZoomChange]) const handleTouchEnd = useCallback((e: React.TouchEvent) => { if (e.touches.length < 2) { touchRef.current.isPinching = false } // Snap to 1 if close if (state.scale < 1.1 && state.scale > 0.9) { setState(INITIAL_STATE) onZoomChange?.(false) } }, [state.scale, onZoomChange]) // ========== MOUSE HANDLERS (DESKTOP) ========== const handleClick = useCallback((e: React.MouseEvent) => { // Skip if we were dragging if (isDragging) return const rect = e.currentTarget.getBoundingClientRect() touchRef.current.containerWidth = rect.width touchRef.current.containerHeight = rect.height if (isZoomed) { setState(INITIAL_STATE) onZoomChange?.(false) } else { // Zoom to click position const x = ((e.clientX - rect.left) / rect.width - 0.5) * -100 const y = ((e.clientY - rect.top) / rect.height - 0.5) * -100 setState({ scale: desktopScale, x, y }) onZoomChange?.(true) } }, [isZoomed, isDragging, desktopScale, onZoomChange]) const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!isZoomed) return e.preventDefault() setIsDragging(true) touchRef.current.dragStartX = e.clientX - state.x touchRef.current.dragStartY = e.clientY - state.y const rect = e.currentTarget.getBoundingClientRect() touchRef.current.containerWidth = rect.width touchRef.current.containerHeight = rect.height }, [isZoomed, state.x, state.y]) const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!isDragging) return const newX = e.clientX - touchRef.current.dragStartX const newY = e.clientY - touchRef.current.dragStartY const { x: clampedX, y: clampedY } = clampPan(newX, newY, state.scale) setState((prev) => ({ ...prev, x: clampedX, y: clampedY })) }, [isDragging, state.scale, clampPan]) const handleMouseUp = useCallback(() => { // Small delay to prevent click from triggering after drag setTimeout(() => setIsDragging(false), 10) }, []) // ========== COMMON ACTIONS ========== const reset = useCallback(() => { setState(INITIAL_STATE) setIsDragging(false) onZoomChange?.(false) }, [onZoomChange]) const zoomTo = useCallback((scale: number, x = 0, y = 0) => { const clampedScale = Math.max(minScale, Math.min(maxScale, scale)) const { x: clampedX, y: clampedY } = clampPan(x, y, clampedScale) setState({ scale: clampedScale, x: clampedX, y: clampedY }) onZoomChange?.(clampedScale > ZOOM_THRESHOLD) }, [minScale, maxScale, clampPan, onZoomChange]) const toggleZoom = useCallback(() => { if (isZoomed) { setState(INITIAL_STATE) onZoomChange?.(false) } else { setState({ scale: desktopScale, x: 0, y: 0 }) onZoomChange?.(true) } }, [isZoomed, desktopScale, onZoomChange]) return { state, isZoomed, isDragging, handlers: { onTouchStart: handleTouchStart, onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, onClick: handleClick, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, }, reset, zoomTo, toggleZoom, } }