Add customizable keyboard shortcuts (#148)

This commit is contained in:
Yuri Karamian
2026-06-21 13:56:55 +02:00
4 changed files with 380 additions and 104 deletions
+64 -79
View File
@@ -52,6 +52,7 @@
syncState,
platformIsMobile
} from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
@@ -409,92 +410,76 @@
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault();
createAndFocusNote();
return;
}
if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault();
$showSearch = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault();
$showCommandPalette = true;
return;
}
if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && code === 'KeyM') {
const action = matchAction(e, $keybindings);
if (action) {
e.preventDefault();
$sourceMode = !$sourceMode;
}
if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
switch (action) {
case 'nav-back':
navigateHistory(-1);
return;
case 'nav-forward':
navigateHistory(1);
return;
case 'new-note':
createAndFocusNote();
return;
case 'toggle-theme': {
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
}
case 'search-vault':
$showSearch = true;
return;
case 'find-in-note':
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
case 'quick-open':
$showCommandPalette = true;
return;
case 'save':
editor?.forceSave();
return;
case 'toggle-source':
$sourceMode = !$sourceMode;
return;
case 'open-new-window':
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
return;
case 'toggle-sidebar':
$sidebarCollapsed = !$sidebarCollapsed;
return;
case 'toggle-notelist':
toggleNoteList();
return;
case 'toggle-focus':
$focusMode = !$focusMode;
return;
case 'toggle-readonly':
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
case 'fullscreen':
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
}
if (mod && (code === 'Backslash' || e.key === '\\' || e.key === '|')) {
e.preventDefault();
if (e.shiftKey) {
toggleNoteList();
} else {
$sidebarCollapsed = !$sidebarCollapsed;
}
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
if ($viewerNote) return; // viewer mode is always read-only
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') {
if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false;
+148 -20
View File
@@ -5,10 +5,85 @@
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
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 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 activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
@@ -158,28 +233,39 @@
</div>
{:else}
<div class="shortcuts-section">
<h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<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>
<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>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle notes list</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>\</kbd></span></div>
<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>
<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>
<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>
<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>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
{#if !isMobile}
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
{/if}
{#each actionGroups as group}
<h4 class="shortcuts-group-title">{group.title}</h4>
{#each group.actions as action}
<div class="shortcut-row">
<span class="shortcut-desc">{action.label}</span>
{#if isMobile}
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
{:else}
<button
class="shortcut-key-btn"
class:capturing={capturingId === action.id}
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>
<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>
<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>
@@ -503,6 +589,48 @@
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-desc kbd {
display: inline-block;
+4 -5
View File
@@ -12,6 +12,7 @@
theme
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import { keybindings, matchAction } from '$lib/keybindings';
import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props();
@@ -73,13 +74,11 @@
}
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && e.code === 'KeyS') {
const action = matchAction(e, $keybindings);
if (action === 'save') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.shiftKey && e.code === 'KeyM') {
} else if (action === 'toggle-source') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
+164
View File
@@ -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;
}