Files
mule-image/frontend/src/hooks/useKeyboardShortcuts.ts
dtoro 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

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