diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index 47a3b1b..1ef95c0 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -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') { diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e7a54e6..7b2b2ef 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -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 = { - 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('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) { - e.preventDefault(); - go(dest); - } + const digit = /^(?:Digit|Numpad)([1-5])$/.exec(e.code); + if (digit) { + e.preventDefault(); + go(NUM_MAP[Number(digit[1]) - 1]); } } diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index 0beb395..41a3a77 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -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;