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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:59:16 +03:00
parent 94d100675e
commit e39cda9ec4
2 changed files with 32 additions and 17 deletions
@@ -24,6 +24,7 @@
['↑ ↓ ← →', 'Move focus between files'], ['↑ ↓ ← →', 'Move focus between files'],
['Enter', 'Open the focused file'], ['Enter', 'Open the focused file'],
['Space / x', 'Select / deselect'], ['Space / x', 'Select / deselect'],
['Shift+Space / Shift+x', 'Select a range from the anchor'],
['e', 'Edit tags (focus the tag filter)'], ['e', 'Edit tags (focus the tag filter)'],
['p', 'Add to pool'], ['p', 'Add to pool'],
['Del', 'Move to trash'], ['Del', 'Move to trash'],
+30 -16
View File
@@ -86,6 +86,24 @@
if (f?.id && !$selectionStore.ids.has(f.id)) selectionStore.select(f.id); 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() { function openTagEditor() {
tagEditorOpen = true; tagEditorOpen = true;
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus()); void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
@@ -138,14 +156,10 @@
} }
return; return;
} }
case ' ': { case ' ':
const f = focusedFile();
if (f?.id) {
e.preventDefault(); e.preventDefault();
selectionStore.toggle(f.id); selectFocused(e.shiftKey);
}
return; return;
}
case 'Delete': case 'Delete':
if ($selectionActive || focusedFile()) { if ($selectionActive || focusedFile()) {
e.preventDefault(); e.preventDefault();
@@ -155,18 +169,18 @@
return; return;
} }
// Letter / symbol commands matched by physical position, so they fire the // Select by position (x), Shift = range — handled before the unshifted-only
// same on a non-Latin layout. // 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; if (e.shiftKey) return;
switch (e.code) { switch (e.code) {
case 'KeyX': {
const f = focusedFile();
if (f?.id) {
e.preventDefault();
selectionStore.toggle(f.id);
}
break;
}
case 'KeyE': case 'KeyE':
if ($selectionActive || focusedFile()) { if ($selectionActive || focusedFile()) {
e.preventDefault(); e.preventDefault();