3 Commits

Author SHA1 Message Date
H1K0 bce79867e4 fix(frontend): scroll the grid to follow the keyboard focus
deploy / deploy (push) Successful in 17s
Arrowing up/down moved the focus ring but the view didn't follow: the
card was scrolled with scrollIntoView({block:'nearest'}), which aligns to
the scroller's edges and is unaware of the fixed bottom navbar overlaying
the scroll area — so the newly focused row slid under the navbar. Replace
it with a manual scroll that keeps the focused card inside the scroller
with a top margin and a bottom margin sized for the navbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:01:35 +03:00
H1K0 e39cda9ec4 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>
2026-06-11 17:59:16 +03:00
H1K0 94d100675e feat(frontend): make keyboard shortcuts layout-independent
Command keys were matched by character (e.key), so on a non-Latin layout
(e.g. Russian) the physical g/f/e/p/x/j/k keys emitted Cyrillic letters
and nothing fired. Letter and digit commands now match by physical
position (e.code: KeyG, Digit1, Slash, …) across the global nav, the file
grid, and the viewer, so the same physical keys work on any layout. Named
keys (arrows, Enter, Esc, Delete), the Mod combos, and the filter's
literal operators (& | ! ( )) stay on e.key, where character matching is
correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:55:39 +03:00
4 changed files with 108 additions and 54 deletions
@@ -189,11 +189,14 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft' || e.key === 'k') { if (e.ctrlKey || e.metaKey || e.altKey) return;
// Letter keys are matched by physical position (e.code) so j/k/e work on any
// keyboard layout; arrows and Escape are layout-independent already.
if (e.key === 'ArrowLeft' || e.code === 'KeyK') {
if (prevId) onNavigate(prevId); if (prevId) onNavigate(prevId);
} else if (e.key === 'ArrowRight' || e.key === 'j') { } else if (e.key === 'ArrowRight' || e.code === 'KeyJ') {
if (nextId) onNavigate(nextId); if (nextId) onNavigate(nextId);
} else if (e.key === 'e') { } else if (e.code === 'KeyE') {
e.preventDefault(); e.preventDefault();
jumpToTags(); jumpToTags();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
@@ -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'],
+22 -17
View File
@@ -57,13 +57,15 @@
let helpOpen = $state(false); let helpOpen = $state(false);
// g-then-letter and 15 jump between sections; both honour the remembered // g-then-letter and 15 jump between sections; both honour the remembered
// per-section URL so you land back on the same filter/scroll. // per-section URL so you land back on the same filter/scroll. Keyed by
// KeyboardEvent.code (physical key) so the shortcuts work the same on any
// layout — on a Russian layout the `f` key still triggers Files, etc.
const G_MAP: Record<string, string> = { const G_MAP: Record<string, string> = {
c: '/categories', KeyC: '/categories',
t: '/tags', KeyT: '/tags',
f: '/files', KeyF: '/files',
p: '/pools', KeyP: '/pools',
s: '/settings' KeyS: '/settings'
}; };
const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→settings const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→settings
@@ -90,7 +92,8 @@
if (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; if (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
if (isLogin) return; if (isLogin) return;
if (e.key === '?') { // Help: `?` by character, or Shift+/ by position (covers non-US layouts).
if (e.key === '?' || (e.code === 'Slash' && e.shiftKey)) {
helpOpen = !helpOpen; helpOpen = !helpOpen;
e.preventDefault(); e.preventDefault();
return; return;
@@ -98,8 +101,8 @@
// Focus the page's search box. Pages with a persistent one (Tags/Categories/ // Focus the page's search box. Pages with a persistent one (Tags/Categories/
// Pools) are handled here; Files has no always-on input, so its own handler // Pools) are handled here; Files has no always-on input, so its own handler
// opens the filter instead. // opens the filter instead. Match `/` by character or by Slash position.
if (e.key === '/') { if (!e.shiftKey && (e.key === '/' || e.code === 'Slash')) {
const input = document.querySelector<HTMLInputElement>('input[type="search"]'); const input = document.querySelector<HTMLInputElement>('input[type="search"]');
if (input) { if (input) {
e.preventDefault(); e.preventDefault();
@@ -108,28 +111,30 @@
return; return;
} }
// The remaining shortcuts are unshifted letters/digits, matched by physical
// position so the layout (Latin or not) doesn't matter.
if (e.shiftKey) return;
if (pendingG) { if (pendingG) {
pendingG = false; pendingG = false;
clearTimeout(gTimer); clearTimeout(gTimer);
const dest = G_MAP[e.key.toLowerCase()]; const dest = G_MAP[e.code];
if (dest) { if (dest) {
e.preventDefault(); e.preventDefault();
go(dest); go(dest);
} }
return; return;
} }
if (e.key === 'g') { if (e.code === 'KeyG') {
pendingG = true; pendingG = true;
clearTimeout(gTimer); clearTimeout(gTimer);
gTimer = setTimeout(() => (pendingG = false), 1000); gTimer = setTimeout(() => (pendingG = false), 1000);
return; return;
} }
if (e.key >= '1' && e.key <= '5') { const digit = /^(?:Digit|Numpad)([1-5])$/.exec(e.code);
const dest = NUM_MAP[Number(e.key) - 1]; if (digit) {
if (dest) { e.preventDefault();
e.preventDefault(); go(NUM_MAP[Number(digit[1]) - 1]);
go(dest);
}
} }
} }
</script> </script>
+79 -34
View File
@@ -71,12 +71,29 @@
focusedId = files[next]?.id ?? null; focusedId = files[next]?.id ?? null;
if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore(); if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore();
const id = focusedId; const id = focusedId;
requestAnimationFrame(() => { requestAnimationFrame(() => keepFocusedInView(id));
const idx = files.findIndex((f) => f.id === id); }
scrollContainer
?.querySelector<HTMLElement>(`[data-file-index="${idx}"]`) // Keep the focused card within the scroller, leaving a margin at the bottom for
?.scrollIntoView({ block: 'nearest' }); // the fixed navbar (which overlaps the scroll area and otherwise hides the row
}); // the focus moves onto). scrollIntoView can't account for that overlay.
const FOCUS_MARGIN_TOP = 8;
const FOCUS_MARGIN_BOTTOM = 72; // ~navbar height + gap
function keepFocusedInView(id: string | null) {
if (!id || !scrollContainer) return;
const idx = files.findIndex((f) => f.id === id);
const card = scrollContainer.querySelector<HTMLElement>(`[data-file-index="${idx}"]`);
if (!card) return;
const cardRect = card.getBoundingClientRect();
const scRect = scrollContainer.getBoundingClientRect();
const top = cardRect.top - scRect.top;
const bottom = cardRect.bottom - scRect.top;
if (top < FOCUS_MARGIN_TOP) {
scrollContainer.scrollTop += top - FOCUS_MARGIN_TOP;
} else if (bottom > scRect.height - FOCUS_MARGIN_BOTTOM) {
scrollContainer.scrollTop += bottom - (scRect.height - FOCUS_MARGIN_BOTTOM);
}
} }
// Action keys operate on the selection; with nothing selected they fall back to // Action keys operate on the selection; with nothing selected they fall back to
@@ -86,6 +103,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());
@@ -112,62 +147,72 @@
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return; if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
// Navigation / named keys — same on every layout.
switch (e.key) { switch (e.key) {
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
moveFocus(1); moveFocus(1);
break; return;
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
moveFocus(-1); moveFocus(-1);
break; return;
case 'ArrowDown': case 'ArrowDown':
e.preventDefault(); e.preventDefault();
moveFocus(gridCols()); moveFocus(gridCols());
break; return;
case 'ArrowUp': case 'ArrowUp':
e.preventDefault(); e.preventDefault();
moveFocus(-gridCols()); moveFocus(-gridCols());
break; return;
case 'Enter': { case 'Enter': {
const f = focusedFile(); const f = focusedFile();
if (f) { if (f) {
e.preventDefault(); e.preventDefault();
openFile(f); openFile(f);
} }
break; return;
} }
case ' ': case ' ':
case 'x': { e.preventDefault();
const f = focusedFile(); selectFocused(e.shiftKey);
if (f?.id) { return;
e.preventDefault();
selectionStore.toggle(f.id);
}
break;
}
case 'e':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
openTagEditor();
}
break;
case 'p':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
void openPoolPicker();
}
break;
case 'Delete': case 'Delete':
if ($selectionActive || focusedFile()) { if ($selectionActive || focusedFile()) {
e.preventDefault(); e.preventDefault();
ensureSelectedFocused(); ensureSelectedFocused();
confirmDeleteFiles = true; confirmDeleteFiles = true;
} }
return;
}
// 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 'KeyE':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
openTagEditor();
}
break; break;
case '/': case 'KeyP':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
void openPoolPicker();
}
break;
case 'Slash':
e.preventDefault(); e.preventDefault();
openFilterAndFocus(); openFilterAndFocus();
break; break;