mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
165 lines
5.9 KiB
TypeScript
165 lines
5.9 KiB
TypeScript
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;
|
|
}
|