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:
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user