Memories view now supports the same keyboard shortcuts, heap membership, and optimistic cache updates as the Timeline. Arrow/Ctrl+A/Escape nav is extracted into a shared useGridKeyNav hook so both views stay in lockstep. Duplicates view is virtualised with @tanstack/react-virtual and has stabilised PhotoThumbnail props so React.memo actually elides work when scrolling or toggling selection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { usePhotoStore } from '../store/photoStore'
|
|
|
|
/** A single row of the visual grid. Only the photo id is required —
|
|
* callers (Timeline, MemoriesView) can keep richer cell shapes but we
|
|
* only care about id for navigation. */
|
|
export interface GridNavRow {
|
|
cells: { id: string }[]
|
|
}
|
|
|
|
interface UseGridKeyNavArgs {
|
|
/** The flat, in-order grid rows the user sees. Navigation wraps at
|
|
* row ends and clamps to the destination row's width on vertical
|
|
* moves so half-full trailing rows don't land on empty cells. */
|
|
rows: GridNavRow[]
|
|
/** Gate the listener. Typically `viewMode === 'grid'` AND the view
|
|
* is mounted. When false, the window keydown listener is detached. */
|
|
enabled: boolean
|
|
/** Called after a successful arrow-nav move so the caller can scroll
|
|
* the destination row into view. No-op moves (same row, within
|
|
* viewport) still call it — the callback decides whether to scroll. */
|
|
scrollRowIntoView?: (rowIdx: number) => void
|
|
}
|
|
|
|
/**
|
|
* Shared arrow/Ctrl+A/Escape grid keyboard handler. Centralises the
|
|
* navigation logic Timeline and MemoriesView both need so they stay in
|
|
* lockstep — any future grid view can opt in by supplying its own
|
|
* `rows` and (optionally) a scroll callback.
|
|
*
|
|
* Scroll math stays with the caller because each grid measures row
|
|
* geometry differently (TanStack virtualizer for Timeline; native DOM
|
|
* offsets for the non-virtualised MemoriesView).
|
|
*/
|
|
export function useGridKeyNav({
|
|
rows,
|
|
enabled,
|
|
scrollRowIntoView,
|
|
}: UseGridKeyNavArgs) {
|
|
// Read inputs through a ref so the listener binds once per `enabled`
|
|
// toggle — without this the handler would re-attach on every render,
|
|
// churning the window event map.
|
|
const stateRef = useRef({ rows, scrollRowIntoView })
|
|
stateRef.current = { rows, scrollRowIntoView }
|
|
|
|
useEffect(() => {
|
|
if (!enabled) return
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const { rows, scrollRowIntoView } = stateRef.current
|
|
if (rows.length === 0) return
|
|
|
|
const target = e.target as HTMLElement | null
|
|
if (
|
|
target &&
|
|
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')
|
|
) {
|
|
return
|
|
}
|
|
|
|
const store = usePhotoStore.getState()
|
|
|
|
const findActive = (): { row: number; col: number } | null => {
|
|
const aid = store.activePhotoId
|
|
if (!aid) return null
|
|
for (let r = 0; r < rows.length; r++) {
|
|
const c = rows[r].cells.findIndex((cell) => cell.id === aid)
|
|
if (c >= 0) return { row: r, col: c }
|
|
}
|
|
return null
|
|
}
|
|
|
|
const move = (dr: number, dc: number) => {
|
|
const current = findActive() ?? { 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 = rows[nextRow].cells.length - 1
|
|
}
|
|
while (
|
|
nextRow < rows.length &&
|
|
nextCol >= rows[nextRow].cells.length
|
|
) {
|
|
if (nextRow === rows.length - 1) {
|
|
nextCol = rows[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 >= rows.length) nextRow = rows.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 = rows[nextRow].cells.length
|
|
if (nextCol >= rowLen) nextCol = rowLen - 1
|
|
if (nextCol < 0) nextCol = 0
|
|
}
|
|
|
|
const dest = rows[nextRow]?.cells[nextCol]
|
|
if (!dest) return
|
|
|
|
// Read fresh actions each call so the hook stays zero-dep.
|
|
const { selectRange, selectPhoto } = usePhotoStore.getState()
|
|
if (e.shiftKey) selectRange(dest.id)
|
|
else selectPhoto(dest.id)
|
|
|
|
scrollRowIntoView?.(nextRow)
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
move(-1, 0)
|
|
break
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
move(1, 0)
|
|
break
|
|
case 'ArrowLeft':
|
|
e.preventDefault()
|
|
move(0, -1)
|
|
break
|
|
case 'ArrowRight':
|
|
e.preventDefault()
|
|
move(0, 1)
|
|
break
|
|
case 'a':
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault()
|
|
const { selectedPhotos, togglePhotoSelection } =
|
|
usePhotoStore.getState()
|
|
const selected = new Set(selectedPhotos)
|
|
for (const row of rows) {
|
|
for (const cell of row.cells) {
|
|
if (!selected.has(cell.id)) togglePhotoSelection(cell.id)
|
|
}
|
|
}
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
usePhotoStore.getState().clearSelection()
|
|
break
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [enabled])
|
|
}
|