Files
mule-image/web/src/lib/actions/gridKeyNav.ts
dtoro 24dfa996b3 web: de-brand PhotoPrism references in user-facing copy
Replaces "PhotoPrism" in UI strings (empty states, tooltips, toasts,
log header, login screen) with neutral terms like "the indexer", "the
library", "the server" — accurate regardless of backend. The login
header becomes "Mulimage" and drops the explicit PhotoPrism mention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:26:12 +02:00

592 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + (19) 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 = <main>).
if (gridEl && node.contains(gridEl)) return gridEl;
const candidate = node.querySelector<HTMLElement>('[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<HTMLElement>('*')) {
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<HTMLElement>(`[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<PpPhoto>(['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 19 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<PpAlbum[]>(['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 19 to pick a heap');
return;
}
const heaps = queryClient.getQueryData<PpAlbum[]>(['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 19 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 19 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<HTMLElement>('[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();
}
};
}