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'] });
});

View File

@@ -119,10 +119,10 @@
}
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
async function applyKeyword() {

View File

@@ -15,6 +15,7 @@
ExternalLink,
Heart,
ImageIcon,
Loader2,
Lock,
MapPin,
Star,
@@ -244,11 +245,14 @@
void applyMark({ color: value });
}
// Tooltips follow the Lightroom culling convention so the swatches
// read as actions, not just colors. Red = reject, Yellow = pick,
// Green = keep, Orange = review-later.
const COLOR_SWATCHES: { key: string; bg: string; title: string }[] = [
{ key: 'red', bg: 'bg-red-500', title: 'Red' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow' },
{ key: 'green', bg: 'bg-green-500', title: 'Green' }
{ key: 'red', bg: 'bg-red-500', title: 'Red — reject' },
{ key: 'orange', bg: 'bg-orange-500', title: 'Orange — review' },
{ key: 'yellow', bg: 'bg-yellow-400', title: 'Yellow — pick' },
{ key: 'green', bg: 'bg-green-500', title: 'Green — keep' }
];
const photoMark = $derived<PhotoMark>(marksQuery.data?.[photo.UID] ?? {});
@@ -314,6 +318,11 @@
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
title={renaming ? 'Renaming…' : 'Click to rename file on disk'}
/>
<!-- Inline spinner next to the filename so the user sees the rename
in flight without having to scan to the bottom of the sidebar. -->
{#if renaming}
<Loader2 class="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
{/if}
<button
class="rounded p-1 hover:bg-accent disabled:opacity-50"
class:text-red-500={photo.Favorite}

View File

@@ -14,7 +14,12 @@
type PpAlbum
} from '$lib/services/photoprism';
import { batchEdit } from '$lib/services/batch';
import { clearSelection, selection, setFocused } from '$lib/stores/selection.svelte';
import {
clearSelection,
focusAfter,
selection,
setFocused
} from '$lib/stores/selection.svelte';
import { filters } from '$lib/stores/filters.svelte';
import { popAndRun, push as pushUndo, undoStack } from '$lib/stores/undo.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte';
@@ -98,6 +103,10 @@
await batchRestore(ids);
void qc.invalidateQueries({ queryKey: ['photos'] });
});
// Advance focus to the photo immediately after the archived
// set before the multi-selection is dropped — lets the user
// keep stepping through the timeline with X.
focusAfter(ids);
clearSelection();
toast.success(`Archived ${ids.length}`);
} catch (err) {
@@ -173,11 +182,27 @@
heapPickerOpen = false;
await withBusy(async () => {
try {
await addToHeap(heap.UID, ids);
const { added } = await addToHeap(heap.UID, ids);
qc.invalidateQueries({ queryKey: ['heaps'] });
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 (UIDs
// already present or unknown to the index) — surface the
// real delta so the user isn't fooled by a green toast over
// a no-op.
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);
qc.invalidateQueries({ queryKey: ['heaps'] });
});
clearSelection();

View File

@@ -13,6 +13,7 @@
helpers.
-->
<script lang="ts">
import { untrack } from 'svelte';
import {
isSelected,
selection,
@@ -40,6 +41,26 @@
const order = $derived(photos.map((p) => p.UID));
$effect(() => {
setOrder(order);
// Re-anchor focus when the previously focused photo isn't part of
// this grid — covers drilling into a /tags category from any
// other view, where the selection.focused module state would
// otherwise leak across surfaces and the new grid would render
// with no tile highlighted. Crucially we only re-anchor when the
// uid is *absent*, so refetches that keep the focused photo
// around (e.g. after `focusAfter` set the next photo on archive)
// don't snap focus back to photos[0].
untrack(() => {
if (order.length === 0) {
setFocused(null);
selection.ids.clear();
return;
}
const cur = selection.focused;
if (cur && order.includes(cur)) return;
setFocused(order[0]);
setAnchor(order[0]);
selection.ids.clear();
});
});
function onClick(e: MouseEvent, uid: string) {

View File

@@ -500,8 +500,19 @@ export async function deleteHeap(uid: string): Promise<void> {
await http.delete(`/albums/${uid}`);
}
export async function addToHeap(uid: string, photos: string[]): Promise<void> {
await http.post(`/albums/${uid}/photos`, { photos });
/**
* PhotoPrism returns `{ code, message, album, photos: [uids in album], added: [delta] }`.
* Surface `added` so callers can detect "200-but-nothing-happened" — PhotoPrism
* silently skips UIDs that are missing from the index or already in the album,
* which used to look like a successful add to the user.
*/
export interface AddToHeapResult {
added: string[];
}
export async function addToHeap(uid: string, photos: string[]): Promise<AddToHeapResult> {
const { data } = await http.post<{ added?: string[] }>(`/albums/${uid}/photos`, { photos });
return { added: data.added ?? [] };
}
export async function removeFromHeap(uid: string, photos: string[]): Promise<void> {

View File

@@ -113,3 +113,51 @@ export function setFocused(uid: string | null): void {
export function setAnchor(uid: string | null): void {
selection.anchor = uid;
}
/**
* Advance focus to the photo immediately after `excluded` in the current
* order, skipping any uid that's in `excluded`. Falls back to the closest
* non-excluded uid *before* the excluded set when the user is already at
* the tail. Returns `null` when nothing else is left.
*
* Used right after a mutation that removes the focused photo from the
* current view (archive / restore / approve / delete) — calling this
* *before* the photo cache refetches keeps focus stable instead of the
* effect-driven anchor falling back to photo[0].
*/
export function focusAfter(excluded: Iterable<string>): string | null {
const excludedSet = excluded instanceof Set ? excluded : new Set(excluded);
const order = selection.order;
if (order.length === 0) {
setFocused(null);
return null;
}
// Anchor index: prefer current focus, else the first excluded uid we
// can find (covers the case where focus was already null).
let anchorIdx = indexOf(selection.focused);
if (anchorIdx === -1) {
for (let i = 0; i < order.length; i++) {
if (excludedSet.has(order[i])) {
anchorIdx = i;
break;
}
}
}
if (anchorIdx === -1) return null;
for (let i = anchorIdx + 1; i < order.length; i++) {
if (!excludedSet.has(order[i])) {
setFocused(order[i]);
setAnchor(order[i]);
return order[i];
}
}
for (let i = anchorIdx - 1; i >= 0; i--) {
if (!excludedSet.has(order[i])) {
setFocused(order[i]);
setAnchor(order[i]);
return order[i];
}
}
setFocused(null);
return null;
}