feat(timeline): focus follows archive, snaps to first on view load

- New selection.focusAfter(excluded) walks selection.order forward past
  the archived/restored set so X-ing through the timeline keeps the
  cursor on the next live photo instead of falling back to photo[0]
  via the auto-anchor effect. Wired into gridKeyNav.toggleArchive (X
  key) and BulkActionBar.onArchive.
- Auto-focus effect on the timeline always re-anchors to photos[0] on
  view load (pageCount → 1), instead of preserving a stale uid from
  the previous filter.
- PhotoGrid re-anchors focus when the previously focused uid isn't in
  the new photo set, so drilling into a /tags category drops the
  cursor on its first tile instead of carrying a stale selection from
  whatever view the user came from.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:56:54 +02:00
parent 84e433ff63
commit a72619e3d1
9 changed files with 222 additions and 28 deletions

View File

@@ -17,6 +17,7 @@ import { filters } from '$lib/stores/filters.svelte';
import { closePreview, openPreview, preview } from '$lib/stores/preview.svelte';
import {
clearSelection,
focusAfter,
indexOf,
selectRange,
selection,
@@ -218,6 +219,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
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);
invalidatePhotos(ids);
const label = target ? `Archived ${ids.length}` : `Restored ${ids.length}`;
toast.success(label);
@@ -344,12 +351,27 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
return;
}
try {
await addToHeap(heap.UID, ids);
const { added } = await addToHeap(heap.UID, ids);
void queryClient.invalidateQueries({ queryKey: ['heaps'] });
void queryClient.invalidateQueries({ queryKey: ['photos'] });
toast.success(`Added ${ids.length}${heap.Title}`);
pushUndo(`Added ${ids.length} to ${heap.Title}`, async () => {
await removeFromHeap(heap.UID, ids);
// 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: `PhotoPrism 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'] });
});