import { type ReactNode, useEffect, useRef } from "react"; import { ZOOM_STEP } from "./useZoomPan.js"; import { useZoom } from "./ZoomContext.js"; interface Props { children: ReactNode; /** * Whether single-pointer drag pans the canvas. Pass false in tool mode so * 1-finger events are owned by the tool; 2-finger pinch/drag still works. * Defaults to true. */ panEnabled?: boolean; } /** * Fills the canvas area, applies zoom/pan transform to its children, and * captures pointer events for gestures. * * • Ctrl/Cmd + scroll: zoom anchored at cursor (desktop) * • 2-finger pinch + translate: simultaneous zoom + pan anchored at midpoint * • 1-finger drag (when panEnabled): pan */ export function ZoomCanvas({ children, panEnabled = true }: Props) { let { zoom, panX, panY, pan, panAndZoomAtPoint, zoomByDeltaAtPoint } = useZoom(); let containerRef = useRef(null); let pointers = useRef(new Map()); let lastPinchDist = useRef(null); let lastPinchMid = useRef<{ x: number; y: number } | null>(null); // Stable refs so the DOM wheel listener never needs re-registration. let panRef = useRef(pan); let panAndZoomAtPointRef = useRef(panAndZoomAtPoint); let zoomByDeltaAtPointRef = useRef(zoomByDeltaAtPoint); panRef.current = pan; panAndZoomAtPointRef.current = panAndZoomAtPoint; zoomByDeltaAtPointRef.current = zoomByDeltaAtPoint; function focalFromClient(clientX: number, clientY: number) { let rect = containerRef.current?.getBoundingClientRect(); if (!rect) return { fx: 0, fy: 0 }; return { fx: clientX - rect.left - rect.width / 2, fy: clientY - rect.top - rect.height / 2 }; } useEffect(() => { let el = containerRef.current; if (!el) return; function onWheel(e: WheelEvent) { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); let { fx, fy } = focalFromClient(e.clientX, e.clientY); zoomByDeltaAtPointRef.current(e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP, fx, fy); } el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, []); function onPointerDown(e: React.PointerEvent) { // Only capture the pointer for panning. In tool mode (panEnabled=false) the inner // tool elements (DragHandle, redact SVG) call setPointerCapture themselves after // this event bubbles up — don't override their capture. if (panEnabled) { e.currentTarget.setPointerCapture(e.pointerId); } pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); lastPinchDist.current = null; lastPinchMid.current = null; } function onPointerMove(e: React.PointerEvent) { // Ignore hover — only process pointers that went through onPointerDown. if (!pointers.current.has(e.pointerId)) return; let prev = pointers.current.get(e.pointerId); pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.current.size === 2) { let [a, b] = [...pointers.current.values()]; let dist = Math.hypot(a.x - b.x, a.y - b.y); let midX = (a.x + b.x) / 2; let midY = (a.y + b.y) / 2; if (lastPinchDist.current !== null && lastPinchDist.current > 0) { let { fx, fy } = focalFromClient(midX, midY); let scaleFactor = dist / lastPinchDist.current; // Translate from midpoint movement + zoom from pinch — both in one state update. let dx = lastPinchMid.current ? midX - lastPinchMid.current.x : 0; let dy = lastPinchMid.current ? midY - lastPinchMid.current.y : 0; panAndZoomAtPointRef.current(dx, dy, scaleFactor, fx, fy); } lastPinchDist.current = dist; lastPinchMid.current = { x: midX, y: midY }; } else if (pointers.current.size === 1 && prev && panEnabled) { panRef.current(e.clientX - prev.x, e.clientY - prev.y); } } function onPointerUp(e: React.PointerEvent) { pointers.current.delete(e.pointerId); if (pointers.current.size < 2) { lastPinchDist.current = null; lastPinchMid.current = null; } } return (
{children}
); }