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:
@@ -189,11 +189,14 @@
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
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);
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'j') {
|
||||
} else if (e.key === 'ArrowRight' || e.code === 'KeyJ') {
|
||||
if (nextId) onNavigate(nextId);
|
||||
} else if (e.key === 'e') {
|
||||
} else if (e.code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
jumpToTags();
|
||||
} else if (e.key === 'Escape') {
|
||||
|
||||
@@ -57,13 +57,15 @@
|
||||
let helpOpen = $state(false);
|
||||
|
||||
// g-then-letter and 1–5 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> = {
|
||||
c: '/categories',
|
||||
t: '/tags',
|
||||
f: '/files',
|
||||
p: '/pools',
|
||||
s: '/settings'
|
||||
KeyC: '/categories',
|
||||
KeyT: '/tags',
|
||||
KeyF: '/files',
|
||||
KeyP: '/pools',
|
||||
KeyS: '/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 (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;
|
||||
e.preventDefault();
|
||||
return;
|
||||
@@ -98,8 +101,8 @@
|
||||
|
||||
// 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
|
||||
// opens the filter instead.
|
||||
if (e.key === '/') {
|
||||
// opens the filter instead. Match `/` by character or by Slash position.
|
||||
if (!e.shiftKey && (e.key === '/' || e.code === 'Slash')) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
if (input) {
|
||||
e.preventDefault();
|
||||
@@ -108,28 +111,30 @@
|
||||
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) {
|
||||
pendingG = false;
|
||||
clearTimeout(gTimer);
|
||||
const dest = G_MAP[e.key.toLowerCase()];
|
||||
const dest = G_MAP[e.code];
|
||||
if (dest) {
|
||||
e.preventDefault();
|
||||
go(dest);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'g') {
|
||||
if (e.code === 'KeyG') {
|
||||
pendingG = true;
|
||||
clearTimeout(gTimer);
|
||||
gTimer = setTimeout(() => (pendingG = false), 1000);
|
||||
return;
|
||||
}
|
||||
if (e.key >= '1' && e.key <= '5') {
|
||||
const dest = NUM_MAP[Number(e.key) - 1];
|
||||
if (dest) {
|
||||
const digit = /^(?:Digit|Numpad)([1-5])$/.exec(e.code);
|
||||
if (digit) {
|
||||
e.preventDefault();
|
||||
go(dest);
|
||||
}
|
||||
go(NUM_MAP[Number(digit[1]) - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -112,33 +112,54 @@
|
||||
if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return;
|
||||
if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
|
||||
// Navigation / named keys — same on every layout.
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
moveFocus(1);
|
||||
break;
|
||||
return;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
moveFocus(-1);
|
||||
break;
|
||||
return;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveFocus(gridCols());
|
||||
break;
|
||||
return;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
moveFocus(-gridCols());
|
||||
break;
|
||||
return;
|
||||
case 'Enter': {
|
||||
const f = focusedFile();
|
||||
if (f) {
|
||||
e.preventDefault();
|
||||
openFile(f);
|
||||
}
|
||||
break;
|
||||
return;
|
||||
}
|
||||
case ' ':
|
||||
case 'x': {
|
||||
case ' ': {
|
||||
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();
|
||||
if (f?.id) {
|
||||
e.preventDefault();
|
||||
@@ -146,28 +167,21 @@
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'e':
|
||||
case 'KeyE':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
openTagEditor();
|
||||
}
|
||||
break;
|
||||
case 'p':
|
||||
case 'KeyP':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
void openPoolPicker();
|
||||
}
|
||||
break;
|
||||
case 'Delete':
|
||||
if ($selectionActive || focusedFile()) {
|
||||
e.preventDefault();
|
||||
ensureSelectedFocused();
|
||||
confirmDeleteFiles = true;
|
||||
}
|
||||
break;
|
||||
case '/':
|
||||
case 'Slash':
|
||||
e.preventDefault();
|
||||
openFilterAndFocus();
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user