1import { useImperativeHandle, useRef, useState, useSyncExternalStore, type Ref } from "react";
2import { flushSync } from "react-dom";
3
4import {
5 onWindowControlsOverlayGeometryChange,
6 windowControlsOverlayHeight,
7} from "../../lib/windowControlsOverlay";
8
9import { AttachmentImage } from "../media/AttachmentImage";
10
11const MAX_ZOOM = 8;
12
13// Subtract the caption strip, matching dialog CSS's --native-titlebar-height.
14function readViewerSize() {
15 return { width: window.innerWidth, height: window.innerHeight - windowControlsOverlayHeight() };
16}
17
18let viewerSize: ReturnType<typeof readViewerSize> | null = null;
19
20// Cached so an unchanged size keeps its identity: a new one resets the zoom.
21function getViewerSize() {
22 const next = readViewerSize();
23 if (next.width !== viewerSize?.width || next.height !== viewerSize.height) viewerSize = next;
24 return viewerSize;
25}
26
27function subscribeViewerSize(onChange: () => void) {
28 window.addEventListener("resize", onChange);
29 // Fullscreen caption-strip changes may not emit resize.
30 const stopOverlayListener = onWindowControlsOverlayGeometryChange(onChange);
31 return () => {
32 window.removeEventListener("resize", onChange);
33 stopOverlayListener();
34 };
35}
36
37export interface ZoomableImageHandle {
38 pan: (key: string) => boolean;
39}
40
41export function ZoomableImage({
42 src,
43 name,
44 mimeType,
45 onError,
46 ref,
47}: {
48 src: string;
49 name: string;
50 mimeType?: string | undefined;
51 onError: () => void;
52 ref?: Ref<ZoomableImageHandle>;
53}) {
54 const viewportRef = useRef<HTMLDivElement>(null);
55 const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 });
56 const viewerSize = useSyncExternalStore(subscribeViewerSize, getViewerSize);
57 const [zoom, setZoom] = useState(1);
58 // A resized viewer refits the image.
59 const [zoomViewerSize, setZoomViewerSize] = useState(viewerSize);
60 if (zoomViewerSize !== viewerSize) {
61 setZoomViewerSize(viewerSize);
62 setZoom(1);
63 }
64 const dragRef = useRef<{
65 pointerId: number;
66 x: number;
67 y: number;
68 left: number;
69 top: number;
70 } | null>(null);
71 const suppressClickRef = useRef(false);
72 const [dragging, setDragging] = useState(false);
73 const maxHeight = Math.max(1, Math.min(viewerSize.height * 0.86, viewerSize.height - 160));
74 // A vector scales without loss, and an SVG without width and height reports a tiny default size.
75 const fit = Math.min(
76 mimeType === "image/svg+xml" ? Infinity : 1,
77 (viewerSize.width * 0.92 - (viewerSize.width >= 640 ? 96 : 0)) / (naturalSize.width || 1),
78 maxHeight / (naturalSize.height || 1),
79 );
80 const width = naturalSize.width * fit * zoom;
81 const height = naturalSize.height * fit * zoom;
82
83 useImperativeHandle(
84 ref,
85 () => ({
86 pan(key) {
87 const viewport = viewportRef.current;
88 if (!viewport || zoom <= 1) return false;
89 // A vertical scrollbar alone must not swallow gallery navigation.
90 if (
91 (key === "ArrowLeft" || key === "ArrowRight") &&
92 viewport.scrollWidth <= viewport.offsetWidth
93 )
94 return false;
95 switch (key) {
96 case "ArrowLeft":
97 viewport.scrollLeft -= 40;
98 break;
99 case "ArrowRight":
100 viewport.scrollLeft += 40;
101 break;
102 case "ArrowUp":
103 viewport.scrollTop -= 40;
104 break;
105 case "ArrowDown":
106 viewport.scrollTop += 40;
107 break;
108 default:
109 return false;
110 }
111 return true;
112 },
113 }),
114 [zoom],
115 );
116
117 const changeZoom = (next: number, point?: { x: number; y: number }) => {
118 const viewport = viewportRef.current;
119 const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
120 if (!viewport || zoom === clamped) return;
121 const bounds = viewport.getBoundingClientRect();
122 const x = point ? point.x - bounds.left : viewport.clientWidth / 2;
123 const y = point ? point.y - bounds.top : viewport.clientHeight / 2;
124 const anchorX = (viewport.scrollLeft + x) / zoom;
125 const anchorY = (viewport.scrollTop + y) / zoom;
126 const clientX = bounds.left + x;
127 const clientY = bounds.top + y;
128 // The new size must be laid out before the scroll position can keep the anchor in place.
129 flushSync(() => setZoom(clamped));
130 const resized = viewport.getBoundingClientRect();
131 viewport.scrollLeft = anchorX * clamped - (clientX - resized.left);
132 viewport.scrollTop = anchorY * clamped - (clientY - resized.top);
133 };
134
135 const attachViewport = (viewport: HTMLDivElement) => {
136 viewportRef.current = viewport;
137 // Trackpad pinch arrives as a ctrlKey wheel; plain wheels pan through native overflow scrolling.
138 // React's onWheel is passive and cannot stop the browser's own page zoom.
139 const wheel = (event: WheelEvent) => {
140 if (!event.ctrlKey || event.deltaY === 0) return;
141 event.preventDefault();
142 const delta =
143 event.deltaY *
144 (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1);
145 changeZoom(zoom * Math.exp(-delta * 0.01), {
146 x: event.clientX,
147 y: event.clientY,
148 });
149 };
150 viewport.addEventListener("wheel", wheel, { passive: false });
151 return () => {
152 viewport.removeEventListener("wheel", wheel);
153 viewportRef.current = null;
154 };
155 };
156
157 return (
158 <div className="min-w-0">
159 <div
160 ref={attachViewport}
161 role="region"
162 aria-label={`${name}, zoomable image`}
163 aria-description="Click to zoom in or return to fit. Pinch or Ctrl+scroll to zoom, scroll or drag to pan. Use Enter to toggle zoom, plus or minus to zoom, and 0 to fit."
164 tabIndex={0}
165 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"
166 style={{
167 width: width || undefined,
168 height: height || undefined,
169 maxHeight,
170 cursor: zoom > 1 ? (dragging ? "grabbing" : "grab") : "zoom-in",
171 }}
172 onClick={(event) => {
173 // Pointer capture emits a click after dragging; preserve zoom.
174 if (suppressClickRef.current || event.detail > 1) return;
175 changeZoom(zoom > 1 ? 1 : 2, { x: event.clientX, y: event.clientY });
176 }}
177 onKeyDown={(event) => {
178 if (event.ctrlKey || event.metaKey || event.altKey) return;
179 if (event.key === "Enter" || event.key === " ") {
180 event.preventDefault();
181 if (!event.repeat) changeZoom(zoom > 1 ? 1 : 2);
182 } else if (event.key === "+" || event.key === "=") {
183 event.preventDefault();
184 changeZoom(zoom * 1.5);
185 } else if (event.key === "-") {
186 event.preventDefault();
187 changeZoom(zoom / 1.5);
188 } else if (event.key === "0") {
189 event.preventDefault();
190 changeZoom(1);
191 }
192 }}
193 onPointerDown={(event) => {
194 if (dragRef.current) return;
195 suppressClickRef.current = false;
196 if (event.pointerType !== "mouse" || event.button !== 0 || zoom <= 1) return;
197 const viewport = event.currentTarget;
198 const bounds = viewport.getBoundingClientRect();
199 if (
200 event.clientX - bounds.left >= viewport.clientWidth ||
201 event.clientY - bounds.top >= viewport.clientHeight
202 )
203 return;
204 dragRef.current = {
205 pointerId: event.pointerId,
206 x: event.clientX,
207 y: event.clientY,
208 left: viewport.scrollLeft,
209 top: viewport.scrollTop,
210 };
211 viewport.setPointerCapture(event.pointerId);
212 setDragging(true);
213 }}
214 onPointerMove={(event) => {
215 const drag = dragRef.current;
216 if (!drag || drag.pointerId !== event.pointerId) return;
217 if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) {
218 suppressClickRef.current = true;
219 }
220 event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x);
221 event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y);
222 }}
223 onPointerUp={(event) => {
224 if (dragRef.current?.pointerId !== event.pointerId) return;
225 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
226 event.currentTarget.releasePointerCapture(event.pointerId);
227 }
228 dragRef.current = null;
229 setDragging(false);
230 }}
231 onLostPointerCapture={(event) => {
232 if (dragRef.current?.pointerId !== event.pointerId) return;
233 dragRef.current = null;
234 setDragging(false);
235 }}
236 >
237 <AttachmentImage
238 name={name}
239 mimeType={mimeType}
240 src={src}
241 alt={name}
242 draggable={false}
243 className="block max-w-none select-none"
244 style={
245 naturalSize.width ? { width, height } : { maxWidth: "var(--media-width)", maxHeight }
246 }
247 onLoad={(event) => {
248 setNaturalSize({
249 width: event.currentTarget.naturalWidth,
250 height: event.currentTarget.naturalHeight,
251 });
252 }}
253 onError={onError}
254 />
255 </div>
256 <span className="sr-only" aria-live="polite">
257 {Math.round(zoom * 100)}% zoom
258 </span>
259 </div>
260 );
261}