feat: loupe view with zoom, pan, video, and filmstrip

Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.

Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
  side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
  zoomed past fit. Z toggles between fit and natural-resolution
  (computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.

Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
  nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
  unmount, and Tab cycling among focusable children.

Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
  duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
  flag stubs with { enabled: viewMode === 'grid' } so they're inert in
  loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
  the auto-open right sidebar effect on viewMode === 'grid' so leaving
  loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 08:39:33 +02:00
parent 1096854553
commit f4fc15101e
10 changed files with 569 additions and 88 deletions

View File

@@ -0,0 +1,179 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import clsx from 'clsx'
import type { Photo } from '../../types/photo'
import {
getLoupeImageSrc,
getLoupeFallbackSrc,
getVideoSrc,
isVideo,
} from './loupeSrc'
interface LoupeImageProps {
photo: Photo
}
const MIN_SCALE = 1
const MAX_SCALE = 8
const WHEEL_STEP = 1.15
export function LoupeImage({ photo }: LoupeImageProps) {
if (isVideo(photo)) {
return <LoupeVideo photo={photo} />
}
return <LoupeStillImage photo={photo} />
}
function LoupeVideo({ photo }: { photo: Photo }) {
return (
<div className="flex flex-1 items-center justify-center bg-black">
<video
key={photo.id}
src={getVideoSrc(photo)}
controls
autoPlay
muted
className="max-h-full max-w-full"
/>
</div>
)
}
function LoupeStillImage({ 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<HTMLImageElement>(null)
// Reset everything when the photo changes.
useEffect(() => {
setLoaded(false)
setUsingFallback(false)
setScale(1)
setOffset({ x: 0, y: 0 })
}, [photo.id])
const primarySrc = getLoupeImageSrc(photo)
const fallbackSrc = getLoupeFallbackSrc(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()
}, [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 (
<div
className="relative flex flex-1 select-none items-center justify-center overflow-hidden bg-black"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
style={{ cursor }}
>
{!loaded && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-text-muted">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
</div>
)}
<img
ref={imgRef}
key={`${photo.id}-${usingFallback}`}
src={src}
alt={photo.filename}
loading="eager"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
onError={handleError}
className={clsx(
'max-h-full max-w-full object-contain transition-opacity duration-150',
loaded ? 'opacity-100' : 'opacity-0'
)}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
// Disable transition during pan/zoom — only fade-in is animated.
transition: 'opacity 150ms',
willChange: 'transform',
}}
/>
{/* Zoom indicator */}
{isZoomed && (
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
{Math.round(scale * 100)}%
</div>
)}
</div>
)
}