mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
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:
@@ -51,6 +51,7 @@
|
|||||||
syncState,
|
syncState,
|
||||||
platformIsMobile
|
platformIsMobile
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
|
|
||||||
const appWindow = getCurrentWindow();
|
const appWindow = getCurrentWindow();
|
||||||
const isMac = navigator.platform.startsWith('Mac');
|
const isMac = navigator.platform.startsWith('Mac');
|
||||||
@@ -394,88 +395,73 @@
|
|||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const code = e.code;
|
const code = e.code;
|
||||||
if (e.altKey && e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.addLinkFromToolbar();
|
editor?.addLinkFromToolbar();
|
||||||
}
|
}
|
||||||
if (mod && e.shiftKey && code === 'KeyM') {
|
|
||||||
|
const action = matchAction(e, $keybindings);
|
||||||
|
if (action) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
switch (action) {
|
||||||
}
|
case 'nav-back':
|
||||||
if (mod && e.shiftKey && code === 'KeyW') {
|
navigateHistory(-1);
|
||||||
e.preventDefault();
|
return;
|
||||||
if ($activeNotePath && $activeNote) {
|
case 'nav-forward':
|
||||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
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-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 && e.key === '\\') {
|
|
||||||
e.preventDefault();
|
|
||||||
$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 (e.key === 'Escape') {
|
||||||
if ($showSettings) $showSettings = false;
|
if ($showSettings) $showSettings = false;
|
||||||
else if ($showInfo) $showInfo = false;
|
else if ($showInfo) $showInfo = false;
|
||||||
|
|||||||
@@ -5,10 +5,85 @@
|
|||||||
import { openUrl } from '$lib/api';
|
import { openUrl } from '$lib/api';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import type { VaultStats } from '$lib/types';
|
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 modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||||
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
|
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 stats = $state<VaultStats | null>(null);
|
||||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||||
let appVersion = $state('...');
|
let appVersion = $state('...');
|
||||||
@@ -158,27 +233,39 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="shortcuts-section">
|
<div class="shortcuts-section">
|
||||||
<h4 class="shortcuts-group-title">General</h4>
|
{#if !isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
{/if}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
{#each actionGroups as group}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||||
<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>
|
{#each group.actions as action}
|
||||||
<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>
|
<div class="shortcut-row">
|
||||||
|
<span class="shortcut-desc">{action.label}</span>
|
||||||
<h4 class="shortcuts-group-title">Interface</h4>
|
{#if isMobile}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||||
<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>
|
{:else}
|
||||||
<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>
|
<button
|
||||||
<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>
|
class="shortcut-key-btn"
|
||||||
<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>
|
class:capturing={capturingId === action.id}
|
||||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
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>
|
<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>
|
<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">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>
|
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||||
@@ -502,6 +589,48 @@
|
|||||||
color: var(--text-tertiary);
|
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-keys kbd,
|
||||||
.shortcut-desc kbd {
|
.shortcut-desc kbd {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
theme
|
theme
|
||||||
} from '$lib/stores/app';
|
} from '$lib/stores/app';
|
||||||
import { readNote } from '$lib/api';
|
import { readNote } from '$lib/api';
|
||||||
|
import { keybindings, matchAction } from '$lib/keybindings';
|
||||||
import type { FileEvent } from '$lib/types';
|
import type { FileEvent } from '$lib/types';
|
||||||
|
|
||||||
let { notePath }: { notePath: string } = $props();
|
let { notePath }: { notePath: string } = $props();
|
||||||
@@ -73,13 +74,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const action = matchAction(e, $keybindings);
|
||||||
if (mod && !e.shiftKey && e.code === 'KeyS') {
|
if (action === 'save') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
editor?.forceSave();
|
editor?.forceSave();
|
||||||
return;
|
} else if (action === 'toggle-source') {
|
||||||
}
|
|
||||||
if (mod && e.shiftKey && e.code === 'KeyM') {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
$sourceMode = !$sourceMode;
|
$sourceMode = !$sourceMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user