Files
mule-image/frontend/src/hooks/useKeyboardShortcuts.ts
dtoro ee6b49952e fix: optimistic membership cache so P toggle is instant and reliable
P already toggled membership in the active heap (remove if every
selected photo is a member, otherwise add the missing ones), but the
mutation only invalidate-then-refetched the heap-photo-ids cache on
success. Pressing P twice in quick succession could read the stale
cache and mis-toggle.

Both heap-toggle mutations (useKeyboardShortcuts P shortcut and the
RightSidebar Pick button) now do an optimistic update in onMutate:
- Read the current ['heap-photo-ids', heapId] cache
- Add or remove the affected ids in a Set
- Write the new array back via setQueryData
- Roll back from the captured `previous` on error
- Re-sync via invalidateQueries in onSettled (heap counts in particular
  still need server truth)

Result: the basket affordance flips the moment you press P, and a
quick second press always reads the new state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:24:29 +02:00

220 lines
7.7 KiB
TypeScript

import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
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 updateActive = (data: PhotoUpdate) => {
const id = usePhotoStore.getState().activePhotoId
if (!id) return
updateMutation.mutate({ id, data })
}
// 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
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS)
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 id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
if (id) openPreview(id)
}
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
)
}