feat(web): keyboard rating/color marks, search focus, shortcuts overlay
Lightroom-style keys in grid+preview: 0-5 rating (re-key toggles), 6-9 color labels, optimistic marks cache patch with rollback. / focuses the search box, ? opens a new shortcut-reference overlay that inertly swallows other keys while open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,10 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
|
bulkSetMarks,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
|
type PhotoMark,
|
||||||
|
type PhotoMarksMap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||||
@@ -36,7 +39,14 @@ import {
|
|||||||
setDetail,
|
setDetail,
|
||||||
markRemoved
|
markRemoved
|
||||||
} from '$lib/stores/bulkAction.svelte';
|
} from '$lib/stores/bulkAction.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import {
|
||||||
|
closeShortcuts,
|
||||||
|
openPreview,
|
||||||
|
toggleLeftSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
toggleShortcuts,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
@@ -68,8 +78,8 @@ export interface GridKeyNavParams {
|
|||||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||||
* ⌘A selects all visible.
|
* ⌘A selects all visible.
|
||||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
* 0–5 rating, 6–9 Lightroom color labels, / focuses search,
|
||||||
* keyboard shortcuts.
|
* ? opens the shortcut reference overlay.
|
||||||
*
|
*
|
||||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||||
* 1. multi-selection set
|
* 1. multi-selection set
|
||||||
@@ -377,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
|
||||||
|
// Bare 0–5 set the rating (0 clears; re-keying the current value also
|
||||||
|
// clears, matching the sidebar's click-to-toggle). 6–9 toggle the four
|
||||||
|
// Lightroom color labels. Multi-selection stamps the whole set.
|
||||||
|
const COLOR_KEYS: Record<string, string> = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' };
|
||||||
|
|
||||||
|
async function markCullTargets(patch: PhotoMark, label: string) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to mark', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Optimistic cache patch — the tile badges and facet panels read
|
||||||
|
// ['marks'], so stamping it up front makes the keystroke feel instant.
|
||||||
|
const prevMap = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
const next: PhotoMarksMap = { ...prevMap };
|
||||||
|
for (const id of ids) {
|
||||||
|
const merged: PhotoMark = { ...next[id], ...patch };
|
||||||
|
if (!merged.rating) delete merged.rating;
|
||||||
|
if (!merged.color) delete merged.color;
|
||||||
|
next[id] = merged;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(['marks'], next);
|
||||||
|
try {
|
||||||
|
await bulkSetMarks(ids, patch);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
|
toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`);
|
||||||
|
} catch (err) {
|
||||||
|
queryClient.setQueryData(['marks'], prevMap);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Mark failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingOfFirstTarget(): number {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return 0;
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.rating ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorOfFirstTarget(): string {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return '';
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.color ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function addCullTargetsToActiveHeap() {
|
async function addCullTargetsToActiveHeap() {
|
||||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||||
toast.message('Press S then 1–9 to pick a heap');
|
toast.message('Press S then 1–9 to pick a heap');
|
||||||
@@ -396,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
|
||||||
|
// Shortcuts overlay: Esc or ? closes it; every other key is inert
|
||||||
|
// while it's up so the reference card can't trigger the actions it
|
||||||
|
// documents.
|
||||||
|
if (view.shortcutsOpen) {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeShortcuts();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Modal owns arrow / Escape / Space while it's open — it handles
|
// Modal owns arrow / Escape / Space while it's open — it handles
|
||||||
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
// 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
|
// keys (X/S/U/A/Z) still pass through because they target the
|
||||||
@@ -431,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const meta = e.metaKey || e.ctrlKey;
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
|
|
||||||
|
// Bare digits: rating (0–5, re-key toggles off) and Lightroom color
|
||||||
|
// labels (6–9). Runs after the S-chord so "s 3" still files to heap 3.
|
||||||
|
if (!meta && !shift && /^[0-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (n <= 5) {
|
||||||
|
const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n;
|
||||||
|
void markCullTargets({ rating: value }, value ? `Rated ${value}★` : 'Rating cleared');
|
||||||
|
} else {
|
||||||
|
const color = COLOR_KEYS[e.key];
|
||||||
|
const value = colorOfFirstTarget() === color ? '' : color;
|
||||||
|
void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Space on a focused tile opens the full-screen preview modal.
|
// Space on a focused tile opens the full-screen preview modal.
|
||||||
// Matches the dblclick gesture so the user has both keyboard and
|
// Matches the dblclick gesture so the user has both keyboard and
|
||||||
// mouse paths to the same surface. `e.code === 'Space'` covers
|
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||||||
@@ -477,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
setFocused(null);
|
setFocused(null);
|
||||||
return;
|
return;
|
||||||
|
case '/':
|
||||||
|
// Jump to the search box (any page that renders one tags it
|
||||||
|
// with data-search-input).
|
||||||
|
if (meta) return;
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
|
||||||
|
return;
|
||||||
|
case '?':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcuts();
|
||||||
|
return;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||||
// Browsers reserve Tab for focus traversal — preventDefault
|
// Browsers reserve Tab for focus traversal — preventDefault
|
||||||
|
|||||||
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<!--
|
||||||
|
Keyboard-shortcut reference overlay, opened with `?` (and the toolbar
|
||||||
|
help affordance). Read-only: gridKeyNav swallows every key except
|
||||||
|
Esc / ? while it's up, so nothing here can fire the actions it lists.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { closeShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
keys: string[];
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
interface Group {
|
||||||
|
title: string;
|
||||||
|
rows: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
title: 'Navigate',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['↑', '↓', '←', '→'], desc: 'Move focus in the grid' },
|
||||||
|
{ keys: ['Shift', '+', 'Arrows'], desc: 'Extend selection' },
|
||||||
|
{ keys: ['Space'], desc: 'Open / close preview' },
|
||||||
|
{ keys: ['Esc'], desc: 'Collapse selection, then clear' },
|
||||||
|
{ keys: ['/'], desc: 'Focus search' },
|
||||||
|
{ keys: ['⌘', 'A'], desc: 'Select all visible' },
|
||||||
|
{ keys: ['⌘', 'K'], desc: 'Command palette' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rate & label',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['1', '…', '5'], desc: 'Set rating (re-key to clear)' },
|
||||||
|
{ keys: ['0'], desc: 'Clear rating' },
|
||||||
|
{ keys: ['6', '7', '8', '9'], desc: 'Color label: red / yellow / green / blue' },
|
||||||
|
{ keys: ['F'], desc: 'Toggle favorite' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Act',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['X'], desc: 'Archive (Delete in Archive view)' },
|
||||||
|
{ keys: ['U'], desc: 'Restore from archive' },
|
||||||
|
{ keys: ['S'], desc: 'Keep (review) · add to heap' },
|
||||||
|
{ keys: ['S', 'then', '1–9'], desc: 'Add to heap N' },
|
||||||
|
{ keys: ['A'], desc: 'Accept date & keep (EXIF review)' },
|
||||||
|
{ keys: ['M'], desc: 'Move to folder' },
|
||||||
|
{ keys: ['⌘', 'Z'], desc: 'Undo last action' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Panels',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['B'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['Tab'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['I'], desc: 'Toggle info sidebar' },
|
||||||
|
{ keys: ['?'], desc: 'This overlay' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view.shortcutsOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeShortcuts();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
class="max-h-[85vh] w-[min(680px,92vw)] overflow-y-auto rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={closeShortcuts}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
{#each GROUPS as group (group.title)}
|
||||||
|
<section>
|
||||||
|
<h3 class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<dl class="space-y-1">
|
||||||
|
{#each group.rows as row (row.desc)}
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<dt class="text-muted-foreground">{row.desc}</dt>
|
||||||
|
<dd class="flex shrink-0 items-center gap-0.5">
|
||||||
|
{#each row.keys as k (k)}
|
||||||
|
{#if k === 'then' || k === '+' || k === '…'}
|
||||||
|
<span class="px-0.5 text-[10px] text-muted-foreground">{k}</span>
|
||||||
|
{:else}
|
||||||
|
<kbd
|
||||||
|
class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -81,6 +81,8 @@ export const view = $state<{
|
|||||||
* persisted — a refresh always returns to the grid.
|
* persisted — a refresh always returns to the grid.
|
||||||
*/
|
*/
|
||||||
previewOpen: boolean;
|
previewOpen: boolean;
|
||||||
|
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
||||||
|
shortcutsOpen: boolean;
|
||||||
metadataSections: Record<string, boolean>;
|
metadataSections: Record<string, boolean>;
|
||||||
}>({
|
}>({
|
||||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
@@ -107,6 +109,7 @@ export const view = $state<{
|
|||||||
),
|
),
|
||||||
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
||||||
previewOpen: false,
|
previewOpen: false,
|
||||||
|
shortcutsOpen: false,
|
||||||
metadataSections:
|
metadataSections:
|
||||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||||
? { ...initial.metadataSections }
|
? { ...initial.metadataSections }
|
||||||
@@ -164,6 +167,14 @@ export function togglePreview(): void {
|
|||||||
view.previewOpen = !view.previewOpen;
|
view.previewOpen = !view.previewOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleShortcuts(): void {
|
||||||
|
view.shortcutsOpen = !view.shortcutsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeShortcuts(): void {
|
||||||
|
view.shortcutsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
view.thumbnailSize = size;
|
view.thumbnailSize = size;
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||||
|
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -122,6 +123,8 @@
|
|||||||
store. Opened from the heap/folder kebabs, the BulkActionBar
|
store. Opened from the heap/folder kebabs, the BulkActionBar
|
||||||
button, and the `m` shortcut — all through openMove(). -->
|
button, and the `m` shortcut — all through openMove(). -->
|
||||||
<MoveToFolderDialog />
|
<MoveToFolderDialog />
|
||||||
|
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
||||||
|
<ShortcutsDialog />
|
||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -843,6 +843,7 @@
|
|||||||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
data-search-input
|
||||||
placeholder={'Search · label:website / "vacation"'}
|
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"
|
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}
|
bind:value={searchDraft}
|
||||||
|
|||||||
Reference in New Issue
Block a user