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:
179
frontend/src/components/loupe/LoupeImage.tsx
Normal file
179
frontend/src/components/loupe/LoupeImage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user