Files
hapi/web/src/components/ImagePreview.tsx
T

269 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, ImagePoint>())
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<button
type="button"
onClick={() => setViewerOpen(true)}
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'}
title="Click to zoom"
>
<img
src={props.src}
alt={props.label}
className={props.imageClassName ?? 'max-h-[calc(100vh-14rem)] max-w-full object-contain transition-transform group-hover:scale-[1.01]'}
draggable={false}
/>
{props.caption}
<span className="sr-only">{props.fileName}</span>
</button>
{viewerOpen ? (
<div
className="fixed inset-0 z-50 flex flex-col bg-black/90 text-white"
role="dialog"
aria-modal="true"
aria-label={props.label}
>
<div className="flex items-center gap-2 border-b border-white/10 bg-black/50 px-3 py-2">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{props.fileName}</div>
<button
type="button"
onClick={() => zoomBy(-IMAGE_SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale <= MIN_IMAGE_SCALE}
title="Zoom out"
>
</button>
<button
type="button"
onClick={resetView}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
title="Reset zoom"
>
{Math.round(scale * 100)}%
</button>
<button
type="button"
onClick={() => zoomBy(IMAGE_SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale >= MAX_IMAGE_SCALE}
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={closeViewer}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
title="Close"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<div
className="relative min-h-0 flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={resetView}
>
<img
src={props.src}
alt={props.label}
draggable={false}
className="absolute left-1/2 top-1/2 max-h-[90vh] max-w-[90vw] select-none object-contain"
style={{
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
transformOrigin: 'center center'
}}
/>
</div>
</div>
) : null}
</>
)
}