Files
mule-image/frontend/src/components/preview/PreviewImage.tsx
dtoro 2679214cb9 refactor: rename loupe to preview, bind to E and Space, fix empty viewer
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>
2026-04-07 22:47:52 +02:00

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>
)
}