From e39cda9ec4b6cddbc3ad3dc2c9948b08885acf1d Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:59:16 +0300 Subject: [PATCH] feat(frontend): keyboard range-select with Shift+Space / Shift+x Plain Space/x toggles the focused card and drops a range anchor there; Shift+Space / Shift+x now selects everything from that anchor to the focused card, sharing the same anchor (lastSelectedIdx) as Shift+click so mouse and keyboard range-selection are interchangeable. Co-Authored-By: Claude Opus 4.8 --- .../lib/components/layout/KeyboardHelp.svelte | 1 + frontend/src/routes/files/+page.svelte | 48 ++++++++++++------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/layout/KeyboardHelp.svelte b/frontend/src/lib/components/layout/KeyboardHelp.svelte index b1880b1..ee37712 100644 --- a/frontend/src/lib/components/layout/KeyboardHelp.svelte +++ b/frontend/src/lib/components/layout/KeyboardHelp.svelte @@ -24,6 +24,7 @@ ['↑ ↓ ← →', 'Move focus between files'], ['Enter', 'Open the focused file'], ['Space / x', 'Select / deselect'], + ['Shift+Space / Shift+x', 'Select a range from the anchor'], ['e', 'Edit tags (focus the tag filter)'], ['p', 'Add to pool'], ['Del', 'Move to trash'], diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 41a3a77..6402f40 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -86,6 +86,24 @@ if (f?.id && !$selectionStore.ids.has(f.id)) selectionStore.select(f.id); } + // Select via the keyboard: a plain press toggles the focused card and drops the + // range anchor there; a Shift press selects everything from the anchor to the + // focused card — the same model as Shift+click on the grid. + function selectFocused(range: boolean) { + const idx = focusedId ? files.findIndex((f) => f.id === focusedId) : -1; + if (idx < 0) return; + if (range && lastSelectedIdx !== null) { + const from = Math.min(lastSelectedIdx, idx); + const to = Math.max(lastSelectedIdx, idx); + for (let i = from; i <= to; i++) { + if (files[i]?.id) selectionStore.select(files[i].id!); + } + } else if (files[idx]?.id) { + selectionStore.toggle(files[idx].id!); + } + lastSelectedIdx = idx; + } + function openTagEditor() { tagEditorOpen = true; void tick().then(() => document.querySelector('.tag-sheet input')?.focus()); @@ -138,14 +156,10 @@ } return; } - case ' ': { - const f = focusedFile(); - if (f?.id) { - e.preventDefault(); - selectionStore.toggle(f.id); - } + case ' ': + e.preventDefault(); + selectFocused(e.shiftKey); return; - } case 'Delete': if ($selectionActive || focusedFile()) { e.preventDefault(); @@ -155,18 +169,18 @@ return; } - // Letter / symbol commands matched by physical position, so they fire the - // same on a non-Latin layout. + // Select by position (x), Shift = range — handled before the unshifted-only + // guard below because Shift+x is a valid range-select. + if (e.code === 'KeyX') { + e.preventDefault(); + selectFocused(e.shiftKey); + return; + } + + // The remaining letter / symbol commands are unshifted-only, matched by + // physical position so they fire the same on a non-Latin layout. if (e.shiftKey) return; switch (e.code) { - case 'KeyX': { - const f = focusedFile(); - if (f?.id) { - e.preventDefault(); - selectionStore.toggle(f.id); - } - break; - } case 'KeyE': if ($selectionActive || focusedFile()) { e.preventDefault();