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 { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions'; import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath'; import { photoNameAndDir } from '$lib/types/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 { startBulk, doneBulk, removedBulk, failBulk, setDetail, markRemoved, clearRemoved } from '$lib/stores/bulkAction.svelte'; import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte'; /** * 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 []; } const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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); } const opLabel = target ? 'Archiving' : 'Restoring'; const doneLabel = target ? `Archived ${ids.length}` : `Restored ${ids.length}`; const tid = toast.loading(`${opLabel} ${ids.length}…`); startBulk(`${opLabel}…`, ids); try { if (target) await batchArchive(ids); else await batchRestore(ids); } catch (err) { failBulk(ids); toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid }); return; } if (target) { // Destructive removal: flash a red cross, then pull the tiles out of // the grid immediately (markRemoved) rather than waiting on the slow // server-reconcile refetch. clearRemoved once the refetch settles so // the archived-filtered page replaces the optimistic hide. removedBulk(doneLabel, ids); focusAfter(ids); clearSelection(); await delay(500); markRemoved(ids); invalidatePhotos(ids); const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] }); void settled.then(() => clearRemoved(ids)); } else { doneBulk(doneLabel, ids); focusAfter(ids); clearSelection(); invalidatePhotos(ids); void queryClient.invalidateQueries({ queryKey: ['marks'] }); } toast.success(doneLabel, { id: tid }); pushUndo(doneLabel, 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; const tid = toast.loading(`Deleting ${ids.length}…`); startBulk('Deleting…', ids); try { await batchDelete(ids); } catch (err) { failBulk(ids); toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid }); return; } // Destructive removal — same red-cross flash then immediate hide as archive. removedBulk(`Deleted ${ids.length}`, ids); focusAfter(ids); clearSelection(); await delay(500); markRemoved(ids); invalidatePhotos(ids); const settled = queryClient.invalidateQueries({ queryKey: ['photos'] }); void queryClient.invalidateQueries({ queryKey: ['marks'] }); void settled.then(() => clearRemoved(ids)); toast.success(`Deleted ${ids.length}`, { id: tid }); } /** 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 tid = toast.loading(`Keeping ${ids.length}…`); startBulk('Keeping…', ids); const { updated, errors } = await batchEdit(ids, (id) => approvePhoto(id), { onProgress: (_done, _total, completedId) => { const p = cachedPhoto(completedId); if (p) setDetail(p.FileName ?? completedId); } }); if (errors.length) { failBulk(ids); toast.error(`Kept ${updated.length}; ${errors.length} failed`, { id: tid, description: errors[0].message }); } else { doneBulk(`Kept ${ids.length}`, ids); toast.success(`Kept ${ids.length}`, { id: tid }); } focusAfter(ids); clearSelection(); invalidatePhotos(ids); } // ── 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; } const tid = toast.loading(`Adding ${ids.length} → ${heap.Title}…`); startBulk(`Adding to ${heap.Title}…`, ids); try { const { added } = await addToHeap(heap.UID, ids); void queryClient.invalidateQueries({ queryKey: ['heaps'] }); void queryClient.invalidateQueries({ queryKey: ['photos'] }); if (added.length === 0) { failBulk(ids); toast.error(`Nothing added to ${heap.Title}`, { id: tid, description: `The server rejected all ${ids.length} UIDs (already in heap, or not indexed).` }); return; } doneBulk(`Added ${added.length} → ${heap.Title}`, ids); if (added.length < ids.length) { toast.success(`Added ${added.length}/${ids.length} → ${heap.Title}`, { id: tid, description: 'The rest were already in this heap.' }); } else { toast.success(`Added ${added.length} → ${heap.Title}`, { id: tid }); } 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) { failBulk(ids); toast.error(err instanceof Error ? err.message : 'Add-to-heap failed', { id: tid }); } } 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; } if (shift) return; // Bare `a` on the EXIF Stripped review tab fires the same // "Accept date & Keep" flow as the bar button. Mirrors the // bar's all-targets-have-a-suggestion gate so the shortcut // can't silently approve photos without a date fix. if ( filters.section === 'review' && new URL(window.location.href).searchParams.get('tab') === 'stripped_exif' ) { const ids = cullTargets(); if (ids.length === 0) return; for (const id of ids) { const p = cachedPhoto(id); if (!p) return; const { fileName, path } = photoNameAndDir(p); if (!suggestDateFromPath({ fileName, originalName: p.OriginalName, path })) { return; } } e.preventDefault(); void acceptDateAndKeep(ids); } 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(); } }; }