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
+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;
}