5 Commits

Author SHA1 Message Date
eb16564b84 fix: gate Timeline arrow keys to grid mode
PreviewView mounts its own arrow handlers via useHotkeys. The Timeline
also installed a window-level keydown listener for grid arrow nav, with
no viewMode check, so in preview mode BOTH handlers fired on every
arrow press and raced to call setActivePhoto. The grid handler walks
photoRows (grid cells) while preview walks the visible-order array,
and whichever store update landed last won, making preview nav land on
the wrong photo.

Telltale: Shift+arrow worked because PreviewView's plain useHotkeys
('left'/'right') doesn't match Shift+arrow, so only Timeline fired and
its visual-grid path got the right neighbor.

Fix: early-return Timeline's keyboard effect when viewMode !== 'grid'.
The listener stays attached to viewMode in the dep array so it
re-engages instantly on closePreview.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:14:19 +02:00
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
123c60ed2c fix: visible-order range selection + preview-after-Space sequence
Two related bugs around visible vs API order.

1. Multi-select range selection (Shift+Click, Shift+Arrow):
- The previous selectRange walked the API photos array and only
  ADDED to the existing selection, never replacing or shrinking. So
  Shift+clicking to the left often "did nothing" (already-selected
  ids skipped) and the selection never matched the user's intended
  range.
- Replace with a store action that walks visiblePhotoIds (the visual
  row-major sequence Timeline already publishes), de-dupes ids
  (tag-grouped views can repeat photos), and REPLACES the selection.
- Track the range anchor as rangeStartId (a photo id) instead of an
  index so it survives filter changes and works correctly when API
  index != visual position.
- Drop the now-redundant lastSelectedIndex / globalIndex plumbing
  from selectPhoto / togglePhotoSelection — call sites simplify to
  pass just the photo id.

2. Preview navigation after pressing Space:
- The Space hotkey path called openPreview(id) without a sequence
  and relied on the store's fallback to whatever Timeline most
  recently published. Make it explicit: read visiblePhotoIds from
  the store snapshot at fire time and pass it through. Same effect
  in the happy case but eliminates any subtle publisher timing
  question.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:04:18 +02:00
e2ee9b691c fix: pass visible sequence to openPreview from the click site
Previously the visible photo order was published only via a passive
useEffect on Timeline, which had a timing race: arrow nav in preview
could read a stale or empty sequence and fall back to the raw API
order, breaking visual order navigation in tag mode and after
filter changes.

Fix: openPreview now accepts an optional visibleSequence parameter,
and Timeline's onDoubleClick passes the freshly-computed flat
sequence directly. The store action adopts that sequence as the
authoritative visiblePhotoIds for the preview session, falling back
to the most-recently-published one for paths that don't have a click
site (e.g. the global Space hotkey).

The Timeline still publishes via useEffect for the Space-hotkey
fallback path, but the click path no longer depends on it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:52:42 +02:00
57f510ad3d fix: keep keyboard hints on a single line
Add whitespace-nowrap to the hints pill container plus the action and
selection-count spans so labels like "Pick → heap" and "1 selected"
no longer break across rows. The pill is an absolute overlay with no
width constraint, so growing horizontally is fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:46:27 +02:00
5 changed files with 164 additions and 84 deletions

View File

