fix: arrow key navigation matches the visual grid
The Timeline arrow keys moved by currentIndex ± columns in the FLAT
photos array, but with date / tag grouping the rendered grid has
half-full last rows for each group, so flat-index nav routinely
landed in the wrong cell — and tag grouping (where one photo can
appear in multiple groups) made it incoherent.
Fix: navigate the actual visual grid the user sees.
- New photoRows = items.filter(type='row') in visual order. The
buildItems pipeline already chunks photos into row items of
[1..columns] cells per group; this is exactly the rendered layout.
- findActiveCell() walks photoRows looking for the activePhotoId
and returns its (rowIndex, colIndex), or null if it isn't on
screen. First-occurrence wins, which matches user intuition in
the tag-grouped view.
- New move(dr, dc) helper:
Left/Right: walk col, wrap across row boundaries (so going Right
off the end of a half-full row jumps to the next group's first
row). Clamps at the very first/last cell.
Up/Down: change row, then clamp the column to the destination
row's actual width — moving down into a 2-cell row from col 3
lands on col 1, not nothing.
- The four arrow handlers all funnel through move(); shift-arrow
still calls selectRange with the destination cell's globalIndex
so range selection works the same as a shift-click on that cell.
- Headers are skipped automatically because they were never in
photoRows. Edge cells, end-of-group, single-row groups, and
tag-repeated photos all behave consistently.
Pulled activePhotoId out of usePhotoStore (was already in the store
but the Timeline component wasn't reading it). Effect deps updated
to invalidate the listener whenever the visible grid changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -299,10 +299,10 @@ export function LeftSidebar() {
|
|||||||
onDoubleClick={
|
onDoubleClick={
|
||||||
item.id.startsWith('folder-')
|
item.id.startsWith('folder-')
|
||||||
? (e) => {
|
? (e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setRenamingId(item.id)
|
setRenamingId(item.id)
|
||||||
setRenameDraft(item.label)
|
setRenameDraft(item.label)
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onDragOver={acceptsDrop ? (e) => {
|
onDragOver={acceptsDrop ? (e) => {
|
||||||
@@ -405,7 +405,7 @@ export function LeftSidebar() {
|
|||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
{/* Sidebar Header */}
|
{/* Sidebar Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||||
<h2 className="text-sm font-semibold text-text">Library</h2>
|
<h2 className="text-sm font-semibold text-text">Views</h2>
|
||||||
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
selectedPhotos,
|
selectedPhotos,
|
||||||
|
activePhotoId,
|
||||||
lastSelectedIndex,
|
lastSelectedIndex,
|
||||||
rangeStartIndex,
|
rangeStartIndex,
|
||||||
selectPhoto,
|
selectPhoto,
|
||||||
@@ -282,50 +283,102 @@ export function Timeline() {
|
|||||||
return () => window.removeEventListener('resize', measureWidth)
|
return () => window.removeEventListener('resize', measureWidth)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Handle keyboard shortcuts for photo navigation. Operates on the flat
|
// Photo rows in visual order — drops the header items so navigation
|
||||||
// photos array, so it ignores grouping.
|
// walks the grid as the user sees it. Each row has cells of length
|
||||||
|
// [1..columns], the last row of a group can be short, and a single
|
||||||
|
// photo with multiple tags will appear in multiple rows.
|
||||||
|
const photoRows = useMemo(
|
||||||
|
() => items.filter((it): it is Extract<TimelineItem, { type: 'row' }> => it.type === 'row'),
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Locate the active photo in the visual grid. Returns the FIRST
|
||||||
|
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
||||||
|
// can repeat a photo across groups. Returns null when there's no
|
||||||
|
// active photo or it isn't currently rendered.
|
||||||
|
const findActiveCell = (): { row: number; col: number } | null => {
|
||||||
|
if (!activePhotoId) return null
|
||||||
|
for (let r = 0; r < photoRows.length; r++) {
|
||||||
|
const row = photoRows[r]
|
||||||
|
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
|
||||||
|
if (c >= 0) return { row: r, col: c }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (photos.length === 0) return
|
if (photoRows.length === 0) return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIndex = lastSelectedIndex ?? -1
|
const move = (dr: number, dc: number) => {
|
||||||
|
const current = findActiveCell() ?? { row: 0, col: -1 }
|
||||||
|
let nextRow = current.row
|
||||||
|
let nextCol = current.col + dc
|
||||||
|
|
||||||
|
if (dc !== 0) {
|
||||||
|
// Wrap left/right across row boundaries.
|
||||||
|
while (nextCol < 0 && nextRow > 0) {
|
||||||
|
nextRow -= 1
|
||||||
|
nextCol = photoRows[nextRow].cells.length - 1
|
||||||
|
}
|
||||||
|
while (
|
||||||
|
nextRow < photoRows.length &&
|
||||||
|
nextCol >= photoRows[nextRow].cells.length
|
||||||
|
) {
|
||||||
|
if (nextRow === photoRows.length - 1) {
|
||||||
|
nextCol = photoRows[nextRow].cells.length - 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextRow += 1
|
||||||
|
nextCol = 0
|
||||||
|
}
|
||||||
|
if (nextCol < 0) nextCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dr !== 0) {
|
||||||
|
nextRow += dr
|
||||||
|
if (nextRow < 0) nextRow = 0
|
||||||
|
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
|
||||||
|
// Clamp the column to the destination row's actual width so
|
||||||
|
// moving down into a half-full row lands on its last cell
|
||||||
|
// instead of nothing.
|
||||||
|
const rowLen = photoRows[nextRow].cells.length
|
||||||
|
if (nextCol >= rowLen) nextCol = rowLen - 1
|
||||||
|
if (nextCol < 0) nextCol = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const dest = photoRows[nextRow]?.cells[nextCol]
|
||||||
|
if (!dest) return
|
||||||
|
if (e.shiftKey) {
|
||||||
|
selectRange(dest.globalIndex)
|
||||||
|
} else {
|
||||||
|
selectPhoto(dest.photo.id, dest.globalIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex > columns - 1) {
|
move(-1, 0)
|
||||||
const newIndex = currentIndex - columns
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex < photos.length - columns) {
|
move(1, 0)
|
||||||
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex > 0) {
|
move(0, -1)
|
||||||
const newIndex = currentIndex - 1
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (currentIndex < photos.length - 1) {
|
move(0, 1)
|
||||||
const newIndex = currentIndex + 1
|
|
||||||
if (e.shiftKey) selectRange(newIndex)
|
|
||||||
else selectPhoto(photos[newIndex].id, newIndex)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'a':
|
case 'a':
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -347,7 +400,7 @@ export function Timeline() {
|
|||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [photos, selectedPhotos, lastSelectedIndex, columns])
|
}, [photoRows, photos, selectedPhotos, activePhotoId])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user