Files
mule-image/web/src/lib/actions/gridKeyNav.ts
dtoro 5be6fd9047 feat(archive): red-cross flash + instant tile removal on archive/delete
Archive/delete now flash a red cross then drop tiles from the grid
immediately, instead of a green check that lingered until the slow
server-reconcile refetch landed. Keyboard `x` archive previously never
called markRemoved, so tiles only vanished on refetch — that lag is gone.

- Add 'removed' bulk state + removedBulk() helper (red cross overlay)
- gridKeyNav archive/delete: removedBulk -> 500ms flash -> markRemoved,
  clearRemoved once refetch settles; restore stays green check
- BulkActionBar: BulkConfig.removing routes archive/delete through the
  red flash; approve/restore/label/note unchanged

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:45:38 +02:00

626 lines
21 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 { 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 + (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 [];
}
const delay = (ms: number) => new Promise<void>((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 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;
}
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<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;
}
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 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();
}
};
}