Add customizable keyboard shortcuts

Drive app shortcuts from a central keybindings store (defaults +
localStorage overrides). In the Info panel's Shortcuts tab: click a
shortcut to rebind, right-click to reset, Esc to cancel; combos
already in use are rejected.
This commit is contained in:
mf
2026-06-21 10:37:58 +03:30
parent db2bef892a
commit 62d5388176
4 changed files with 376 additions and 99 deletions
+29 -43
View File
@@ -51,6 +51,7 @@
syncState, syncState,
platformIsMobile platformIsMobile
} from '$lib/stores/app'; } from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
@@ -394,88 +395,73 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code; const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar();
}
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
switch (action) {
case 'nav-back':
navigateHistory(-1); navigateHistory(-1);
return; return;
} case 'nav-forward':
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1); navigateHistory(1);
return; return;
} case 'new-note':
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return; return;
} case 'toggle-theme': {
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark'; const next = isDark ? 'light' : 'dark';
$theme = next; $theme = next;
setTheme(next); setTheme(next);
return; return;
} }
if (mod && e.shiftKey && code === 'KeyF') { case 'search-vault':
e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} case 'find-in-note':
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
} else { } else {
$showSearch = true; $showSearch = true;
} }
return; return;
} case 'quick-open':
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return; return;
} case 'save':
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return; return;
} case 'toggle-source':
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} return;
if (mod && e.shiftKey && code === 'KeyW') { case 'open-new-window':
e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
} }
} return;
if (mod && e.key === '\\') { case 'toggle-sidebar':
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed; $sidebarCollapsed = !$sidebarCollapsed;
return; return;
} case 'toggle-focus':
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode; $focusMode = !$focusMode;
return; return;
} case 'toggle-readonly':
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly; $readOnly = !$readOnly;
return; return;
} case 'fullscreen':
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs)); appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return; return;
} }
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
+148 -19
View File
@@ -5,10 +5,85 @@
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
import {
ACTIONS,
keybindings,
setBinding,
resetBinding,
bindingFromEvent,
bindingToKeys,
bindingsEqual,
type ActionDef,
} from '$lib/keybindings';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
];
// id of the action currently listening for a new key combo, or null.
let capturingId = $state<string | null>(null);
// id of an action whose row should briefly flash a "already in use" warning.
let conflictId = $state<string | null>(null);
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
function startCapture(id: string) {
conflictId = null;
capturingId = id;
}
function stopCapture() {
capturingId = null;
}
function flashConflict(id: string) {
conflictId = id;
if (conflictTimer) clearTimeout(conflictTimer);
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
}
function captureKeydown(e: KeyboardEvent) {
if (!capturingId) return;
// Capture phase + stopImmediatePropagation keeps the combo from also
// triggering the app-level shortcut handler on window.
e.preventDefault();
e.stopImmediatePropagation();
if (e.key === 'Escape') { stopCapture(); return; }
const binding = bindingFromEvent(e);
if (!binding) return; // modifier-only press; keep listening
// Reject combos already bound to a different action.
const map = $keybindings;
const target = capturingId;
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
if (clash) {
flashConflict(target);
stopCapture();
return;
}
setBinding(target, binding);
stopCapture();
}
$effect(() => {
if (!capturingId) return;
window.addEventListener('keydown', captureKeydown, true);
return () => window.removeEventListener('keydown', captureKeydown, true);
});
function resetKey(e: MouseEvent, id: string) {
e.preventDefault();
resetBinding(id);
if (capturingId === id) stopCapture();
}
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
@@ -158,27 +233,39 @@
</div> </div>
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4> {#if !isMobile}
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> {/if}
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> {#each actionGroups as group}
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <h4 class="shortcuts-group-title">{group.title}</h4>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> {#each group.actions as action}
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div> <div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
<h4 class="shortcuts-group-title">Interface</h4> {#if isMobile}
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div> <span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div> {:else}
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div> <button
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div> class="shortcut-key-btn"
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div> class:capturing={capturingId === action.id}
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div> class:conflict={conflictId === action.id}
title="Click to rebind · right-click to reset"
onclick={() => startCapture(action.id)}
oncontextmenu={(e) => resetKey(e, action.id)}
>
{#if capturingId === action.id}
<span class="capture-prompt">Press keys…</span>
{:else if conflictId === action.id}
<span class="conflict-msg">Already in use</span>
{:else}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{/if}
</button>
{/if}
</div>
{/each}
{/each}
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4> <h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
@@ -502,6 +589,48 @@
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.shortcuts-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 0 0 4px;
}
.shortcut-key-btn {
background: none;
border: 1px solid transparent;
border-radius: 6px;
padding: 2px 4px;
margin: -2px -4px;
cursor: pointer;
display: flex;
align-items: center;
flex-shrink: 0;
font: inherit;
}
.shortcut-key-btn:hover {
background: var(--bg-hover);
}
.shortcut-key-btn.capturing {
border-color: var(--accent);
background: var(--accent-light);
}
.shortcut-key-btn.conflict {
border-color: var(--danger, #e11d48);
}
.capture-prompt {
font-size: 12px;
color: var(--accent);
}
.conflict-msg {
font-size: 12px;
color: var(--danger, #e11d48);
}
.shortcut-keys kbd, .shortcut-keys kbd,
.shortcut-desc kbd { .shortcut-desc kbd {
display: inline-block; display: inline-block;
+4 -5
View File
@@ -12,6 +12,7 @@
theme theme
} from '$lib/stores/app'; } from '$lib/stores/app';
import { readNote } from '$lib/api'; import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types'; import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props(); let { notePath }: { notePath: string } = $props();
@@ -73,13 +74,11 @@
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const action = matchAction(e, $keybindings);
if (mod && !e.shiftKey && e.code === 'KeyS') { if (action === 'save') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return; } else if (action === 'toggle-source') {
}
if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+163
View File
@@ -0,0 +1,163 @@
import { writable } from "svelte/store";
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
// "Backslash", "ArrowLeft", "F11".
export interface Binding {
mod?: boolean;
shift?: boolean;
alt?: boolean;
code: string;
}
export interface ActionDef {
id: string;
label: string;
group: "General" | "Interface" | "Navigation";
default: Binding;
}
// Every customizable action and its factory-default binding. These mirror the
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
export const ACTIONS: ActionDef[] = [
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
];
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
ACTIONS.map((a) => [a.id, a.default]),
);
const STORAGE_KEY = "helix-keybindings";
function loadBindings(): Record<string, Binding> {
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
try {
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
if (raw) {
const stored = JSON.parse(raw) as Record<string, Binding>;
for (const id of Object.keys(merged)) {
if (stored[id]) merged[id] = stored[id];
}
}
} catch {
// Corrupt storage - fall back to defaults.
}
return merged;
}
export const keybindings = writable<Record<string, Binding>>(loadBindings());
keybindings.subscribe((map) => {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
}
} catch {
// Ignore quota / unavailable storage.
}
});
export function setBinding(id: string, binding: Binding) {
keybindings.update((m) => ({ ...m, [id]: binding }));
}
export function resetBinding(id: string) {
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
}
// True when the keyboard event matches the given binding exactly. Modifier
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
if (!b) return false;
const mod = e.ctrlKey || e.metaKey;
return (
!!b.mod === mod &&
!!b.shift === e.shiftKey &&
!!b.alt === e.altKey &&
e.code === b.code
);
}
// Returns the id of the first action whose binding matches the event, or null.
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
for (const id of Object.keys(map)) {
if (matchBinding(e, map[id])) return id;
}
return null;
}
// Build a Binding from a captured keydown event. Returns null for events that
// are only modifier keys (no real key pressed yet).
const MODIFIER_CODES = new Set([
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
]);
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
return {
mod: e.ctrlKey || e.metaKey,
shift: e.shiftKey,
alt: e.altKey,
code: e.code,
};
}
export function bindingsEqual(a: Binding, b: Binding): boolean {
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
}
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
const CODE_LABELS: Record<string, string> = {
Backslash: "\\",
BracketLeft: "[",
BracketRight: "]",
Comma: ",",
Period: ".",
Slash: "/",
Semicolon: ";",
Quote: "'",
Backquote: "`",
Minus: "-",
Equal: "=",
Space: "Space",
Enter: "Enter",
Tab: "Tab",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
};
function codeToLabel(code: string): string {
if (code.startsWith("Key")) return code.slice(3);
if (code.startsWith("Digit")) return code.slice(5);
if (code.startsWith("Numpad")) return code.slice(6);
return CODE_LABELS[code] ?? code;
}
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
export function bindingToKeys(b: Binding): string[] {
const keys: string[] = [];
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
keys.push(codeToLabel(b.code));
return keys;
}