|
| 1 | +import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' |
| 2 | +import { CloseIcon } from '@/components/icons' |
| 3 | + |
| 4 | +const MIN_IMAGE_SCALE = 0.25 |
| 5 | +const MAX_IMAGE_SCALE = 8 |
| 6 | +const IMAGE_SCALE_STEP = 0.25 |
| 7 | + |
| 8 | +function clampImageScale(value: number): number { |
| 9 | + return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value)) |
| 10 | +} |
| 11 | + |
| 12 | +type ImagePoint = { x: number; y: number } |
| 13 | + |
| 14 | +function getPointDistance(a: ImagePoint, b: ImagePoint): number { |
| 15 | + return Math.hypot(a.x - b.x, a.y - b.y) |
| 16 | +} |
| 17 | + |
| 18 | +function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint { |
| 19 | + return { |
| 20 | + x: (a.x + b.x) / 2, |
| 21 | + y: (a.y + b.y) / 2 |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +export function ImagePreview(props: { |
| 26 | + src: string |
| 27 | + fileName: string |
| 28 | + label: string |
| 29 | + buttonClassName?: string |
| 30 | + imageClassName?: string |
| 31 | + caption?: ReactNode |
| 32 | +}) { |
| 33 | + const [viewerOpen, setViewerOpen] = useState(false) |
| 34 | + const [scale, setScale] = useState(1) |
| 35 | + const [offset, setOffset] = useState({ x: 0, y: 0 }) |
| 36 | + const scaleRef = useRef(scale) |
| 37 | + const offsetRef = useRef(offset) |
| 38 | + const activePointersRef = useRef(new Map<number, ImagePoint>()) |
| 39 | + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) |
| 40 | + const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null) |
| 41 | + |
| 42 | + const updateScale = useCallback((next: number | ((current: number) => number)) => { |
| 43 | + setScale((current) => { |
| 44 | + const value = typeof next === 'function' ? next(current) : next |
| 45 | + scaleRef.current = value |
| 46 | + return value |
| 47 | + }) |
| 48 | + }, []) |
| 49 | + |
| 50 | + const updateOffset = useCallback((next: ImagePoint) => { |
| 51 | + offsetRef.current = next |
| 52 | + setOffset(next) |
| 53 | + }, []) |
| 54 | + |
| 55 | + const resetView = useCallback(() => { |
| 56 | + updateScale(1) |
| 57 | + updateOffset({ x: 0, y: 0 }) |
| 58 | + }, [updateOffset, updateScale]) |
| 59 | + |
| 60 | + const closeViewer = useCallback(() => { |
| 61 | + setViewerOpen(false) |
| 62 | + activePointersRef.current.clear() |
| 63 | + dragRef.current = null |
| 64 | + pinchRef.current = null |
| 65 | + resetView() |
| 66 | + }, [resetView]) |
| 67 | + |
| 68 | + const zoomBy = useCallback((delta: number) => { |
| 69 | + updateScale((current) => clampImageScale(current + delta)) |
| 70 | + }, [updateScale]) |
| 71 | + |
| 72 | + const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => { |
| 73 | + event.preventDefault() |
| 74 | + const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP |
| 75 | + zoomBy(delta) |
| 76 | + }, [zoomBy]) |
| 77 | + |
| 78 | + const beginPinch = useCallback(() => { |
| 79 | + const pointers = Array.from(activePointersRef.current.values()) |
| 80 | + if (pointers.length < 2) return |
| 81 | + |
| 82 | + const [first, second] = pointers |
| 83 | + pinchRef.current = { |
| 84 | + startDistance: getPointDistance(first, second), |
| 85 | + startScale: scaleRef.current, |
| 86 | + startCenter: getPointCenter(first, second), |
| 87 | + origin: offsetRef.current |
| 88 | + } |
| 89 | + dragRef.current = null |
| 90 | + }, []) |
| 91 | + |
| 92 | + const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => { |
| 93 | + if (event.button !== 0) return |
| 94 | + event.currentTarget.setPointerCapture(event.pointerId) |
| 95 | + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) |
| 96 | + |
| 97 | + if (activePointersRef.current.size >= 2) { |
| 98 | + beginPinch() |
| 99 | + return |
| 100 | + } |
| 101 | + |
| 102 | + dragRef.current = { |
| 103 | + pointerId: event.pointerId, |
| 104 | + startX: event.clientX, |
| 105 | + startY: event.clientY, |
| 106 | + originX: offsetRef.current.x, |
| 107 | + originY: offsetRef.current.y |
| 108 | + } |
| 109 | + }, [beginPinch]) |
| 110 | + |
| 111 | + const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => { |
| 112 | + if (!activePointersRef.current.has(event.pointerId)) return |
| 113 | + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) |
| 114 | + |
| 115 | + if (activePointersRef.current.size >= 2 && pinchRef.current) { |
| 116 | + const pointers = Array.from(activePointersRef.current.values()) |
| 117 | + const [first, second] = pointers |
| 118 | + const distance = getPointDistance(first, second) |
| 119 | + const center = getPointCenter(first, second) |
| 120 | + const pinch = pinchRef.current |
| 121 | + const nextScale = pinch.startDistance > 0 |
| 122 | + ? clampImageScale(pinch.startScale * (distance / pinch.startDistance)) |
| 123 | + : pinch.startScale |
| 124 | + |
| 125 | + updateScale(nextScale) |
| 126 | + updateOffset({ |
| 127 | + x: pinch.origin.x + center.x - pinch.startCenter.x, |
| 128 | + y: pinch.origin.y + center.y - pinch.startCenter.y |
| 129 | + }) |
| 130 | + return |
| 131 | + } |
| 132 | + |
| 133 | + const drag = dragRef.current |
| 134 | + if (!drag || drag.pointerId !== event.pointerId) return |
| 135 | + updateOffset({ |
| 136 | + x: drag.originX + event.clientX - drag.startX, |
| 137 | + y: drag.originY + event.clientY - drag.startY |
| 138 | + }) |
| 139 | + }, [updateOffset, updateScale]) |
| 140 | + |
| 141 | + const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { |
| 142 | + activePointersRef.current.delete(event.pointerId) |
| 143 | + if (dragRef.current?.pointerId === event.pointerId) { |
| 144 | + dragRef.current = null |
| 145 | + } |
| 146 | + pinchRef.current = null |
| 147 | + |
| 148 | + const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined |
| 149 | + if (remainingPointer) { |
| 150 | + dragRef.current = { |
| 151 | + pointerId: remainingPointer[0], |
| 152 | + startX: remainingPointer[1].x, |
| 153 | + startY: remainingPointer[1].y, |
| 154 | + originX: offsetRef.current.x, |
| 155 | + originY: offsetRef.current.y |
| 156 | + } |
| 157 | + } |
| 158 | + }, []) |
| 159 | + |
| 160 | + useEffect(() => { |
| 161 | + if (!viewerOpen) return |
| 162 | + |
| 163 | + const handleKeyDown = (event: KeyboardEvent) => { |
| 164 | + if (event.key === 'Escape') { |
| 165 | + closeViewer() |
| 166 | + } |
| 167 | + if (event.key === '0') { |
| 168 | + resetView() |
| 169 | + } |
| 170 | + if (event.key === '+' || event.key === '=') { |
| 171 | + zoomBy(IMAGE_SCALE_STEP) |
| 172 | + } |
| 173 | + if (event.key === '-') { |
| 174 | + zoomBy(-IMAGE_SCALE_STEP) |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + window.addEventListener('keydown', handleKeyDown) |
| 179 | + return () => window.removeEventListener('keydown', handleKeyDown) |
| 180 | + }, [closeViewer, resetView, viewerOpen, zoomBy]) |
| 181 | + |
| 182 | + return ( |
| 183 | + <> |
| 184 | + <button |
| 185 | + type="button" |
| 186 | + onClick={() => setViewerOpen(true)} |
| 187 | + className={props.buttonClassName ?? 'group flex min-h-[18rem] w-full items-center justify-center overflow-auto rounded-md border border-[var(--app-border)] bg-[var(--app-code-bg)] p-3 text-left'} |
| 188 | + title="Click to zoom" |
| 189 | + > |
| 190 | + <img |
| 191 | + src={props.src} |
| 192 | + alt={props.label} |
| 193 | + className={props.imageClassName ?? 'max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]'} |
| 194 | + draggable={false} |
| 195 | + /> |
| 196 | + {props.caption} |
| 197 | + <span className="sr-only">{props.fileName}</span> |
| 198 | + </button> |
| 199 | + |
| 200 | + {viewerOpen ? ( |
| 201 | + <div |
| 202 | + className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white" |
| 203 | + role="dialog" |
| 204 | + aria-modal="true" |
| 205 | + aria-label={props.label} |
| 206 | + > |
| 207 | + <div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2"> |
| 208 | + <div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div> |
| 209 | + <button |
| 210 | + type="button" |
| 211 | + onClick={() => zoomBy(-IMAGE_SCALE_STEP)} |
| 212 | + className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40" |
| 213 | + disabled={scale <= MIN_IMAGE_SCALE} |
| 214 | + title="Zoom out" |
| 215 | + > |
| 216 | + − |
| 217 | + </button> |
| 218 | + <button |
| 219 | + type="button" |
| 220 | + onClick={resetView} |
| 221 | + className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20" |
| 222 | + title="Reset zoom" |
| 223 | + > |
| 224 | + {Math.round(scale * 100)}% |
| 225 | + </button> |
| 226 | + <button |
| 227 | + type="button" |
| 228 | + onClick={() => zoomBy(IMAGE_SCALE_STEP)} |
| 229 | + className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40" |
| 230 | + disabled={scale >= MAX_IMAGE_SCALE} |
| 231 | + title="Zoom in" |
| 232 | + > |
| 233 | + + |
| 234 | + </button> |
| 235 | + <button |
| 236 | + type="button" |
| 237 | + onClick={closeViewer} |
| 238 | + className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20" |
| 239 | + title="Close" |
| 240 | + > |
| 241 | + <CloseIcon className="h-4 w-4" /> |
| 242 | + </button> |
| 243 | + </div> |
| 244 | + <div |
| 245 | + className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing" |
| 246 | + onWheel={handleWheel} |
| 247 | + onPointerDown={handlePointerDown} |
| 248 | + onPointerMove={handlePointerMove} |
| 249 | + onPointerUp={handlePointerUp} |
| 250 | + onPointerCancel={handlePointerUp} |
| 251 | + onDoubleClick={resetView} |
| 252 | + > |
| 253 | + <img |
| 254 | + src={props.src} |
| 255 | + alt={props.label} |
| 256 | + draggable={false} |
| 257 | + className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain" |
| 258 | + style={{ |
| 259 | + transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`, |
| 260 | + transformOrigin: 'center center' |
| 261 | + }} |
| 262 | + /> |
| 263 | + </div> |
| 264 | + </div> |
| 265 | + ) : null} |
| 266 | + </> |
| 267 | + ) |
| 268 | +} |
0 commit comments