Files
mule-image/frontend/src/components/preview/PreviewView.tsx
dtoro 7bb03be51a fix: stable preview nav handlers via ref
react-hotkeys-hook can fire a stale closure when the callback dependency
array changes between renders, causing arrow nav to read an old photos
array (e.g. the empty initial render before visiblePhotoIds was applied)
and land on the wrong photo or no-op entirely.

Move the latest photos / activePhotoId into a navRef updated on every
render. The goPrev / goNext callbacks become stable (their useCallback
deps shrink to just setActivePhoto) and read the freshest values from
the ref at fire time. useHotkeys no longer has to re-bind on every
render — the handlers can capture the ref once.

The visible-order array still drives navigation; this just removes the
re-bind race that was making it look like nav was ignoring it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:07:46 +02:00

216 lines
8.0 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip'
import { getPreviewImageSrc, isVideo } from './previewSrc'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
export function PreviewView() {
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
const closePreview = usePhotoStore((s) => s.closePreview)
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
const containerRef = useRef<HTMLDivElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
// Same hook Timeline uses, so we share one cache entry rather than looking
// it up by key (which broke when the key gained the filter params).
const { data: rawPhotos = [] } = usePhotosQuery()
// Walk the timeline's visible-order sequence (published by Timeline
// into the photo store), which respects tag-grouping and any other
// grid-layout rearrangement. Falls back to the raw photos list when
// the sequence isn't populated yet — relevant on a fresh page load
// where the user opened preview before the timeline mounted.
const photos: Photo[] = useMemo(() => {
if (visiblePhotoIds.length === 0) return rawPhotos
const byId = new Map(rawPhotos.map((p) => [p.id, p]))
const out: Photo[] = []
for (const id of visiblePhotoIds) {
const p = byId.get(id)
if (p) out.push(p)
}
return out
}, [visiblePhotoIds, rawPhotos])
const currentIndex = activePhotoId
? photos.findIndex((p) => p.id === activePhotoId)
: 0
const safeIndex = currentIndex < 0 ? 0 : currentIndex
const currentPhoto: Photo | undefined = photos[safeIndex]
// Keep the latest photos array + active id in a ref so the keyboard
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
// hook can fire a closure that captured an older photos array (e.g.
// the empty initial render before visiblePhotoIds was applied) and
// arrow nav lands on the wrong photo or no-ops.
const navRef = useRef({ photos, activePhotoId })
navRef.current = { photos, activePhotoId }
const goPrev = useCallback(() => {
const { photos: ps, activePhotoId: aid } = navRef.current
if (ps.length === 0) return
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
const safe = idx < 0 ? 0 : idx
const next = Math.max(0, safe - 1)
setActivePhoto(ps[next].id)
}, [setActivePhoto])
const goNext = useCallback(() => {
const { photos: ps, activePhotoId: aid } = navRef.current
if (ps.length === 0) return
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
const safe = idx < 0 ? 0 : idx
const next = Math.min(ps.length - 1, safe + 1)
setActivePhoto(ps[next].id)
}, [setActivePhoto])
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
// The handlers themselves are stable (refs internally) so the deps
// array stays empty — useHotkeys won't have to re-bind on every render.
useHotkeys('escape', closePreview, { preventDefault: true })
useHotkeys('left', goPrev, { preventDefault: true })
useHotkeys('right', goNext, { preventDefault: true })
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
// (browsers can't preload them via Image()) and skip when at the edges.
useEffect(() => {
const neighbors: Photo[] = []
if (safeIndex > 0) neighbors.push(photos[safeIndex - 1])
if (safeIndex < photos.length - 1) neighbors.push(photos[safeIndex + 1])
for (const p of neighbors) {
if (isVideo(p)) continue
const img = new Image()
img.src = getPreviewImageSrc(p)
}
}, [safeIndex, photos])
// Focus trap: focus the preview container on mount, restore focus on
// unmount. The container is keyboard-focusable (tabIndex=-1) so screen
// readers and tab navigation stay scoped here.
useEffect(() => {
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
containerRef.current?.focus()
return () => {
previouslyFocusedRef.current?.focus?.()
}
}, [])
// Trap Tab inside the dialog so users can't accidentally tab into the
// hidden grid behind. Simple cycle implementation.
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Tab') return
const root = containerRef.current
if (!root) return
const focusable = root.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (focusable.length === 0) {
e.preventDefault()
root.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement as HTMLElement | null
if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
if (!currentPhoto) {
return (
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label="Photo preview"
tabIndex={-1}
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
>
<div>No photo to display</div>
<button
onClick={closePreview}
className="mt-4 rounded border border-border px-3 py-1 text-sm hover:bg-surface"
>
Close
</button>
</div>
)
}
return (
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label={`Photo preview: ${currentPhoto.filename}`}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="fixed inset-0 z-40 flex bg-black outline-none"
>
{/* Main column — image + filmstrip */}
<div className="relative flex min-w-0 flex-1 flex-col">
{/* Filename + counter */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
<div className="font-mono">{currentPhoto.filename}</div>
<div className="text-text-muted">
{safeIndex + 1} / {photos.length}
</div>
</div>
{/* Top-right action buttons */}
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
onClick={() => setInfoPanelOpen((v) => !v)}
className={
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
(infoPanelOpen ? 'ring-2 ring-primary' : '')
}
title="Toggle info panel (I)"
aria-label="Toggle info panel"
aria-pressed={infoPanelOpen}
>
<Info className="h-5 w-5" />
</button>
<button
onClick={closePreview}
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
title="Close (Esc)"
aria-label="Close preview"
>
<X className="h-5 w-5" />
</button>
</div>
<PreviewImage photo={currentPhoto} />
<PreviewFilmstrip
photos={photos}
currentIndex={safeIndex}
onSelect={setActivePhoto}
/>
</div>
{/* Right info panel — slides in/out, mirrors the grid right sidebar
* but lives inside the preview overlay so it isn't covered by it. */}
{infoPanelOpen && (
<aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface">
<PhotoInfoPanel photoId={currentPhoto.id} />
</aside>
)}
</div>
)
}