@@ -30,13 +30,15 @@ export function KeyboardHints() {
// inside the main column in App.tsx so it isn't offset by the
// sidebar widths.
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
{hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
{hint.key}
</kbd>
<span className="text-xs text-text-muted">{hint.action}</span>
<span className="whitespace-nowrap text-xs text-text-muted">
{hint.action}
</span>
{i < hints.length - 1 && (
<span className="ml-1 text-text-faint"></span>
)}
@@ -45,7 +47,7 @@ export function KeyboardHints() {
{selectedCount > 0 && (
<>
<span className="text-text-faint"></span>
<span className="text-xs font-medium text-primary">
<span className="whitespace-nowrap text-xs font-medium text-primary">
{selectedCount} selected
</span>
</>

View File

@@ -45,22 +45,38 @@ export function PreviewView() {
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(() => {
if (photos.length === 0) return
const next = Math.max(0, safeIndex - 1)
setActivePhoto(photos[next].id)
}, [photos, safeIndex, setActivePhoto])
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(() => {
if (photos.length === 0) return
const next = Math.min(photos.length - 1, safeIndex + 1)
setActivePhoto(photos[next].id)
}, [photos, safeIndex, setActivePhoto])
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 }, [goPrev])
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
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

View File

@@ -166,10 +166,9 @@ export function Timeline() {
const {
selectedPhotos,
activePhotoId,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
togglePhotoSelection,
selectRange,
clearSelection,
openPreview,
} = usePhotoStore()
@@ -179,6 +178,7 @@ export function Timeline() {
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
const viewMode = usePhotoStore((s) => s.viewMode)
// Calculate number of columns based on container width.
const columns = useMemo(() => {
@@ -220,19 +220,6 @@ export function Timeline() {
return result
}, [items])
// Range-selection helper. Operates on the global photos array, not on
// virtualizer items.
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Virtual scrolling setup with per-item heights.
const virtualizer = useVirtualizer({
count: items.length,
@@ -297,20 +284,27 @@ export function Timeline() {
[items]
)
// Publish the flat visible-order id sequence to the photo store so
// PreviewView arrow nav (and the filmstrip) walks the same order the
// user sees in the grid. Includes duplicates from tag grouping
// landing on the same photo's "second" appearance in the next tag
// bucket is the right behavior in tag mode.
useEffect(() => {
// Flat visible-order id sequence — exactly the order the user reads
// off the grid (top-to-bottom, left-to-right within each row).
// Includes duplicates from tag-grouping; landing on the same photo's
// "second" appearance in the next tag bucket is the right behavior
// in tag mode.
const visibleSequence = useMemo(() => {
const ids: string[] = []
for (const row of photoRows) {
for (const cell of row.cells) {
ids.push(cell.photo.id)
}
}
setVisiblePhotoIds(ids)
}, [photoRows, setVisiblePhotoIds])
return ids
}, [photoRows])
// Publish to the photo store so PreviewView's arrow nav and filmstrip
// can walk the same order even when opened from a non-click path
// (e.g. the global Space hotkey).
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
@@ -329,7 +323,14 @@ export function Timeline() {
// Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place.
//
// Inert in preview mode — PreviewView mounts its own arrow handlers,
// and a window-level grid handler firing alongside them used to race
// against PreviewView's setActivePhoto, landing the user on the wrong
// photo. The grid handler stays attached so it can re-engage the
// moment the user closes preview.
useEffect(() => {
if (viewMode !== 'grid') return
const handleKeyDown = (e: KeyboardEvent) => {
if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
@@ -377,9 +378,9 @@ export function Timeline() {
const dest = photoRows[nextRow]?.cells[nextCol]
if (!dest) return
if (e.shiftKey) {
selectRange(dest.globalIndex)
selectRange(dest.photo.id)
} else {
selectPhoto(dest.photo.id, dest.globalIndex)
selectPhoto(dest.photo.id)
}
}
@@ -403,9 +404,9 @@ export function Timeline() {
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
photos.forEach((photo, index) => {
photos.forEach((photo) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index)
togglePhotoSelection(photo.id)
}
})
}
@@ -420,7 +421,7 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [photoRows, photos, selectedPhotos, activePhotoId])
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId])
if (isLoading) {
return (
@@ -502,7 +503,7 @@ export function Timeline() {
}}
>
<div className="flex" style={{ gap: `${GAP}px` }}>
{item.cells.map(({ photo, globalIndex }) => (
{item.cells.map(({ photo }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
@@ -510,15 +511,15 @@ export function Timeline() {
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
if (e.shiftKey) {
selectRange(photo.id)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
togglePhotoSelection(photo.id)
} else {
selectPhoto(photo.id, globalIndex)
selectPhoto(photo.id)
}
}}
onDoubleClick={() => openPreview(photo.id)}
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
/>
))}
</div>

View File

@@ -300,8 +300,13 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
// Space toggles the preview view (open from grid, close from preview).
// Double-click on a thumbnail does the same.
const openPreviewFromGrid = () => {
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openPreview(id)
const state = usePhotoStore.getState()
const id = state.activePhotoId ?? getFirstPhotoId?.() ?? null
if (!id) return
// Pass the current visible sequence explicitly so preview navigation
// walks the order the user actually sees, even if the active photo
// was selected before the publisher caught up.
openPreview(id, state.visiblePhotoIds)
}
const togglePreview = () => {

View File

@@ -7,26 +7,37 @@ interface PhotoStore {
photos: Photo[]
selectedPhotos: string[]
activePhotoId: string | null
lastSelectedIndex: number | null
rangeStartIndex: number | null
/** The id of the photo where the current range-selection anchor lives.
* Set when the user clicks (without modifiers) or arrow-navigates,
* read by selectRange to figure out the start of a Shift+Click /
* Shift+Arrow range. Tracked as an id (not an index) so it survives
* filter changes and works correctly in tag-grouped mode where
* visual indices != API indices. */
rangeStartId: string | null
viewMode: ViewMode
/** Flat sequence of photo ids in the order they currently appear in
* the timeline grid (including duplicates from tag-grouping). The
* preview view walks this sequence so arrow nav matches the order
* the user actually sees. Owned by the Timeline component, which
* rewrites it whenever its layout items change. */
* the timeline grid (including duplicates from tag-grouping). Used
* for both preview navigation order and range selection. Owned by
* the Timeline component. */
visiblePhotoIds: string[]
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
togglePhotoSelection: (id: string, index: number) => void
selectRange: (endIndex: number) => void
selectPhoto: (id: string) => void
togglePhotoSelection: (id: string) => void
/** Replace the selection with every photo between the current
* rangeStartId and the supplied endId, walking visiblePhotoIds in
* visual order. If there's no anchor yet, anchors at endId. */
selectRange: (endId: string) => void
deselectPhoto: (id: string) => void
clearSelection: () => void
setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
setVisiblePhotoIds: (ids: string[]) => void
openPreview: (id: string) => void
/** Open preview on a specific photo. The visibleSequence (optional)
* is the ordered list of photo ids the user currently sees in the
* timeline; passing it from the click site avoids a race where the
* passive Timeline publisher hasn't updated yet. */
openPreview: (id: string, visibleSequence?: string[]) => void
closePreview: () => void
}
@@ -34,38 +45,72 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
photos: [],
selectedPhotos: [],
activePhotoId: null,
lastSelectedIndex: null,
rangeStartIndex: null,
rangeStartId: null,
viewMode: 'grid',
visiblePhotoIds: [],
setPhotos: (photos) => set({ photos }),
selectPhoto: (id, index) => set({
selectPhoto: (id) => set({
selectedPhotos: [id],
activePhotoId: id,
lastSelectedIndex: index,
rangeStartIndex: index,
rangeStartId: id,
}),
togglePhotoSelection: (id, index) => set((state) => {
togglePhotoSelection: (id) => set((state) => {
const isSelected = state.selectedPhotos.includes(id)
return {
selectedPhotos: isSelected
? state.selectedPhotos.filter(photoId => photoId !== id)
? state.selectedPhotos.filter((photoId) => photoId !== id)
: [...state.selectedPhotos, id],
lastSelectedIndex: index,
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
// A toggle-add anchors the range here so a subsequent Shift+click
// extends from this id. A toggle-remove leaves the anchor alone.
rangeStartId: isSelected ? state.rangeStartId : id,
activePhotoId: id,
}
}),
selectRange: (endIndex) => {
// Note: The actual range selection logic should be handled in the Timeline component
// which has access to the photos array
set({
lastSelectedIndex: endIndex,
})
},
selectRange: (endId) =>
set((state) => {
const ids = state.visiblePhotoIds
// No published sequence yet → just behave like a single-pick.
if (ids.length === 0) {
return {
selectedPhotos: [endId],
activePhotoId: endId,
rangeStartId: endId,
}
}
const anchorId = state.rangeStartId ?? state.activePhotoId ?? endId
const startIdx = ids.indexOf(anchorId)
const endIdx = ids.indexOf(endId)
if (startIdx < 0 || endIdx < 0) {
return {
selectedPhotos: [endId],
activePhotoId: endId,
rangeStartId: endId,
}
}
const min = Math.min(startIdx, endIdx)
const max = Math.max(startIdx, endIdx)
// De-dupe because the visible sequence can repeat photos in
// tag-grouped mode.
const seen = new Set<string>()
const next: string[] = []
for (let i = min; i <= max; i++) {
const id = ids[i]
if (!seen.has(id)) {
seen.add(id)
next.push(id)
}
}
return {
selectedPhotos: next,
activePhotoId: endId,
// Anchor stays put — the user can keep extending from the
// original click point, matching Lightroom / Finder behavior.
}
}),
deselectPhoto: (id) => set((state) => ({
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
@@ -73,8 +118,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
clearSelection: () => set({
selectedPhotos: [],
lastSelectedIndex: null,
rangeStartIndex: null,
rangeStartId: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),
@@ -99,7 +143,19 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
return { visiblePhotoIds }
}),
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
openPreview: (id, visibleSequence) =>
set((s) => ({
viewMode: 'preview',
activePhotoId: id,
// Adopt the caller-provided sequence when they pass one. Falls
// back to whatever Timeline most recently published, which is
// correct for paths like the global Space hotkey that don't have
// a click site to compute the sequence from.
visiblePhotoIds:
visibleSequence && visibleSequence.length > 0
? visibleSequence
: s.visiblePhotoIds,
})),
closePreview: () => set({ viewMode: 'grid' }),
}))