1import {
2 useCallback,
3 useEffect,
4 useImperativeHandle,
5 useLayoutEffect,
6 useRef,
7 useState,
8 type Ref,
9} from "react";
10
11const MAX_ZOOM = 8;
12
13export interface ZoomableImageHandle {
14 pan: (key: string) => boolean;
15}
16
17/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */
18export function ZoomableImage({
19 src,
20 name,
21 onError,
22 ref,
23}: {
24 src: string;
25 name: string;
26 onError: () => void;
27 ref?: Ref<ZoomableImageHandle>;
28}) {
29 const viewportRef = useRef<HTMLDivElement>(null);
30 const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
31 const [windowSize, setWindowSize] = useState(() => ({
32 width: window.innerWidth,
33 height: window.innerHeight,
34 }));
35 const [zoom, setZoom] = useState(1);
36 const zoomRef = useRef(1);
37 const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null);
38 const dragRef = useRef<{
39 pointerId: number;
40 x: number;
41 y: number;
42 left: number;
43 top: number;
44 } | null>(null);
45 const suppressClickRef = useRef(false);
46 const [dragging, setDragging] = useState(false);
47 const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 160));
48 const fit = Math.min(
49 1,
50 (windowSize.width * 0.92 - (windowSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1),
51 maxHeight / (naturalSize.height || 1),
52 );
53 const width = naturalSize.width * fit * zoom;
54 const height = naturalSize.height * fit * zoom;
55
56 useImperativeHandle(
57 ref,
58 () => ({
59 pan(key) {
60 const viewport = viewportRef.current;
61 if (!viewport || zoomRef.current <= 1) return false;
62 // A vertical scrollbar alone must not swallow gallery navigation.
63 if (
64 (key === "ArrowLeft" || key === "ArrowRight") &&
65 viewport.scrollWidth <= viewport.offsetWidth
66 )
67 return false;
68 switch (key) {
69 case "ArrowLeft":
70 viewport.scrollLeft -= 40;
71 break;
72 case "ArrowRight":
73 viewport.scrollLeft += 40;
74 break;
75 case "ArrowUp":
76 viewport.scrollTop -= 40;
77 break;
78 case "ArrowDown":
79 viewport.scrollTop += 40;
80 break;
81 default:
82 return false;
83 }
84 return true;
85 },
86 }),
87 [],
88 );
89
90 const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => {
91 const viewport = viewportRef.current;
92 const previous = zoomRef.current;
93 const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
94 if (!viewport || previous === clamped) return;
95 const bounds = viewport.getBoundingClientRect();
96 const x = point ? point.x - bounds.left : viewport.clientWidth / 2;
97 const y = point ? point.y - bounds.top : viewport.clientHeight / 2;
98 anchorRef.current = {
99 x: (viewport.scrollLeft + x) / previous,
100 y: (viewport.scrollTop + y) / previous,
101 clientX: bounds.left + x,
102 clientY: bounds.top + y,
103 };
104 zoomRef.current = clamped;
105 setZoom(clamped);
106 }, []);
107
108 useLayoutEffect(() => {
109 const viewport = viewportRef.current;
110 const anchor = anchorRef.current;
111 if (!viewport || !anchor) return;
112 const bounds = viewport.getBoundingClientRect();
113 viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left);
114 viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top);
115 anchorRef.current = null;
116 }, [zoom]);
117
118 useEffect(() => {
119 const resize = () => {
120 setWindowSize({ width: window.innerWidth, height: window.innerHeight });
121 changeZoom(1);
122 };
123 window.addEventListener("resize", resize);
124 return () => window.removeEventListener("resize", resize);
125 }, [changeZoom]);
126
127 useEffect(() => {
128 const viewport = viewportRef.current;
129 if (!viewport) return;
130 const wheel = (event: WheelEvent) => {
131 if (event.deltaY === 0) return;
132 event.preventDefault();
133 const delta =
134 event.deltaY *
135 (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1);
136 changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), {
137 x: event.clientX,
138 y: event.clientY,
139 });
140 };
141 viewport.addEventListener("wheel", wheel, { passive: false });
142 return () => viewport.removeEventListener("wheel", wheel);
143 }, [changeZoom]);
144
145 return (
146 <div className="min-w-0">
147 <div
148 ref={viewportRef}
149 role="region"
150 aria-label={`${name}, zoomable image`}
151 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."
152 tabIndex={0}
153 className="max-w-[var(--media-width)] overflow-auto overscroll-contain rounded-lg bg-background shadow-2xl ring-1 ring-border/70 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring"
154 style={{
155 width: width || undefined,
156 height: height || undefined,
157 maxHeight,
158 cursor: zoom > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
159 }}
160 onClick={(event) => {
161 // Pointer capture also produces a click after dragging; leave the image zoomed.
162 if (suppressClickRef.current || event.detail > 1) return;
163 changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY });
164 }}
165 onKeyDown={(event) => {
166 if (event.ctrlKey || event.metaKey || event.altKey) return;
167 if (event.key === "Enter" || event.key === " ") {
168 event.preventDefault();
169 if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2);
170 } else if (event.key === "+" || event.key === "=") {
171 event.preventDefault();
172 changeZoom(zoomRef.current * 1.5);
173 } else if (event.key === "-") {
174 event.preventDefault();
175 changeZoom(zoomRef.current / 1.5);
176 } else if (event.key === "0") {
177 event.preventDefault();
178 changeZoom(1);
179 }
180 }}
181 onPointerDown={(event) => {
182 if (dragRef.current) return;
183 suppressClickRef.current = false;
184 if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return;
185 const viewport = event.currentTarget;
186 const bounds = viewport.getBoundingClientRect();
187 if (
188 event.clientX - bounds.left >= viewport.clientWidth ||
189 event.clientY - bounds.top >= viewport.clientHeight
190 )
191 return;
192 dragRef.current = {
193 pointerId: event.pointerId,
194 x: event.clientX,
195 y: event.clientY,
196 left: viewport.scrollLeft,
197 top: viewport.scrollTop,
198 };
199 viewport.setPointerCapture(event.pointerId);
200 setDragging(true);
201 }}
202 onPointerMove={(event) => {
203 const drag = dragRef.current;
204 if (!drag || drag.pointerId !== event.pointerId) return;
205 if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) {
206 suppressClickRef.current = true;
207 }
208 event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x);
209 event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y);
210 }}
211 onPointerUp={(event) => {
212 if (dragRef.current?.pointerId !== event.pointerId) return;
213 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
214 event.currentTarget.releasePointerCapture(event.pointerId);
215 }
216 dragRef.current = null;
217 setDragging(false);
218 }}
219 onLostPointerCapture={(event) => {
220 if (dragRef.current?.pointerId !== event.pointerId) return;
221 dragRef.current = null;
222 setDragging(false);
223 }}
224 >
225 <img
226 src={src}
227 alt={name}
228 draggable={false}
229 className="block max-w-none select-none"
230 style={
231 naturalSize.width ? { width, height } : { maxWidth: "var(--media-width)", maxHeight }
232 }
233 onLoad={(event) => {
234 setNaturalSize({
235 width: event.currentTarget.naturalWidth,
236 height: event.currentTarget.naturalHeight,
237 });
238 }}
239 onError={onError}
240 />
241 </div>
242 <span className="sr-only" aria-live="polite">
243 {Math.round(zoom * 100)}% zoom
244 </span>
245 </div>
246 );
247}