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>
353 lines
12 KiB
TypeScript
353 lines
12 KiB
TypeScript
import { useHotkeys } from 'react-hotkeys-hook'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { usePhotoStore } from '../store/photoStore'
|
|
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
|
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
|
import { toast } from '../components/ToastContainer'
|
|
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
|
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
|
|
|
interface KeyboardShortcutsProps {
|
|
onToggleLeftSidebar: () => void
|
|
onToggleRightSidebar: () => void
|
|
/** Returns the first photo id in the current timeline, or null if empty. */
|
|
getFirstPhotoId?: () => string | null
|
|
}
|
|
|
|
interface PhotoUpdate {
|
|
rating?: number
|
|
is_discarded?: boolean
|
|
color_label?: string | null
|
|
}
|
|
|
|
// Spec §6.4 number-key color labels.
|
|
const COLOR_LABELS: Record<string, string> = {
|
|
'6': 'red',
|
|
'7': 'orange',
|
|
'8': 'yellow',
|
|
'9': 'green',
|
|
}
|
|
|
|
// Default options shared by every shortcut: preventDefault stops the browser
|
|
// from claiming the event (Firefox quick-find on letter keys, Cmd+F search,
|
|
// `/` quick-find, Tab focus traversal). enableOnFormTags is left default-off
|
|
// so typing in inputs doesn't fire culling shortcuts.
|
|
const HK_OPTS = { preventDefault: true } as const
|
|
|
|
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
|
|
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
|
const openPreview = usePhotoStore((s) => s.openPreview)
|
|
const closePreview = usePhotoStore((s) => s.closePreview)
|
|
|
|
const isPreview = viewMode === 'preview'
|
|
|
|
// Photo mutation shared by every culling shortcut. Reads the active photo
|
|
// id from the store at fire time so the closure stays fresh without forcing
|
|
// hotkey re-binding on every selection change.
|
|
const queryClient = useQueryClient()
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ id, data }: { id: string; data: PhotoUpdate }) =>
|
|
photosApi.update(id, data),
|
|
onSuccess: (_data, vars) => {
|
|
queryClient.invalidateQueries({ queryKey: ['photo', vars.id] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
})
|
|
|
|
const invalidatePhotoQueries = () => {
|
|
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
|
}
|
|
|
|
const bulkRatingMutation = useMutation({
|
|
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
|
photosApi.bulkSetRating(ids, rating),
|
|
onSuccess: invalidatePhotoQueries,
|
|
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const bulkColorMutation = useMutation({
|
|
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
|
photosApi.bulkSetColor(ids, color),
|
|
onSuccess: invalidatePhotoQueries,
|
|
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const bulkDiscardMutation = useMutation({
|
|
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
|
onSuccess: invalidatePhotoQueries,
|
|
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
const bulkRestoreMutation = useMutation({
|
|
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
|
|
onSuccess: invalidatePhotoQueries,
|
|
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
|
|
})
|
|
|
|
/** The set of photo ids the next culling action should apply to.
|
|
* - Multi-selection → all selected photos
|
|
* - Single selection → that one photo
|
|
* - No selection but an activePhotoId set (last clicked) → that one
|
|
* - Otherwise → empty
|
|
*/
|
|
const cullTargets = (): string[] => {
|
|
const state = usePhotoStore.getState()
|
|
if (state.selectedPhotos.length > 0) return state.selectedPhotos
|
|
if (state.activePhotoId) return [state.activePhotoId]
|
|
return []
|
|
}
|
|
|
|
/** Apply a partial PhotoUpdate to the cull targets. Picks the right
|
|
* bulk endpoint when there are 2+ photos so a single API call covers
|
|
* the whole selection. */
|
|
const updateActive = (data: PhotoUpdate) => {
|
|
const ids = cullTargets()
|
|
if (ids.length === 0) return
|
|
|
|
if (ids.length === 1) {
|
|
const id = ids[0]
|
|
updateMutation.mutate(
|
|
{ id, data },
|
|
{
|
|
onSuccess: () => {
|
|
// Only the discard/restore subset of single-photo updates is
|
|
// undoable today — rating and color round-trip cleanly enough
|
|
// that the manual fix is faster than maintaining per-photo
|
|
// previous-value snapshots.
|
|
if (data.is_discarded === true) {
|
|
registerUndoable('Discarded 1 photo', async () => {
|
|
await photosApi.bulkRestore([id])
|
|
invalidatePhotoQueries()
|
|
})
|
|
} else if (data.is_discarded === false) {
|
|
registerUndoable('Restored 1 photo', async () => {
|
|
await photosApi.bulkDiscard([id])
|
|
invalidatePhotoQueries()
|
|
})
|
|
}
|
|
},
|
|
}
|
|
)
|
|
return
|
|
}
|
|
|
|
// Multi-selection — fan out to the right bulk endpoint per field.
|
|
if (data.rating !== undefined) {
|
|
bulkRatingMutation.mutate({ ids, rating: data.rating })
|
|
}
|
|
if (data.color_label !== undefined) {
|
|
bulkColorMutation.mutate({ ids, color: data.color_label })
|
|
}
|
|
if (data.is_discarded === true) {
|
|
bulkDiscardMutation.mutate(ids, {
|
|
onSuccess: () => {
|
|
registerUndoable(
|
|
`Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
|
async () => {
|
|
await photosApi.bulkRestore(ids)
|
|
invalidatePhotoQueries()
|
|
}
|
|
)
|
|
},
|
|
})
|
|
} else if (data.is_discarded === false) {
|
|
bulkRestoreMutation.mutate(ids, {
|
|
onSuccess: () => {
|
|
registerUndoable(
|
|
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
|
async () => {
|
|
await photosApi.bulkDiscard(ids)
|
|
invalidatePhotoQueries()
|
|
}
|
|
)
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
// P key (Pick): toggle the current selection's membership in the active
|
|
// heap. If every selected photo is already a member, remove them; otherwise
|
|
// add the missing ones. No active heap → toast hint.
|
|
const heapMutation = useMutation({
|
|
mutationFn: ({
|
|
heapId,
|
|
photoIds,
|
|
remove,
|
|
}: {
|
|
heapId: string
|
|
photoIds: string[]
|
|
remove: boolean
|
|
}) =>
|
|
remove
|
|
? heapsApi.removePhotos(heapId, photoIds)
|
|
: heapsApi.addPhotos(heapId, photoIds),
|
|
// Optimistically flip the membership cache so the basket affordance
|
|
// updates instantly and a quick second P press reads the new state
|
|
// (otherwise invalidate-then-refetch leaves a brief stale window).
|
|
onMutate: ({ heapId, photoIds, remove }) => {
|
|
const key = ['heap-photo-ids', heapId] as const
|
|
const previous = queryClient.getQueryData<string[]>(key)
|
|
const set = new Set(previous ?? [])
|
|
if (remove) photoIds.forEach((id) => set.delete(id))
|
|
else photoIds.forEach((id) => set.add(id))
|
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
|
return { previous }
|
|
},
|
|
onError: (e: any, _vars, ctx) => {
|
|
// Roll back the optimistic update on failure.
|
|
if (ctx?.previous) {
|
|
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
|
}
|
|
toast.error('Heap update failed', e.message || 'Unknown error')
|
|
},
|
|
onSuccess: (data, vars) => {
|
|
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
|
(h) => h.id === vars.heapId
|
|
)
|
|
const heapName = heap?.name ?? 'heap'
|
|
if (vars.remove) {
|
|
const removed = data?.removed ?? 0
|
|
toast.success(`Removed from ${heapName}`, `${removed} photo${removed === 1 ? '' : 's'}`)
|
|
} else {
|
|
const added = data?.added ?? 0
|
|
const already = data?.already_present ?? 0
|
|
if (added > 0) {
|
|
toast.success(
|
|
`Added to ${heapName}`,
|
|
`${added} photo${added > 1 ? 's' : ''}${already > 0 ? ` (${already} already present)` : ''}`
|
|
)
|
|
}
|
|
}
|
|
},
|
|
onSettled: (_data, _err, vars) => {
|
|
// Re-sync with server truth (heap counts in particular need this).
|
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
|
queryClient.invalidateQueries({ queryKey: ['heap-photo-ids', vars.heapId] })
|
|
},
|
|
})
|
|
|
|
const togglePickOnSelection = () => {
|
|
const state = usePhotoStore.getState()
|
|
const ids =
|
|
state.selectedPhotos.length > 0
|
|
? state.selectedPhotos
|
|
: state.activePhotoId
|
|
? [state.activePhotoId]
|
|
: []
|
|
if (ids.length === 0) {
|
|
toast.info('Nothing selected', 'Select photos first, then press P')
|
|
return
|
|
}
|
|
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
|
|
const active = heapsList.find((h) => h.is_active)
|
|
if (!active) {
|
|
toast.info('No active heap', 'Set an active heap (target icon next to a heap)')
|
|
return
|
|
}
|
|
// Determine direction: if every selected photo is already a member, this
|
|
// press REMOVES them; otherwise it ADDS the missing ones. Mirrors how
|
|
// Lightroom's flag-toggle works.
|
|
const memberIds =
|
|
queryClient.getQueryData<string[]>(['heap-photo-ids', active.id]) ?? []
|
|
const memberSet = new Set(memberIds)
|
|
const allMembers = ids.every((id) => memberSet.has(id))
|
|
heapMutation.mutate({
|
|
heapId: active.id,
|
|
photoIds: ids,
|
|
remove: allMembers,
|
|
})
|
|
}
|
|
|
|
// Toggle sidebars. The right sidebar `i` shortcut is grid-only — in
|
|
// preview mode the PreviewView mounts its own `i` handler for the
|
|
// overlay info panel, and we don't want both to fire.
|
|
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
|
useHotkeys('i', onToggleRightSidebar, { ...HK_OPTS, enabled: !isPreview })
|
|
|
|
// Cmd/Ctrl+Z → pop the most recent undoable action and reverse it.
|
|
// Bound at the global level so it works in both grid and preview modes.
|
|
useHotkeys(
|
|
'mod+z',
|
|
async () => {
|
|
const entry = useUndoStore.getState().pop()
|
|
if (!entry) {
|
|
toast.info('Nothing to undo')
|
|
return
|
|
}
|
|
try {
|
|
await entry.undo()
|
|
} catch (e: any) {
|
|
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
|
toast.error('Undo failed', e?.message || 'Unknown error')
|
|
}
|
|
},
|
|
HK_OPTS
|
|
)
|
|
|
|
// Search focus (/ or Cmd/Ctrl+F).
|
|
const focusSearch = () => {
|
|
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
|
el?.focus()
|
|
el?.select()
|
|
}
|
|
useHotkeys('/', focusSearch, HK_OPTS)
|
|
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
|
|
|
// Space toggles the preview view (open from grid, close from preview).
|
|
// Double-click on a thumbnail does the same.
|
|
const openPreviewFromGrid = () => {
|
|
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 = () => {
|
|
if (isPreview) closePreview()
|
|
else openPreviewFromGrid()
|
|
}
|
|
|
|
useHotkeys('space', togglePreview, HK_OPTS, [isPreview, getFirstPhotoId])
|
|
|
|
// ── Culling shortcuts (work in both grid and preview) ────────────────────
|
|
|
|
// Star rating: 1-5 set, 0 clears.
|
|
useHotkeys(
|
|
'1,2,3,4,5',
|
|
(_e, handler) => {
|
|
const rating = parseInt(handler.keys![0])
|
|
if (Number.isFinite(rating)) updateActive({ rating })
|
|
},
|
|
HK_OPTS
|
|
)
|
|
|
|
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
|
|
|
|
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
|
|
// adding it to the heap you set as active. Toggling on already-picked
|
|
// photos removes them from the heap.
|
|
useHotkeys('p', togglePickOnSelection, HK_OPTS)
|
|
|
|
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
|
|
|
|
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)
|
|
|
|
// Color labels 6-9 (red/orange/yellow/green per spec §6.4).
|
|
useHotkeys(
|
|
'6,7,8,9',
|
|
(_e, handler) => {
|
|
const label = COLOR_LABELS[handler.keys![0]]
|
|
if (label) updateActive({ color_label: label })
|
|
},
|
|
HK_OPTS
|
|
)
|
|
|
|
}
|