import { useState, useEffect, useRef, useCallback } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import type { Photo } from '../../types/photo' import { getPreviewImageSrc, getPreviewFallbackSrc, getVideoSrc, isVideo, } from './previewSrc' interface PreviewImageProps { photo: Photo } const MIN_SCALE = 1 const MAX_SCALE = 8 const WHEEL_STEP = 1.15 export function PreviewImage({ photo }: PreviewImageProps) { if (isVideo(photo)) { return } return } function PreviewVideo({ photo }: { photo: Photo }) { return (
) } function PreviewStillImage({ photo }: { photo: Photo }) { const [loaded, setLoaded] = useState(false) const [usingFallback, setUsingFallback] = useState(false) // scale=1 means "fit to viewport". Anything >1 zooms in; we don't allow <1 // because the fit size already fills the viewport. const [scale, setScale] = useState(1) const [offset, setOffset] = useState({ x: 0, y: 0 }) const dragStateRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null) const imgRef = useRef(null) // Reset everything when the photo changes. useEffect(() => { setLoaded(false) setUsingFallback(false) setScale(1) setOffset({ x: 0, y: 0 }) }, [photo.id]) const primarySrc = getPreviewImageSrc(photo) const fallbackSrc = getPreviewFallbackSrc(photo) const src = usingFallback ? fallbackSrc : primarySrc const handleError = () => { if (!usingFallback && primarySrc !== fallbackSrc) { setUsingFallback(true) } } // Z key: toggle between fit (scale=1) and actual size (natural/displayed). // If we're already zoomed (manual wheel zoom), Z snaps back to fit. const toggleZoom = useCallback(() => { if (scale !== 1) { setScale(1) setOffset({ x: 0, y: 0 }) return } const img = imgRef.current if (!img) return const ratio = img.naturalWidth / img.clientWidth if (!isFinite(ratio) || ratio <= 1) return setScale(Math.min(ratio, MAX_SCALE)) }, [scale]) useHotkeys( 'z', (e) => { e.preventDefault() toggleZoom() }, { preventDefault: true }, [toggleZoom] ) const handleWheel = (e: React.WheelEvent) => { e.preventDefault() const delta = e.deltaY < 0 ? WHEEL_STEP : 1 / WHEEL_STEP setScale((prev) => { const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev * delta)) // Snapping back to 1 also resets pan offset. if (next === 1) setOffset({ x: 0, y: 0 }) return next }) } const handleMouseDown = (e: React.MouseEvent) => { if (scale === 1) return e.preventDefault() dragStateRef.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y, } } const handleMouseMove = (e: React.MouseEvent) => { const drag = dragStateRef.current if (!drag) return setOffset({ x: drag.ox + (e.clientX - drag.x), y: drag.oy + (e.clientY - drag.y), }) } const endDrag = () => { dragStateRef.current = null } const isZoomed = scale > 1 const cursor = isZoomed ? dragStateRef.current ? 'grabbing' : 'grab' : 'zoom-in' return (
{photo.filename} setLoaded(true)} onError={handleError} className="max-h-full max-w-full object-contain" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, transformOrigin: 'center center', willChange: 'transform', }} /> {!loaded && (
)} {/* Zoom indicator */} {isZoomed && (
{Math.round(scale * 100)}%
)}
) }