import { useEffect, useRef, useState, type RefObject } from 'react'; import { THROW_MIN_DISTANCE, createVelocityTracker, runInertia } from '@/lib/momentum'; const ZOOM_MIN = 1; const ZOOM_MAX = 8; const SWIPE_THRESHOLD = 80; // px of horizontal flick (at zoom ~1) to navigate const CLICK_BUFFER = 6; // px of travel under which a press counts as a tap type Options = { resetKey: unknown; onSwipe?: (dir: -1 | 1) => void; onTap?: () => void; }; export type PanZoom = { zoom: number; offset: { x: number; y: number }; reframing: boolean; handlers: { onWheel: (e: React.WheelEvent) => void; onPointerDown: (e: React.PointerEvent) => void; onPointerMove: (e: React.PointerEvent) => void; onPointerUp: (e: React.PointerEvent) => void; onClick: (e: React.MouseEvent) => void; }; }; /** * Pan + pinch-zoom + wheel-zoom + release momentum for a CSS-transformed * element. Pure gesture mechanics built on `lib/momentum` — the consumer * supplies `onSwipe` for un-zoomed horizontal flicks and `onTap` for plain * presses, and applies the returned `zoom`/`offset` as a transform. */ export function usePanZoom( containerRef: RefObject, { resetKey, onSwipe, onTap }: Options, ): PanZoom { const [zoom, setZoom] = useState(1); const [offset, setOffset] = useState({ x: 0, y: 0 }); const [reframing, setReframing] = useState(false); const reframeTimerRef = useRef(null); const reframe = () => { setZoom(ZOOM_MIN); setOffset({ x: 0, y: 0 }); setReframing(true); if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); reframeTimerRef.current = window.setTimeout(() => setReframing(false), 260); }; const pointersRef = useRef(new Map()); const downPosRef = useRef<{ x: number; y: number } | null>(null); const panStartRef = useRef<{ ox: number; oy: number } | null>(null); const pinchStartRef = useRef<{ d: number; z: number; ox: number; oy: number; cx: number; cy: number } | null>(null); const velocity = useRef(createVelocityTracker()); const cancelInertiaRef = useRef<(() => void) | null>(null); const zoomRef = useRef(zoom); const offsetRef = useRef(offset); useEffect(() => { zoomRef.current = zoom; }, [zoom]); useEffect(() => { offsetRef.current = offset; }, [offset]); const stopInertia = () => { cancelInertiaRef.current?.(); cancelInertiaRef.current = null; }; const [prevKey, setPrevKey] = useState(resetKey); if (prevKey !== resetKey) { setPrevKey(resetKey); setZoom(ZOOM_MIN); setOffset({ x: 0, y: 0 }); setReframing(false); } useEffect(() => { return () => { stopInertia(); if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); }; }, [resetKey]); useEffect(() => () => { stopInertia(); if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current); }, []); const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => Math.hypot(a.x - b.x, a.y - b.y); const onWheel = (e: React.WheelEvent) => { e.preventDefault(); stopInertia(); const r = containerRef.current?.getBoundingClientRect(); if (!r) return; const mx = e.clientX - r.left - r.width / 2; const my = e.clientY - r.top - r.height / 2; const factor = Math.exp(-e.deltaY * 0.001); const z = zoomRef.current; const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z * factor)); // Hit the floor → reframe to the original framing. if (newZoom <= ZOOM_MIN + 0.001 && z > ZOOM_MIN + 0.001) { reframe(); return; } const k = newZoom / z - 1; setOffset(o => ({ x: o.x - mx * k, y: o.y - my * k })); setZoom(newZoom); }; const onPointerDown = (e: React.PointerEvent) => { if ((e.target as HTMLElement).closest('button')) return; stopInertia(); e.currentTarget.setPointerCapture(e.pointerId); const map = pointersRef.current; map.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY }); if (map.size === 1) { panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y }; pinchStartRef.current = null; downPosRef.current = { x: e.clientX, y: e.clientY }; velocity.current.reset(e.clientX, e.clientY); } else if (map.size === 2) { downPosRef.current = null; const [a, b] = [...map.values()]; const r = containerRef.current!.getBoundingClientRect(); pinchStartRef.current = { d: dist(a, b), z: zoomRef.current, ox: offsetRef.current.x, oy: offsetRef.current.y, cx: (a.x + b.x) / 2 - r.left - r.width / 2, cy: (a.y + b.y) / 2 - r.top - r.height / 2, }; panStartRef.current = null; } }; const onPointerMove = (e: React.PointerEvent) => { const map = pointersRef.current; const pt = map.get(e.pointerId); if (!pt) return; pt.x = e.clientX; pt.y = e.clientY; if (map.size === 1 && panStartRef.current) { const only = [...map.values()][0]; setOffset({ x: panStartRef.current.ox + (only.x - only.sx), y: panStartRef.current.oy + (only.y - only.sy), }); velocity.current.sample(e.clientX, e.clientY); } else if (map.size === 2 && pinchStartRef.current) { const [a, b] = [...map.values()]; const r = containerRef.current?.getBoundingClientRect(); if (!r) return; const start = pinchStartRef.current; const newD = dist(a, b); const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, start.z * newD / start.d)); const ncx = (a.x + b.x) / 2 - r.left - r.width / 2; const ncy = (a.y + b.y) / 2 - r.top - r.height / 2; const imgPx = (start.cx - start.ox) / start.z; const imgPy = (start.cy - start.oy) / start.z; setZoom(newZoom); setOffset({ x: ncx - newZoom * imgPx, y: ncy - newZoom * imgPy }); } }; const onPointerUp = (e: React.PointerEvent) => { const map = pointersRef.current; const pt = map.get(e.pointerId); if (!pt) return; const dx = pt.x - pt.sx; const dy = pt.y - pt.sy; map.delete(e.pointerId); if (map.size === 0) { if (zoomRef.current <= ZOOM_MIN + 0.001 && (offsetRef.current.x !== 0 || offsetRef.current.y !== 0)) { reframe(); } else if (panStartRef.current) { const moved = Math.hypot(dx, dy); if (zoomRef.current <= 1.05) { if (moved > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) { onSwipe?.(dx > 0 ? -1 : 1); } else { setOffset({ x: 0, y: 0 }); } } else if (moved > THROW_MIN_DISTANCE) { stopInertia(); cancelInertiaRef.current = runInertia(velocity.current.release(), (sdx, sdy) => setOffset(o => ({ x: o.x + sdx, y: o.y + sdy }))); } } panStartRef.current = null; pinchStartRef.current = null; } else if (map.size === 1) { const remaining = [...map.values()][0]; remaining.sx = remaining.x; remaining.sy = remaining.y; panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y }; pinchStartRef.current = null; velocity.current.reset(remaining.x, remaining.y); } }; const onClick = (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button')) return; const down = downPosRef.current; downPosRef.current = null; if (!down) return; if (Math.hypot(e.clientX - down.x, e.clientY - down.y) <= CLICK_BUFFER) onTap?.(); }; return { zoom, offset, reframing, handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp, onClick } }; }