"use client"; import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; export const MIN_ZOOM = 1; export const MAX_ZOOM = 8; interface Transform { scale: number; x: number; y: number; } interface Pointer { x: number; y: number; } type Gesture = | { kind: "pan"; startX: number; startY: number; originX: number; originY: number } | { kind: "pinch"; startDist: number; // Content-space point that sat under the fingers' midpoint at the start; // it is kept under the (moving) midpoint for the whole pinch. contentX: number; contentY: number; startScale: number; } | null; /** Movement (px) past which a press counts as a drag rather than a click. */ const DRAG_THRESHOLD = 4; /** * Zoom and pan for the review player's picture. * * `stageRef` is the clipping box that receives gestures; `layerRef` is the * element that gets transformed. The transform is written straight to the * layer's style so panning never re-renders the player; only `scale` is state * (for the zoom badge). * * - Wheel / trackpad pinch: zoom toward the cursor (always, even while drawing) * - Drag (mouse, or one finger): pan, when zoomed and `gesturesEnabled` * - Two-finger pinch: zoom + pan, when `gesturesEnabled` */ export function useZoomPan( stageRef: RefObject, layerRef: RefObject, { gesturesEnabled }: { gesturesEnabled: boolean } ) { const [scale, setScale] = useState(1); const t = useRef({ scale: 1, x: 0, y: 0 }); const pointers = useRef(new Map()); const gesture = useRef(null); const dragged = useRef(false); const pressStart = useRef(null); const apply = useCallback( (next: Transform) => { const stage = stageRef.current; const w = stage?.clientWidth ?? 0; const h = stage?.clientHeight ?? 0; const s = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, next.scale)); // Keep the layer covering the stage: no empty gutter past the picture edge. const x = Math.min(0, Math.max(w - w * s, next.x)); const y = Math.min(0, Math.max(h - h * s, next.y)); t.current = { scale: s, x, y }; const layer = layerRef.current; if (layer) { layer.style.transform = s === 1 ? "" : `translate(${x}px, ${y}px) scale(${s})`; } setScale(s); }, [stageRef, layerRef] ); /** Zoom to `nextScale`, keeping the stage point (px, py) fixed on screen. */ const zoomAt = useCallback( (nextScale: number, px: number, py: number) => { const { scale: s, x, y } = t.current; const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextScale)); const cx = (px - x) / s; const cy = (py - y) / s; apply({ scale: clamped, x: px - cx * clamped, y: py - cy * clamped }); }, [apply] ); const zoomBy = useCallback( (factor: number) => { const stage = stageRef.current; if (!stage) return; zoomAt(t.current.scale * factor, stage.clientWidth / 2, stage.clientHeight / 2); }, [stageRef, zoomAt] ); const reset = useCallback(() => apply({ scale: 1, x: 0, y: 0 }), [apply]); // Wheel needs a non-passive native listener to be able to preventDefault. useEffect(() => { const stage = stageRef.current; if (!stage) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? stage.clientHeight : 1; // Trackpad pinch arrives as ctrl+wheel with small deltas — make it snappier. const k = e.ctrlKey ? 0.01 : 0.0015; const rect = stage.getBoundingClientRect(); zoomAt( t.current.scale * Math.exp(-e.deltaY * unit * k), e.clientX - rect.left, e.clientY - rect.top ); }; stage.addEventListener("wheel", onWheel, { passive: false }); return () => stage.removeEventListener("wheel", onWheel); }, [stageRef, zoomAt]); // Re-clamp when the player changes size (panel resize, fullscreen, rotate). useEffect(() => { const stage = stageRef.current; if (!stage) return; const observer = new ResizeObserver(() => apply(t.current)); observer.observe(stage); return () => observer.disconnect(); }, [stageRef, apply]); const local = (e: React.PointerEvent): Pointer => { const rect = stageRef.current!.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const beginGesture = () => { const pts = [...pointers.current.values()]; const { scale: s, x, y } = t.current; if (pts.length >= 2) { const [a, b] = pts; const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; gesture.current = { kind: "pinch", startDist: Math.hypot(a.x - b.x, a.y - b.y) || 1, contentX: (mx - x) / s, contentY: (my - y) / s, startScale: s, }; } else if (pts.length === 1 && s > 1) { gesture.current = { kind: "pan", startX: pts[0].x, startY: pts[0].y, originX: x, originY: y, }; } else { gesture.current = null; } }; const onPointerDown = (e: React.PointerEvent) => { if (!gesturesEnabled || !stageRef.current) return; if (e.pointerType === "mouse" && e.button !== 0) return; const p = local(e); if (pointers.current.size === 0) { dragged.current = false; pressStart.current = p; } pointers.current.set(e.pointerId, p); if (pointers.current.size >= 2) dragged.current = true; // a pinch is never a tap beginGesture(); if (gesture.current) stageRef.current.setPointerCapture?.(e.pointerId); }; const onPointerMove = (e: React.PointerEvent) => { if (!pointers.current.has(e.pointerId)) return; const p = local(e); pointers.current.set(e.pointerId, p); const start = pressStart.current; if (start && Math.hypot(p.x - start.x, p.y - start.y) > DRAG_THRESHOLD) { dragged.current = true; } const g = gesture.current; if (!g) return; if (g.kind === "pan") { apply({ scale: t.current.scale, x: g.originX + (p.x - g.startX), y: g.originY + (p.y - g.startY), }); } else { const [a, b] = [...pointers.current.values()]; if (!a || !b) return; const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; const s = Math.min( MAX_ZOOM, Math.max(MIN_ZOOM, (g.startScale * Math.hypot(a.x - b.x, a.y - b.y)) / g.startDist) ); apply({ scale: s, x: mx - g.contentX * s, y: my - g.contentY * s }); } }; const onPointerEnd = (e: React.PointerEvent) => { if (!pointers.current.delete(e.pointerId)) return; // Lifting one finger of a pinch hands over to a pan from where it is now. beginGesture(); }; /** * Call from the stage's click handler: true when the click is really the * tail end of a drag/pinch and should not toggle playback. */ const consumeDrag = useCallback(() => { const was = dragged.current; dragged.current = false; return was; }, []); const zoomed = scale > 1; return { scale, zoomed, zoomBy, reset, consumeDrag, stageHandlers: { onPointerDown, onPointerMove, onPointerUp: onPointerEnd, onPointerCancel: onPointerEnd, }, stageStyle: { // At 1x leave vertical page scrolling to the browser on phones, but keep // pinch for us; once zoomed every touch belongs to the player. touchAction: zoomed ? "none" : "pan-y", cursor: gesturesEnabled && zoomed ? "grab" : undefined, } as React.CSSProperties, }; }