import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, type Ref, } from "react"; const MAX_ZOOM = 8; export interface ZoomableImageHandle { pan: (key: string) => boolean; } /** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */ export function ZoomableImage({ src, name, onError, ref, }: { src: string; name: string; onError: () => void; ref?: Ref; }) { const viewportRef = useRef(null); const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); const [windowSize, setWindowSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight, })); const [zoom, setZoom] = useState(1); const zoomRef = useRef(1); const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); const dragRef = useRef<{ pointerId: number; x: number; y: number; left: number; top: number; } | null>(null); const suppressClickRef = useRef(false); const [dragging, setDragging] = useState(false); const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); const fit = Math.min( 1, (windowSize.width * 0.92) / (naturalSize.width || 1), maxHeight / (naturalSize.height || 1), ); const width = naturalSize.width * fit * zoom; const height = naturalSize.height * fit * zoom; useImperativeHandle( ref, () => ({ pan(key) { const viewport = viewportRef.current; if (!viewport || zoomRef.current <= 1) return false; switch (key) { case "ArrowLeft": viewport.scrollLeft -= 40; break; case "ArrowRight": viewport.scrollLeft += 40; break; case "ArrowUp": viewport.scrollTop -= 40; break; case "ArrowDown": viewport.scrollTop += 40; break; default: return false; } return true; }, }), [], ); const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { const viewport = viewportRef.current; const previous = zoomRef.current; const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); if (!viewport || previous === clamped) return; const bounds = viewport.getBoundingClientRect(); const x = point ? point.x - bounds.left : viewport.clientWidth / 2; const y = point ? point.y - bounds.top : viewport.clientHeight / 2; anchorRef.current = { x: (viewport.scrollLeft + x) / previous, y: (viewport.scrollTop + y) / previous, clientX: bounds.left + x, clientY: bounds.top + y, }; zoomRef.current = clamped; setZoom(clamped); }, []); useLayoutEffect(() => { const viewport = viewportRef.current; const anchor = anchorRef.current; if (!viewport || !anchor) return; const bounds = viewport.getBoundingClientRect(); viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left); viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top); anchorRef.current = null; }, [zoom]); useEffect(() => { const resize = () => { setWindowSize({ width: window.innerWidth, height: window.innerHeight }); changeZoom(1); }; window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [changeZoom]); useEffect(() => { const viewport = viewportRef.current; if (!viewport) return; const wheel = (event: WheelEvent) => { if (event.deltaY === 0) return; event.preventDefault(); const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { x: event.clientX, y: event.clientY, }); }; viewport.addEventListener("wheel", wheel, { passive: false }); return () => viewport.removeEventListener("wheel", wheel); }, [changeZoom]); return (
1 ? (dragging ? "grabbing" : "grab") : "zoom-in", }} onClick={(event) => { // Pointer capture also produces a click after dragging; leave the image zoomed. if (suppressClickRef.current || event.detail > 1) return; changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); }} onKeyDown={(event) => { if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); } else if (event.key === "+" || event.key === "=") { event.preventDefault(); changeZoom(zoomRef.current * 1.5); } else if (event.key === "-") { event.preventDefault(); changeZoom(zoomRef.current / 1.5); } else if (event.key === "0") { event.preventDefault(); changeZoom(1); } }} onPointerDown={(event) => { if (dragRef.current) return; suppressClickRef.current = false; if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; const viewport = event.currentTarget; const bounds = viewport.getBoundingClientRect(); if ( event.clientX - bounds.left >= viewport.clientWidth || event.clientY - bounds.top >= viewport.clientHeight ) return; dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, left: viewport.scrollLeft, top: viewport.scrollTop, }; viewport.setPointerCapture(event.pointerId); setDragging(true); }} onPointerMove={(event) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { suppressClickRef.current = true; } event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); }} onPointerUp={(event) => { if (dragRef.current?.pointerId !== event.pointerId) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } dragRef.current = null; setDragging(false); }} onLostPointerCapture={(event) => { if (dragRef.current?.pointerId !== event.pointerId) return; dragRef.current = null; setDragging(false); }} > {name} { setNaturalSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, }); }} onError={onError} />
{Math.round(zoom * 100)}% zoom
); }