import { toast } from 'svelte-sonner'; import { batchEdit } from '$lib/services/batch'; import { invalidatePhotos } from '$lib/services/bulk'; import { addToHeap, approvePhoto, batchArchive, batchDelete, batchRestore, removeFromHeap, type PpAlbum } from '$lib/services/photoprism'; import { queryClient } from '$lib/queryClient'; import { filters } from '$lib/stores/filters.svelte'; import { clearBulkToFirst, clearSelection, focusAfter, indexOf, selectRange, selection, setAnchor, setFocused, toggle } from '$lib/stores/selection.svelte'; import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; import type { PpPhoto } from '$lib/types/photoprism'; /** * Optional parameters the host passes via `use:gridKeyNav={...}`. * * - `scrollToIndex`: invoked when the action's own arrow-nav lands on a * tile that's currently windowed-out of the DOM. The host expands its * render window and scrolls the now-mounted shell into view. * - `onArrow`: when provided, the action delegates ALL arrow keys to the * host instead of computing moves itself. Required for grids with * interleaved non-tile rows (e.g. month headers): linear +/-cols math * skips wrong because the column count of header rows is 1 (full-span), * not the tile column count. The host owns the visual-row map and * handles the (row, col) translation. Mirrors mule-image's * `useGridKeyNav` pattern. */ export type ArrowKey = 'ArrowLeft' | 'ArrowRight' | 'ArrowUp' | 'ArrowDown'; export interface GridKeyNavParams { scrollToIndex?: (i: number) => void; onArrow?: (key: ArrowKey, extending: boolean) => void; } /** * Svelte `action` for the timeline grid. Owns: * - Arrow-key focus navigation (with shift-extend) inside the visible grid * - Click + shift/ctrl click selection mutations * - Window-level shortcuts mirroring mule-image's keyboard layer: * x archive-toggle, u restore, s + (1–9) add to * heap N (bare s adds to the currently-viewed heap), b/Tab toggles * left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes, * ⌘A selects all visible. * Rating + color labels are mouse-driven via the metadata sidebar — no * keyboard shortcuts. * * Archive / restore target a synthesized "cull target list" — in priority: * 1. multi-selection set * 2. focused tile */ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) { let scrollToIndex = params.scrollToIndex; let onArrow = params.onArrow; /** Cached column count for the visible grid. Read from CSS * (`grid-template-columns` resolves to a space-separated list of px * sizes), invalidated by a `ResizeObserver` on the grid host. This * keeps the read DOM-cheap regardless of how many tiles are mounted — * critical once windowing renders only a slice of the order. */ let cachedCols: number | null = null; let gridEl: HTMLElement | null = null; function findGrid(): HTMLElement | null { // The `[role="group"][aria-label="Photos"]` or simply the first // element whose computed grid-template-columns has >1 track. The // timeline grid sits inside `node` (the action target =
). if (gridEl && node.contains(gridEl)) return gridEl; const candidate = node.querySelector('[data-photo-grid]'); if (candidate) { gridEl = candidate; return candidate; } // Fallback: the first descendant that's display: grid with ≥2 cols. // Avoids a hard coupling on the data-attribute in case the host // hasn't tagged it yet. for (const el of node.querySelectorAll('*')) { const cs = getComputedStyle(el); if (cs.display === 'grid' && cs.gridTemplateColumns.split(' ').length > 1) { gridEl = el; return el; } } return null; } function tilesPerRow(): number { if (cachedCols !== null) return cachedCols; const grid = findGrid(); if (!grid) return 1; const cols = getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length; cachedCols = Math.max(1, cols); return cachedCols; } const ro = new ResizeObserver(() => { // Container width changed → column count likely changed too. // Cheaper to invalidate than to recompute; tilesPerRow recomputes // on next access (which is per keystroke at most). cachedCols = null; }); ro.observe(node); function focusedIndex(): number { return indexOf(selection.focused); } /** Move the focus cursor by `delta` tiles. When the move is NOT a * shift-extension, the anchor is bumped to the new focused tile so the * next shift-click/arrow starts from the user's current cursor (the * "starting photo") instead of a stale toggle/selectOnly anchor. * * Scroll-into-view tries the direct DOM lookup first (works pre- * windowing AND post-windowing for tiles already in the visible * window); if the tile isn't rendered (windowed out), defer to the * host-provided `scrollToIndex` which expands the window. */ function moveFocus(delta: number, extending: boolean) { if (selection.order.length === 0) return; const cur = focusedIndex(); const next = cur < 0 ? delta > 0 ? 0 : selection.order.length - 1 : Math.min(Math.max(0, cur + delta), selection.order.length - 1); const nextUid = selection.order[next]; // Plain arrow nav collapses any prior multi-selection down to the // cursor: one ringed tile at a time. Shift-extend keeps `ids` // growing from the anchor (selectRange runs after this). if (!extending) clearSelection(); setFocused(nextUid); if (!extending) setAnchor(nextUid); const tile = node.querySelector(`[data-uid="${nextUid}"]`); if (tile) { tile.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } else { scrollToIndex?.(next); } } /** Synthesize a target list. Multi-selection wins, then focused. */ function cullTargets(): string[] { if (selection.ids.size > 0) return Array.from(selection.ids); if (selection.focused) return [selection.focused]; return []; } /** Look up a photo's current cached state without forcing a refetch. * Walks every `['photos', …]` cache entry first, then the per-photo * cache. Lets `x` decide "archive vs restore" based on the actual current * state instead of always sending Archived=true. * * The `['photos', …]` namespace holds two shapes: a flat `PpPhoto[]` * (e.g. ratings/colors pools) and TanStack's `InfiniteData` envelope * (`{pages: PpPhoto[][], pageParams}`) used by the timeline's infinite * scroll. Walk both — assuming a flat array on the timeline cache used * to throw `list.find is not a function` and abort the F/X handlers. */ function cachedPhoto(uid: string): PpPhoto | undefined { const lists = queryClient.getQueriesData({ queryKey: ['photos'] }); for (const [, data] of lists) { if (!data) continue; if (Array.isArray(data)) { const hit = (data as PpPhoto[]).find((p) => p.UID === uid); if (hit) return hit; continue; } const pages = (data as { pages?: PpPhoto[][] }).pages; if (!Array.isArray(pages)) continue; for (const page of pages) { const hit = page?.find?.((p) => p.UID === uid); if (hit) return hit; } } return queryClient.getQueryData(['photo', uid]); } async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') { const ids = cullTargets(); if (ids.length === 0) { const verb = direction === 'restore' ? 'restore' : 'archive'; toast.message(`Nothing to ${verb}`, { description: 'Click a photo or select some first' }); return; } let target: boolean; if (direction === 'archive') target = true; else if (direction === 'restore') target = false; else { const first = cachedPhoto(ids[0]); target = !(first?.Archived ?? false); } // PhotoPrism's photo PUT silently drops the Archived field — the // only working path is /api/v1/batch/photos/{archive,restore}. The // previous patchTargets call PUT'd `{Archived: true}` and got a 200 // back, so the toast fired but nothing moved. try { if (target) await batchArchive(ids); else await batchRestore(ids); } catch (err) { toast.error(err instanceof Error ? err.message : 'Archive failed'); return; } // Move focus forward before the photos query refetches, so the // user can keep X-ing through the timeline without their cursor // snapping back to photo[0]. Walks past every uid we just // archived/restored — relevant when the cull targets came from a // multi-selection rather than the single focused tile. focusAfter(ids); // Drop the now-stale selection set. The archived UIDs are about // to leave the timeline on refetch, but the SvelteSet membership // keeps the selection ring on them until then — confusing for // the user and a footgun if they Ctrl-click to add more and end // up re-archiving the same photos. The BulkActionBar button path // clears for the same reason; mirror it here. clearSelection(); invalidatePhotos(ids); const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; toast.success(label); pushUndo(label, async () => { if (target) await batchRestore(ids); else await batchArchive(ids); invalidatePhotos(ids); }); } /** Permanently delete cull targets — only callable from the archive * section (X is rerouted away from archive-toggle there). PhotoPrism * rejects deletion of un-archived photos with a 4xx, so the section * gate doubles as a safety guard against accidental deletes from the * main timeline. Confirm dialog is mandatory — no undo path exists. */ async function deleteCullTargets() { const ids = cullTargets(); if (ids.length === 0) { toast.message('Nothing to delete', { description: 'Click a photo or select some first' }); return; } const msg = ids.length === 1 ? 'Permanently delete this photo? This cannot be undone.' : `Permanently delete ${ids.length} photos? This cannot be undone.`; if (!confirm(msg)) return; try { await batchDelete(ids); } catch (err) { toast.error(err instanceof Error ? err.message : 'Delete failed'); return; } focusAfter(ids); clearSelection(); invalidatePhotos(ids); toast.success(`Deleted ${ids.length}`); } /** Approve cull targets — clears them out of the review pile by * bumping each photo's quality score above PhotoPrism's review * threshold. The op is one-way (no /unapprove route), so we don't * push an undo entry: a re-keyed S would just be a no-op on * already-approved photos. */ async function approveCullTargets() { const ids = cullTargets(); if (ids.length === 0) { toast.message('Nothing to keep', { description: 'Click a photo or select some first' }); return; } const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id)); // Approve moves photos out of the review pile, so the same // stale-selection trap as archive/delete applies — advance focus // past the approved set and drop the now-irrelevant selection // before invalidate refetches the (smaller) view. focusAfter(ids); clearSelection(); invalidatePhotos(ids); if (errors.length) { toast.error(`Kept ${updated.length}; ${errors.length} failed`, { description: errors[0].message }); return; } toast.success(`Kept ${ids.length}`); } // ── S chord (add-to-heap) ──────────────────────────────────────────── // Press S: arm a short timer. A digit 1–9 within the window adds the // cull targets to the Nth heap in the heap list. Any other key cancels // the chord without firing. On timeout, fall back to the currently- // viewed heap (i.e. when section==='heap'); otherwise show a hint toast. let sChordTimer: number | null = null; const S_CHORD_MS = 500; function clearSChord() { if (sChordTimer !== null) { window.clearTimeout(sChordTimer); sChordTimer = null; } } async function addCullTargetsToHeap(heap: PpAlbum) { const ids = cullTargets(); if (ids.length === 0) { toast.message('Nothing to add', { description: 'Click a photo or select some first' }); return; } try { const { added } = await addToHeap(heap.UID, ids); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); // PhotoPrism returns 200 even when nothing was added — distinguish // "really added N" from "skipped all N" so the toast tells the // truth. if (added.length === 0) { toast.error(`Nothing added to ${heap.Title}`, { description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).` }); return; } if (added.length < ids.length) { toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { description: 'The rest were already in this heap.' }); } else { toast.success(`Added ${added.length} → ${heap.Title}`); } pushUndo(`Added ${added.length} to ${heap.Title}`, async () => { await removeFromHeap(heap.UID, added); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); }); } catch (err) { toast.error(err instanceof Error ? err.message : 'Add-to-heap failed'); } } async function addCullTargetsToHeapByIndex(idx: number) { const heaps = queryClient.getQueryData(['heaps']) ?? []; if (idx < 1 || idx > heaps.length) { toast.message(`No heap #${idx}`); return; } await addCullTargetsToHeap(heaps[idx - 1]); } async function addCullTargetsToActiveHeap() { if (filters.section !== 'heap' || !filters.heapUid) { toast.message('Press S then 1–9 to pick a heap'); return; } const heaps = queryClient.getQueryData(['heaps']) ?? []; const heap = heaps.find((h) => h.UID === filters.heapUid); if (!heap) { toast.message('Active heap not found'); return; } await addCullTargetsToHeap(heap); } async function onKey(e: KeyboardEvent) { // Don't hijack typing inside form fields. const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return; // Modal owns arrow / Escape / Space while it's open — it handles // its own linear nav, close-on-Esc, and close-on-Space. Action // keys (X/S/U/A/Z) still pass through because they target the // shared selection store and work the same in either context. if (view.previewOpen) { if ( e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === ' ' || e.code === 'Space' ) { return; } } // S+digit chord. A digit 1–9 within the chord window consumes the key // and fires add-to-heap-N. Any other key cancels the chord without // firing the default active-heap action — the user switched intent — // and falls through to normal handling for that key. if (sChordTimer !== null) { if (/^[1-9]$/.test(e.key)) { e.preventDefault(); clearSChord(); void addCullTargetsToHeapByIndex(parseInt(e.key, 10)); return; } clearSChord(); } const meta = e.metaKey || e.ctrlKey; const shift = e.shiftKey; // Space on a focused tile opens the full-screen preview modal. // Matches the dblclick gesture so the user has both keyboard and // mouse paths to the same surface. `e.code === 'Space'` covers // layouts where `e.key` is the dead-key combining mark. if ((e.key === ' ' || e.code === 'Space') && !meta && !shift) { if (selection.focused) { e.preventDefault(); openPreview(); return; } } // ── Grid nav keys ──────────────────────────────────────────────────── switch (e.key) { case 'ArrowLeft': case 'ArrowRight': case 'ArrowUp': case 'ArrowDown': e.preventDefault(); if (onArrow) { // Host owns the visual-row map (needed for grids with // interleaved headers). The host calls setFocused + // scrollToIndex + selectRange-on-shift itself. onArrow(e.key, shift); } else { const delta = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' ? -tilesPerRow() : tilesPerRow(); moveFocus(delta, shift); if (shift && selection.focused) selectRange(selection.focused); } return; case 'Escape': // First Esc collapses a multi-selection back to single-focus // on its first member — the user's "starting photo" stays // visible instead of vanishing. Only when there's no bulk // does Esc fully dismiss focus. if (clearBulkToFirst()) return; clearSelection(); setFocused(null); return; case 'Tab': // Tab in the grid context = mule-image's left-sidebar toggle. // Browsers reserve Tab for focus traversal — preventDefault // here is fine because the grid owns this surface. e.preventDefault(); toggleLeftSidebar(); return; case 'i': case 'I': if (!meta && !shift) { e.preventDefault(); toggleRightSidebar(); } return; case 'b': case 'B': if (!meta && !shift) { e.preventDefault(); toggleLeftSidebar(); } return; case 'z': case 'Z': if (meta) { e.preventDefault(); const entry = await popAndRun(); if (entry) toast.success(`Undone: ${entry.label}`); else toast.message('Nothing to undo'); } return; case 'a': case 'A': if (meta) { e.preventDefault(); for (const id of selection.order) selection.ids.add(id); } return; case 'x': case 'X': if (meta || shift) return; e.preventDefault(); // Archive section: X becomes permanent delete (Keep/Delete // is the binary flow there, mirroring Review's Keep/Archive). // Everywhere else X toggles archive on the cull targets. if (filters.section === 'archive') { void deleteCullTargets(); return; } void toggleArchive('toggle'); return; case 'u': case 'U': if (meta || shift) return; e.preventDefault(); void toggleArchive('restore'); return; case 's': case 'S': if (meta || shift) return; e.preventDefault(); // Review section repurposes S as the Keep affordance — // matches the BulkActionBar button and keeps the binary // Keep/Archive flow on home-row keys (S/X). The heap chord // is meaningless here anyway (review photos can't sensibly // be filed before they're approved). if (filters.section === 'review') { void approveCullTargets(); return; } // Archive section: S = Keep = restore back to the timeline // (inverse of Delete on X). Same rationale as review — // heap-filing an archived photo isn't a flow that fits the // section's intent. if (filters.section === 'archive') { void toggleArchive('restore'); return; } // Arm the chord. A digit 1–9 within S_CHORD_MS picks heap N; // otherwise we fall back to the currently-viewed heap. clearSChord(); sChordTimer = window.setTimeout(() => { sChordTimer = null; void addCullTargetsToActiveHeap(); }, S_CHORD_MS); return; } } function onClick(e: MouseEvent) { const tile = (e.target as HTMLElement | null)?.closest('[data-tile]'); if (!tile) return; const uid = tile.dataset.uid; if (!uid) return; // Modifier clicks are the only paths this document-level handler // owns. Plain clicks bubble to the tile button's onclick, which // reduces selection to just that tile. if (e.shiftKey) { e.preventDefault(); selectRange(uid); setFocused(uid); } else if (e.metaKey || e.ctrlKey) { e.preventDefault(); toggle(uid); setFocused(uid); } } node.addEventListener('click', onClick); // Keydown lives on the window so arrow keys, Esc, ⌘Z, etc. work // immediately on page load regardless of which element holds focus. // The filter inside `onKey` keeps form-field typing safe. window.addEventListener('keydown', onKey); return { update(next: GridKeyNavParams = {}) { scrollToIndex = next.scrollToIndex; onArrow = next.onArrow; }, destroy() { clearSChord(); node.removeEventListener('click', onClick); window.removeEventListener('keydown', onKey); ro.disconnect(); } }; }