import { useCallback, useEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' import { CloseIcon } from '@/components/icons' const MIN_IMAGE_SCALE = 0.25 const MAX_IMAGE_SCALE = 8 const IMAGE_SCALE_STEP = 0.25 function clampImageScale(value: number): number { return Math.min(MAX_IMAGE_SCALE, Math.max(MIN_IMAGE_SCALE, value)) } type ImagePoint = { x: number; y: number } function getPointDistance(a: ImagePoint, b: ImagePoint): number { return Math.hypot(a.x - b.x, a.y - b.y) } function getPointCenter(a: ImagePoint, b: ImagePoint): ImagePoint { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } } export function ImagePreview(props: { src: string fileName: string label: string buttonClassName?: string imageClassName?: string caption?: ReactNode }) { const [viewerOpen, setViewerOpen] = useState(false) const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const scaleRef = useRef(scale) const offsetRef = useRef(offset) const activePointersRef = useRef(new Map()) const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: ImagePoint; origin: ImagePoint } | null>(null) const updateScale = useCallback((next: number | ((current: number) => number)) => { setScale((current) => { const value = typeof next === 'function' ? next(current) : next scaleRef.current = value return value }) }, []) const updateOffset = useCallback((next: ImagePoint) => { offsetRef.current = next setOffset(next) }, []) const resetView = useCallback(() => { updateScale(1) updateOffset({ x: 0, y: 0 }) }, [updateOffset, updateScale]) const closeViewer = useCallback(() => { setViewerOpen(false) activePointersRef.current.clear() dragRef.current = null pinchRef.current = null resetView() }, [resetView]) const zoomBy = useCallback((delta: number) => { updateScale((current) => clampImageScale(current + delta)) }, [updateScale]) const handleWheel = useCallback((event: WheelEvent) => { event.preventDefault() const delta = event.deltaY < 0 ? IMAGE_SCALE_STEP : -IMAGE_SCALE_STEP zoomBy(delta) }, [zoomBy]) const beginPinch = useCallback(() => { const pointers = Array.from(activePointersRef.current.values()) if (pointers.length < 2) return const [first, second] = pointers pinchRef.current = { startDistance: getPointDistance(first, second), startScale: scaleRef.current, startCenter: getPointCenter(first, second), origin: offsetRef.current } dragRef.current = null }, []) const handlePointerDown = useCallback((event: PointerEvent) => { if (event.button !== 0) return event.currentTarget.setPointerCapture(event.pointerId) activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (activePointersRef.current.size >= 2) { beginPinch() return } dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, originX: offsetRef.current.x, originY: offsetRef.current.y } }, [beginPinch]) const handlePointerMove = useCallback((event: PointerEvent) => { if (!activePointersRef.current.has(event.pointerId)) return activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) if (activePointersRef.current.size >= 2 && pinchRef.current) { const pointers = Array.from(activePointersRef.current.values()) const [first, second] = pointers const distance = getPointDistance(first, second) const center = getPointCenter(first, second) const pinch = pinchRef.current const nextScale = pinch.startDistance > 0 ? clampImageScale(pinch.startScale * (distance / pinch.startDistance)) : pinch.startScale updateScale(nextScale) updateOffset({ x: pinch.origin.x + center.x - pinch.startCenter.x, y: pinch.origin.y + center.y - pinch.startCenter.y }) return } const drag = dragRef.current if (!drag || drag.pointerId !== event.pointerId) return updateOffset({ x: drag.originX + event.clientX - drag.startX, y: drag.originY + event.clientY - drag.startY }) }, [updateOffset, updateScale]) const handlePointerUp = useCallback((event: PointerEvent) => { activePointersRef.current.delete(event.pointerId) if (dragRef.current?.pointerId === event.pointerId) { dragRef.current = null } pinchRef.current = null const remainingPointer = activePointersRef.current.entries().next().value as [number, ImagePoint] | undefined if (remainingPointer) { dragRef.current = { pointerId: remainingPointer[0], startX: remainingPointer[1].x, startY: remainingPointer[1].y, originX: offsetRef.current.x, originY: offsetRef.current.y } } }, []) useEffect(() => { if (!viewerOpen) return const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { closeViewer() } if (event.key === '0') { resetView() } if (event.key === '+' || event.key === '=') { zoomBy(IMAGE_SCALE_STEP) } if (event.key === '-') { zoomBy(-IMAGE_SCALE_STEP) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) }, [closeViewer, resetView, viewerOpen, zoomBy]) return ( <> {viewerOpen ? (
{props.fileName}
{props.label}
) : null} ) }