The loupe view is now called "preview" everywhere — file paths, type
names, store actions, and the contextual hint pill. There's a single
preview action bound to E and Space (Enter is gone); double-click on a
thumbnail still works. Both shortcuts toggle: open from grid, close
from preview.
This commit also folds in the fix for the "preview shows nothing" bug
the user just hit:
- Extract usePhotosQuery into frontend/src/hooks/usePhotosQuery.ts so
Timeline, PreviewView, and App.tsx all share one query — and one
cache entry. Previously PreviewView and App.tsx looked the cache up
by ['photos'], but the Timeline query key gained the filter params
(['photos', filterParams]) when the filter bar shipped, so the
lookup returned undefined and the preview rendered "No photo to
display". App.tsx's getFirstPhotoId callback had the same bug.
- Harden PreviewImage: render the <img> immediately and overlay the
spinner with absolute positioning, instead of toggling opacity-0 →
opacity-100 on load. The previous opacity-toggle could leave the
image stuck invisible if the load event raced with a key change.
- Add { preventDefault: true } to every useHotkeys call so single
letter shortcuts (1-5, P, X, U) no longer leak into Firefox quick-
find, and Cmd/Ctrl+F no longer triggers the browser find toolbar.
Files renamed:
components/loupe/LoupeView.tsx -> components/preview/PreviewView.tsx
components/loupe/LoupeImage.tsx -> components/preview/PreviewImage.tsx
components/loupe/LoupeFilmstrip.tsx -> components/preview/PreviewFilmstrip.tsx
components/loupe/loupeSrc.ts -> components/preview/previewSrc.ts
Symbol renames: openLoupe→openPreview, closeLoupe→closePreview, the
viewMode 'loupe' tag → 'preview', and all the LoupeXxx component and
helper exports.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
4.8 KiB
TypeScript
179 lines
4.8 KiB
TypeScript
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 <PreviewVideo photo={photo} />
|
|
}
|
|
return <PreviewStillImage photo={photo} />
|
|
}
|
|
|
|
function PreviewVideo({ 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 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<HTMLImageElement>(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 (
|
|
<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 }}
|
|
>
|
|
<img
|
|
ref={imgRef}
|
|
key={`${photo.id}-${usingFallback}`}
|
|
src={src}
|
|
alt={photo.filename}
|
|
loading="eager"
|
|
decoding="async"
|
|
draggable={false}
|
|
onLoad={() => 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 && (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-text-muted">
|
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
)
|
|
}
|