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<ZoomableImageHandle>;}) { const viewportRef = useRef<HTMLDivElement>(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 ( <div className="min-w-0"> <div ref={viewportRef} role="region" aria-label={`${name}, zoomable image`} aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit." tabIndex={0} className="max-w-[92vw] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring" style={{ width: width || undefined, height: height || undefined, maxHeight, cursor: zoom > 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); }} > <img src={src} alt={name} draggable={false} className="block max-w-none select-none" style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }} onLoad={(event) => { setNaturalSize({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight, }); }} onError={onError} /> </div> <span className="sr-only" aria-live="polite"> {Math.round(zoom * 100)}% zoom </span> </div> );}
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<ZoomableImageHandle>;
}) {
const viewportRef = useRef<HTMLDivElement>(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 (
<div className="min-w-0">
<div
ref={viewportRef}
role="region"
aria-label={`${name}, zoomable image`}
aria-description="Click to zoom in or return to fit. Scroll to zoom, drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit."
tabIndex={0}
className="max-w-[92vw] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{
width: width || undefined,
height: height || undefined,
maxHeight,
cursor: zoom > 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);
}}
>
<img
src={src}
alt={name}
draggable={false}
className="block max-w-none select-none"
style={naturalSize.width ? { width, height } : { maxWidth: "92vw", maxHeight }}
onLoad={(event) => {
setNaturalSize({
width: event.currentTarget.naturalWidth,
height: event.currentTarget.naturalHeight,
});
}}
onError={onError}
/>
</div>
<span className="sr-only" aria-live="polite">
{Math.round(zoom * 100)}% zoom
</span>
</div>
);
}