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