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>
This commit is contained in:
2026-06-11 17:55:39 +03:00
parent 19ec96c544
commit 94d100675e
3 changed files with 59 additions and 37 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') {
+21 -16
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(dest); go(NUM_MAP[Number(digit[1]) - 1]);
}
} }
} }
</script> </script>
+31 -17
View File
@@ -112,33 +112,54 @@
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': { const f = focusedFile();
if (f?.id) {
e.preventDefault();
selectionStore.toggle(f.id);
}
return;
}
case 'Delete':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
confirmDeleteFiles = true;
}
return;
}
// Letter / symbol commands 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(); const f = focusedFile();
if (f?.id) { if (f?.id) {
e.preventDefault(); e.preventDefault();
@@ -146,28 +167,21 @@
} }
break; break;
} }
case 'e': case 'KeyE':
if ($selectionActive || focusedFile()) { if ($selectionActive || focusedFile()) {
e.preventDefault(); e.preventDefault();
ensureSelectedFocused(); ensureSelectedFocused();
openTagEditor(); openTagEditor();
} }
break; break;
case 'p': case 'KeyP':
if ($selectionActive || focusedFile()) { if ($selectionActive || focusedFile()) {
e.preventDefault(); e.preventDefault();
ensureSelectedFocused(); ensureSelectedFocused();
void openPoolPicker(); void openPoolPicker();
} }
break; break;
case 'Delete': case 'Slash':
if ($selectionActive || focusedFile()) {
e.preventDefault();
ensureSelectedFocused();
confirmDeleteFiles = true;
}
break;
case '/':
e.preventDefault(); e.preventDefault();
openFilterAndFocus(); openFilterAndFocus();
break; break;