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:
@@ -191,6 +191,13 @@
|
||||
* append silently — we never want the focus to jump back to the top of
|
||||
* the timeline mid-scroll. `untrack` keeps Escape (which clears focus)
|
||||
* from immediately re-triggering this effect.
|
||||
*
|
||||
* Always re-anchors to photos[0] when pageCount lands on 1 (which
|
||||
* only happens on initial load or after a filter change resets the
|
||||
* infinite-query) so switching views drops the user back at the top
|
||||
* with a fresh focus cursor. Mid-flow mutations (archive / restore /
|
||||
* etc.) advance focus themselves via `focusAfter` and don't flip
|
||||
* pageCount, so they don't get clobbered by this re-anchor.
|
||||
*/
|
||||
$effect(() => {
|
||||
// Re-read pageCount so the effect bottoms out cleanly on filter
|
||||
@@ -204,10 +211,7 @@
|
||||
// Only re-anchor focus on the very first page; later pages
|
||||
// must not pull focus back to photo[0].
|
||||
if (pages !== 1) return;
|
||||
const cur = selection.focused;
|
||||
if (!cur || !photos.some((p) => p.UID === cur)) {
|
||||
setFocused(photos[0].UID);
|
||||
}
|
||||
setFocused(photos[0].UID);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -658,6 +662,28 @@
|
||||
e.preventDefault();
|
||||
setSearch(searchDraft.trim());
|
||||
}
|
||||
|
||||
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||
const SEARCH_EXAMPLES = [
|
||||
'label:dog',
|
||||
'keyword:vacation',
|
||||
'taken:2024',
|
||||
'"exact phrase"'
|
||||
];
|
||||
let searchFocused = $state(false);
|
||||
function onSearchFocus() {
|
||||
searchFocused = true;
|
||||
}
|
||||
function onSearchBlur() {
|
||||
// Defer so a click on an example fires before the popover unmounts.
|
||||
setTimeout(() => (searchFocused = false), 120);
|
||||
}
|
||||
function applySearchExample(ex: string) {
|
||||
searchDraft = ex;
|
||||
setSearch(ex);
|
||||
searchFocused = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Toolbar showRightToggle>
|
||||
@@ -687,12 +713,14 @@
|
||||
{emptyingArchive ? 'Emptying…' : 'Empty Archive'}
|
||||
</button>
|
||||
{/if}
|
||||
<form class="flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder='Search · label:website / "vacation"'
|
||||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
bind:value={searchDraft}
|
||||
onfocus={onSearchFocus}
|
||||
onblur={onSearchBlur}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -713,6 +741,32 @@
|
||||
✕
|
||||
</button>
|
||||
{/if}
|
||||
<!--
|
||||
Cheat-sheet popover: opens on input focus, lists working q-DSL
|
||||
patterns. Clicking an example fills the input AND fires the
|
||||
search, so it doubles as a one-click "try it" affordance.
|
||||
-->
|
||||
{#if searchFocused}
|
||||
<div
|
||||
class="absolute left-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md"
|
||||
>
|
||||
<div class="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Examples
|
||||
</div>
|
||||
{#each SEARCH_EXAMPLES as ex (ex)}
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full rounded px-2 py-1 text-left font-mono text-[11px] hover:bg-accent"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
applySearchExample(ex);
|
||||
}}
|
||||
>
|
||||
{ex}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#snippet trailing()}
|
||||
|
||||
@@ -138,11 +138,15 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
// Titles follow the Lightroom culling convention so users see the
|
||||
// swatch's *intent* (reject/review/pick/keep), not just its color.
|
||||
// Used both as tooltip on swatches and as the visible card label in
|
||||
// the colors-tab picker grid below.
|
||||
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' }
|
||||
];
|
||||
interface ColorGroup {
|
||||
key: string;
|
||||
|
||||
Reference in New Issue
Block a user