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]) }