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>
216 lines
8.0 KiB
TypeScript
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>
|
|
)
|
|
}
|