import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react'; export type Pan = { translateX: number; translateY: number; }; const INITIAL_PAN = { translateX: 0, translateY: 0, }; export const usePan = (active: boolean) => { const [pan, setPan] = useState(INITIAL_PAN); const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>( active ? 'grab' : 'initial', ); // Track the exact handler references that were passed to addEventListener so // we can remove them even if the component re-renders or unmounts mid-drag. const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( null, ); const touchAttachedRef = useRef<{ move: (e: TouchEvent) => void; end: (e: TouchEvent) => void; } | null>(null); useEffect(() => { setCursor(active ? 'grab' : 'initial'); }, [active]); const handleMouseDown: MouseEventHandler = (evt) => { if (!active) return; evt.preventDefault(); setCursor('grabbing'); const handleMouseMove = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setPan((p) => ({ translateX: p.translateX + e.movementX, translateY: p.translateY + e.movementY, })); }; const handleMouseUp = (e: MouseEvent) => { e.preventDefault(); setCursor('grab'); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); attachedRef.current = null; }; attachedRef.current = { move: handleMouseMove, up: handleMouseUp }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Touch equivalent so a zoomed image can be dragged on a phone. Single-finger // only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we // derive the delta from the previous touch position. const handleTouchStart: TouchEventHandler = (evt) => { if (!active || evt.touches.length !== 1) return; setCursor('grabbing'); let lastX = evt.touches[0].clientX; let lastY = evt.touches[0].clientY; const handleTouchMove = (e: TouchEvent) => { if (e.touches.length !== 1) return; e.preventDefault(); const t = e.touches[0]; const dx = t.clientX - lastX; const dy = t.clientY - lastY; lastX = t.clientX; lastY = t.clientY; setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy })); }; const handleTouchEnd = () => { setCursor('grab'); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); document.removeEventListener('touchcancel', handleTouchEnd); touchAttachedRef.current = null; }; touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd }; document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleTouchEnd); document.addEventListener('touchcancel', handleTouchEnd); }; useEffect(() => { if (!active) setPan(INITIAL_PAN); }, [active]); // Remove listeners if the component unmounts while a drag is in progress. useEffect( () => () => { if (attachedRef.current) { document.removeEventListener('mousemove', attachedRef.current.move); document.removeEventListener('mouseup', attachedRef.current.up); attachedRef.current = null; } if (touchAttachedRef.current) { document.removeEventListener('touchmove', touchAttachedRef.current.move); document.removeEventListener('touchend', touchAttachedRef.current.end); document.removeEventListener('touchcancel', touchAttachedRef.current.end); touchAttachedRef.current = null; } }, [], ); return { pan, cursor, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, }; };