mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
Add customizable keyboard shortcuts (#148)
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
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-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user