feat: snappier timeline — instant discard, progressive load, restore preview origin
- Discard yanks photos from the grid optimistically (cache strip + active cursor advance) instead of waiting for the mutation round-trip; wired through the X hotkey, RightSidebar bulk discard, LeftSidebar discard drop, and DiscardActionBar restore/delete. - usePhotosQuery resolves on the first 500-photo page and streams the remaining pages into the cache in the background, so the first thumbnails paint immediately on large libraries. - Closing preview restores the photo it was opened on (snapshot ref in PreviewView, written directly to the store) and Timeline scrolls that row back into view. Escape is handled on the dialog with stopPropagation so Timeline's window-level Esc handler doesn't wipe the restored selection. - Preview overlay bumped to z-[1000] so it covers Leaflet map tiles, and the right sidebar no longer collapses during preview — both fix visible layout shifts on close. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,38 @@ 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)
|
||||
|
||||
// Snapshot the photo we opened on, captured once at mount via the
|
||||
// store's getState (which is guaranteed to reflect the value the
|
||||
// openPreview action just wrote, even if the React subscription
|
||||
// hasn't been delivered to this component's first render yet). This
|
||||
// is the id we'll restore on close, no matter how many neighbours
|
||||
// the user arrows through inside the preview.
|
||||
const openOriginRef = useRef<string | null>(
|
||||
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
||||
)
|
||||
const closePreview = useCallback(() => {
|
||||
// Bypass the store action and write the restoration directly so
|
||||
// the snapshot ref is the single source of truth. Falls back to
|
||||
// the live activePhotoId if the ref was somehow never populated
|
||||
// (defensive — openPreview always sets activePhotoId before
|
||||
// PreviewView mounts).
|
||||
const id =
|
||||
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
|
||||
usePhotoStore.setState({
|
||||
viewMode: 'grid',
|
||||
activePhotoId: id,
|
||||
rangeStartId: id,
|
||||
selectedPhotos: id ? [id] : [],
|
||||
previewOriginPhotoId: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 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()
|
||||
@@ -93,7 +118,16 @@ export function PreviewView() {
|
||||
// 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 })
|
||||
//
|
||||
// Escape is intentionally NOT bound through useHotkeys here. The
|
||||
// grid-level Timeline component listens for Escape on `window` to
|
||||
// clear the current selection, and react-hotkeys-hook binds at
|
||||
// document level — so a single Esc keypress would land in BOTH
|
||||
// handlers. The grid handler would then wipe the selection we just
|
||||
// restored from the preview origin. Instead, escape is handled by
|
||||
// the dialog's onKeyDown below, which runs first (lower in the
|
||||
// bubble chain) and calls stopPropagation so the keypress never
|
||||
// reaches window.
|
||||
useHotkeys('left', goPrev, { preventDefault: true })
|
||||
useHotkeys('right', goNext, { preventDefault: true })
|
||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||
@@ -123,8 +157,17 @@ export function PreviewView() {
|
||||
}, [])
|
||||
|
||||
// Trap Tab inside the dialog so users can't accidentally tab into the
|
||||
// hidden grid behind. Simple cycle implementation.
|
||||
// hidden grid behind. Simple cycle implementation. Also intercepts
|
||||
// Escape and stops propagation before the keypress reaches the
|
||||
// window-level handler in Timeline (which would otherwise wipe the
|
||||
// selection we just restored to the entry photo).
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const root = containerRef.current
|
||||
if (!root) return
|
||||
@@ -156,7 +199,7 @@ export function PreviewView() {
|
||||
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"
|
||||
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
>
|
||||
<div>No photo to display</div>
|
||||
<button
|
||||
@@ -177,7 +220,7 @@ export function PreviewView() {
|
||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||
className="fixed inset-0 z-[1000] flex bg-black outline-none"
|
||||
>
|
||||
{/* Main column — image + filmstrip */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
|
||||
Reference in New Issue
Block a user