diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index d77af97..f4f51bc 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -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; diff --git a/src/lib/components/InfoPanel.svelte b/src/lib/components/InfoPanel.svelte index e7e1278..0df984d 100644 --- a/src/lib/components/InfoPanel.svelte +++ b/src/lib/components/InfoPanel.svelte @@ -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(null); + // id of an action whose row should briefly flash a "already in use" warning. + let conflictId = $state(null); + let conflictTimer: ReturnType | 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(null); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let appVersion = $state('...'); @@ -158,28 +233,39 @@ {:else}
-

General

-
New note{modKey}+N
-
Save{modKey}+S
-
Quick open{modKey}+P
-
Find in note{modKey}+F
-
Search vault{modKey}+Shift+F
-
Open note in new window{modKey}+Shift+W
- -

Interface

-
Toggle sidebar{modKey}+\
-
Toggle notes list{modKey}+Shift+\
-
Toggle dark / light theme{modKey}+Shift+N
-
Toggle focus mode{modKey}+Shift+E
-
Toggle read-only{modKey}+Shift+R
-
Toggle source / WYSIWYG{modKey}+Shift+M
-
Toggle fullscreenF11
+ {#if !isMobile} +

Click a shortcut to rebind it, right-click to reset.

+ {/if} + {#each actionGroups as group} +

{group.title}

+ {#each group.actions as action} +
+ {action.label} + {#if isMobile} + {#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}{key}{/each} + {:else} + + {/if} +
+ {/each} + {/each}
Close panel / exit focusEsc
-

Navigation

-
Go backAlt+
-
Go forwardAlt+
-

Formatting

Bold{modKey}+B
Italic{modKey}+I
@@ -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; diff --git a/src/lib/components/NoteWindow.svelte b/src/lib/components/NoteWindow.svelte index d4de6d3..0dee877 100644 --- a/src/lib/components/NoteWindow.svelte +++ b/src/lib/components/NoteWindow.svelte @@ -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; } diff --git a/src/lib/keybindings.ts b/src/lib/keybindings.ts new file mode 100644 index 0000000..8c2a189 --- /dev/null +++ b/src/lib/keybindings.ts @@ -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 = Object.fromEntries( + ACTIONS.map((a) => [a.id, a.default]), +); + +const STORAGE_KEY = "helix-keybindings"; + +function loadBindings(): Record { + const merged: Record = { ...DEFAULT_BINDINGS }; + try { + const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY); + if (raw) { + const stored = JSON.parse(raw) as Record; + 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>(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 | 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 = { + 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; +}