HotKeys Bug Fixes

This commit is contained in:
2026-07-22 10:39:29 -04:00
parent 5f6b2b3883
commit ea53f92f62
8 changed files with 463 additions and 39 deletions
+3
View File
@@ -22,6 +22,7 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"hotkeys-js": "^4.0.4",
"y-codemirror.next": "^0.3.5",
"y-websocket": "^3.0.0",
"yjs": "^13.6.31",
@@ -345,6 +346,8 @@
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hotkeys-js": ["[email protected]", "", {}, "sha512-hseNiqaskxSnujuGp8aRMLJfcjaFiTSS0I2GQhqru82N/sx6CGyUf6pvU5X1iycvw2EqmvILkFIb5OzYFXY+9A=="],
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"isomorphic.js": ["[email protected]", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
+1
View File
@@ -31,6 +31,7 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"codemirror": "^6.0.2",
"codemirror-lang-typst": "^0.4.0",
"hotkeys-js": "^4.0.4",
"y-codemirror.next": "^0.3.5",
"y-websocket": "^3.0.0",
"yjs": "^13.6.31"
+1 -17
View File
@@ -7,12 +7,7 @@
highlightActiveLine,
} from "@codemirror/view";
import { EditorState, Compartment, StateField } from "@codemirror/state";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { defaultKeymap, history, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
indentOnInput,
@@ -46,7 +41,6 @@
diagnostics?: Diagnostic[];
collab?: { text: Y.Text; awareness: Awareness } | null;
onchange: (value: string) => void;
onsave: () => void;
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
onready?: (view: EditorView | null) => void;
}
@@ -59,7 +53,6 @@
diagnostics = [],
collab = null,
onchange,
onsave,
onlspstatus,
onready,
}: Props = $props();
@@ -170,17 +163,8 @@
: [autocompletion({ override: [typstCompletions] })]),
EditorView.lineWrapping,
keymap.of([
{
key: "Mod-s",
preventDefault: true,
run: () => {
onsave();
return true;
},
},
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
+199 -1
View File
@@ -6,6 +6,14 @@
import Modal from "./Modal.svelte";
import * as api from "$lib/ts/api";
import type { AppInfo, CompatibilityStatus } from "$lib/ts/api";
import {
HOTKEY_DEFS,
comboFromEvent,
isCustomized,
keysFor,
rebindHotkey,
resetHotkey,
} from "$lib/ts/hotkeys";
import {
app,
applyTheme,
@@ -35,6 +43,7 @@
| "accessibility"
| "account"
| "lsp"
| "hotkeys"
| "about";
const sections: { id: Section; label: string; icon: string }[] = [
@@ -43,9 +52,115 @@
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
{ id: "account", label: "Account", icon: "ph:user-circle" },
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
{ id: "hotkeys", label: "Hotkeys", icon: "ph:keyboard" },
{ id: "about", label: "About", icon: "ph:info" },
];
const isMac =
typeof navigator !== "undefined" &&
/mac/i.test(navigator.platform ?? navigator.userAgent);
function formatKeys(combo: string): string[] {
const variants = combo.split(",");
const preferred =
variants.find((variant) =>
isMac ? variant.includes("command") : !variant.includes("command"),
) ?? variants[0];
return preferred.split("+").map((part) => {
switch (part) {
case "command":
return "⌘";
case "ctrl":
return "Ctrl";
case "alt":
return isMac ? "⌥" : "Alt";
case "shift":
return "Shift";
case "esc":
return "Esc";
case "space":
return "Space";
case "up":
return "↑";
case "down":
return "↓";
case "left":
return "←";
case "right":
return "→";
default:
return part.length === 1 ? part.toUpperCase() : part;
}
});
}
let editingId = $state<string | null>(null);
let hotkeyVersion = $state(0);
const editableHotkeyGroups = $derived.by(() => {
hotkeyVersion;
const groups = new Map<string, typeof HOTKEY_DEFS>();
for (const def of HOTKEY_DEFS) {
if (!groups.has(def.group)) groups.set(def.group, []);
groups.get(def.group)!.push(def);
}
return Array.from(groups.entries()).map(([title, defs]) => ({
title,
items: defs.map((def) => ({
id: def.id,
label: def.label,
keys: keysFor(def.id),
customized: isCustomized(def.id),
})),
}));
});
$effect(() => {
if (!editingId) return;
const id = editingId;
function handleCapture(event: KeyboardEvent) {
event.preventDefault();
event.stopPropagation();
if (event.key === "Escape") {
editingId = null;
return;
}
const combo = comboFromEvent(event);
if (!combo) return;
rebindHotkey(id, combo);
hotkeyVersion++;
editingId = null;
}
window.addEventListener("keydown", handleCapture, true);
return () => window.removeEventListener("keydown", handleCapture, true);
});
const staticHotkeyGroups: { title: string; items: { keys: string[]; label: string }[] }[] = [
{
title: "File browser",
items: [
{ keys: ["F2"], label: "Rename selected entry" },
{ keys: ["Delete"], label: "Delete selected entries" },
{ keys: ["Esc"], label: "Cancel rename or clear selection" },
],
},
{
title: "Image viewer",
items: [
{ keys: ["←"], label: "Previous image" },
{ keys: ["→"], label: "Next image" },
{ keys: ["Esc"], label: "Close viewer" },
],
},
{
title: "General",
items: [{ keys: ["Esc"], label: "Close dialog" }],
},
];
const lspLabel: Record<string, string> = {
off: "Off",
starting: "Starting…",
@@ -507,6 +622,88 @@
</div>
{/if}
</div>
{:else if section === "hotkeys"}
<div class="flex flex-col gap-5">
<p class="text-xs text-[var(--color-ink-muted)]">
Click the pencil next to a shortcut and press a new key combination.
Press Esc while listening to cancel.
</p>
{#each editableHotkeyGroups as group}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">
{group.title}
</span>
<div class="flex flex-col divide-y divide-[var(--color-line)] rounded-md border border-[var(--color-line)]">
{#each group.items as item}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="text-xs">{item.label}</span>
{#if editingId === item.id}
<span class="text-xs text-[var(--color-accent)]">
Press keys… (Esc to cancel)
</span>
{:else}
<span class="flex shrink-0 items-center gap-1">
{#each formatKeys(item.keys) as key}
<kbd
class="rounded border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-ink-muted)]"
>
{key}
</kbd>
{/each}
{#if item.customized}
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Reset to default"
aria-label="Reset to default"
onclick={() => {
resetHotkey(item.id);
hotkeyVersion++;
}}
>
<Icon icon="ph:arrow-counter-clockwise" class="text-xs" />
</button>
{/if}
<button
class="rounded p-1 text-[var(--color-ink-muted)] transition hover:bg-[var(--color-surface-sunken)] hover:text-[var(--color-ink)]"
title="Change shortcut"
aria-label="Change shortcut"
onclick={() => (editingId = item.id)}
>
<Icon icon="ph:pencil-simple" class="text-xs" />
</button>
</span>
{/if}
</div>
{/each}
</div>
</div>
{/each}
{#each staticHotkeyGroups as group}
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-ink-muted)]">
{group.title}
</span>
<div class="flex flex-col divide-y divide-[var(--color-line)] rounded-md border border-[var(--color-line)]">
{#each group.items as item}
<div class="flex items-center justify-between gap-4 px-3 py-2">
<span class="text-xs">{item.label}</span>
<span class="flex shrink-0 items-center gap-1">
{#each item.keys as key}
<kbd
class="rounded border border-[var(--color-line)] bg-[var(--color-surface-muted)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-ink-muted)]"
>
{key}
</kbd>
{/each}
</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3">
@@ -569,7 +766,8 @@
section === "about" ||
section === "appearance" ||
section === "accessibility" ||
section === "lsp"}
section === "lsp" ||
section === "hotkeys"}
<button
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
onclick={onclose}
+41 -10
View File
@@ -18,17 +18,48 @@ export function wrapSelection(
placeholder = "",
) {
if (!view) return;
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to);
const body = selected || placeholder;
const { state } = view;
const selection = state.selection.main;
const selected = state.doc.sliceString(selection.from, selection.to);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
selection: {
anchor: selection.from + prefix.length,
head: selection.from + prefix.length + body.length,
},
});
const innerWrapped =
selected.length >= prefix.length + suffix.length &&
selected.startsWith(prefix) &&
selected.endsWith(suffix);
const before = state.doc.sliceString(
Math.max(0, selection.from - prefix.length),
selection.from,
);
const after = state.doc.sliceString(
selection.to,
Math.min(state.doc.length, selection.to + suffix.length),
);
const outerWrapped = before === prefix && after === suffix;
if (innerWrapped) {
const inner = selected.slice(prefix.length, selected.length - suffix.length);
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: inner },
selection: { anchor: selection.from, head: selection.from + inner.length },
});
} else if (outerWrapped) {
const from = selection.from - prefix.length;
const to = selection.to + suffix.length;
view.dispatch({
changes: { from, to, insert: selected },
selection: { anchor: from, head: from + selected.length },
});
} else {
const body = selected || placeholder;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
selection: {
anchor: selection.from + prefix.length,
head: selection.from + prefix.length + body.length,
},
});
}
view.focus();
}
+1 -1
View File
@@ -40,7 +40,7 @@ const palette: Record<"light" | "dark", ThemeColors> = {
background: "#16181d",
surface: "#1d2026",
text: "#eef0f4",
selection: "#2f3a52",
selection: "#3f5a91",
activeLine: "#1d2026",
cursor: "#6b93ff",
border: "#2f343d",
+154
View File
@@ -0,0 +1,154 @@
import hotkeys from "hotkeys-js";
// The default filter ignores any contenteditable target, which silently
// blocks every shortcut while the CodeMirror editor (contenteditable) has
// focus — exactly when these shortcuts are meant to fire. Only keep the
// exclusion for classic form fields (rename dialogs, prompts, etc).
hotkeys.filter = (event: KeyboardEvent) => {
const target = (event.target as HTMLElement | null) ?? null;
const tagName = target?.tagName;
return tagName !== "INPUT" && tagName !== "TEXTAREA" && tagName !== "SELECT";
};
export interface HotkeyDef {
id: string;
group: string;
label: string;
defaultKeys: string;
}
export const HOTKEY_DEFS: HotkeyDef[] = [
{ id: "save", group: "Editor", label: "Save and compile", defaultKeys: "command+s,ctrl+s" },
{ id: "undo", group: "Editor", label: "Undo", defaultKeys: "command+z,ctrl+z" },
{
id: "redo",
group: "Editor",
label: "Redo",
defaultKeys: "command+shift+z,ctrl+shift+z,ctrl+y",
},
{
id: "toggleSidebar",
group: "Editor",
label: "Toggle file sidebar",
defaultKeys: "command+shift+b,ctrl+shift+b",
},
{ id: "bold", group: "Formatting", label: "Bold (toggle)", defaultKeys: "command+b,ctrl+b" },
{ id: "italic", group: "Formatting", label: "Italic (toggle)", defaultKeys: "command+i,ctrl+i" },
{
id: "underline",
group: "Formatting",
label: "Underline (toggle)",
defaultKeys: "command+u,ctrl+u",
},
{
id: "strikethrough",
group: "Formatting",
label: "Strikethrough (toggle)",
defaultKeys: "command+shift+x,ctrl+shift+x",
},
{ id: "link", group: "Formatting", label: "Insert link", defaultKeys: "command+k,ctrl+k" },
{
id: "numberedList",
group: "Formatting",
label: "Numbered list",
defaultKeys: "command+shift+7,ctrl+shift+7",
},
{
id: "bulletedList",
group: "Formatting",
label: "Bulleted list",
defaultKeys: "command+shift+8,ctrl+shift+8",
},
...[1, 2, 3, 4, 5, 6].map((level) => ({
id: `heading${level}`,
group: "Formatting",
label: `Heading level ${level}`,
defaultKeys: `command+alt+${level},ctrl+alt+${level}`,
})),
];
const STORAGE_KEY = "hotkey-overrides";
function loadOverrides(): Record<string, string> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
} catch {
return {};
}
}
function saveOverrides(overrides: Record<string, string>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
}
export function keysFor(id: string): string {
const def = HOTKEY_DEFS.find((entry) => entry.id === id);
const overrides = loadOverrides();
return overrides[id] ?? def?.defaultKeys ?? "";
}
export function isCustomized(id: string): boolean {
return id in loadOverrides();
}
const registered = new Map<string, { keys: string; handler: (event: KeyboardEvent) => void }>();
export function registerHotkey(id: string, handler: (event: KeyboardEvent) => void) {
const keys = keysFor(id);
if (!keys) return;
hotkeys(keys, handler);
registered.set(id, { keys, handler });
}
export function unregisterAll() {
for (const { keys } of registered.values()) hotkeys.unbind(keys);
registered.clear();
}
export function rebindHotkey(id: string, newKeys: string) {
const entry = registered.get(id);
if (!entry) return;
hotkeys.unbind(entry.keys);
hotkeys(newKeys, entry.handler);
registered.set(id, { keys: newKeys, handler: entry.handler });
const def = HOTKEY_DEFS.find((item) => item.id === id);
const overrides = loadOverrides();
if (def && def.defaultKeys === newKeys) {
delete overrides[id];
} else {
overrides[id] = newKeys;
}
saveOverrides(overrides);
}
export function resetHotkey(id: string) {
const def = HOTKEY_DEFS.find((entry) => entry.id === id);
if (def) rebindHotkey(id, def.defaultKeys);
}
const NAMED_KEYS: Record<string, string> = {
" ": "space",
Escape: "esc",
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
export function comboFromEvent(event: KeyboardEvent): string | null {
if (["Control", "Shift", "Alt", "Meta"].includes(event.key)) return null;
const parts: string[] = [];
if (event.ctrlKey) parts.push("ctrl");
if (event.metaKey) parts.push("command");
if (event.altKey) parts.push("alt");
if (event.shiftKey) parts.push("shift");
const key = event.key;
const mainKey = key.length === 1 ? key.toLowerCase() : (NAMED_KEYS[key] ?? key.toLowerCase());
parts.push(mainKey);
return parts.join("+");
}
+63 -10
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { registerHotkey, unregisterAll } from "$lib/ts/hotkeys";
import { save } from "@tauri-apps/plugin-dialog";
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { Image } from "@tauri-apps/api/image";
@@ -24,7 +25,13 @@
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
import type { EditorView } from "@codemirror/view";
import { insertText, redoEdit, undoEdit } from "$lib/ts/editor-actions";
import {
insertText,
prefixLines,
redoEdit,
undoEdit,
wrapSelection,
} from "$lib/ts/editor-actions";
import * as api from "$lib/ts/api";
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
@@ -446,13 +453,6 @@
setStatus("Conflicts resolved and uploaded");
});
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
if (app.view === "editor") saveAndCompile();
}
}
let dropActive = $state(false);
function dropDestination():
@@ -528,9 +528,64 @@
}
});
registerHotkey("save", (event) => {
event.preventDefault();
if (app.view === "editor") saveAndCompile();
});
registerHotkey("undo", (event) => {
event.preventDefault();
if (app.view === "editor") undoEdit(editorView);
});
registerHotkey("redo", (event) => {
event.preventDefault();
if (app.view === "editor") redoEdit(editorView);
});
registerHotkey("toggleSidebar", (event) => {
event.preventDefault();
if (app.view === "editor" && !app.target?.standalone) toggleSidebar();
});
registerHotkey("bold", (event) => {
event.preventDefault();
if (app.view === "editor") wrapSelection(editorView, "*", "*", "bold");
});
registerHotkey("italic", (event) => {
event.preventDefault();
if (app.view === "editor") wrapSelection(editorView, "_", "_", "italic");
});
registerHotkey("underline", (event) => {
event.preventDefault();
if (app.view === "editor")
wrapSelection(editorView, "#underline[", "]", "underlined");
});
registerHotkey("strikethrough", (event) => {
event.preventDefault();
if (app.view === "editor")
wrapSelection(editorView, "#strike[", "]", "struck through");
});
registerHotkey("link", (event) => {
event.preventDefault();
if (app.view === "editor") insertText(editorView, '#link("https://")[text]');
});
registerHotkey("numberedList", (event) => {
event.preventDefault();
if (app.view === "editor") prefixLines(editorView, "+ ", "Numbered item");
});
registerHotkey("bulletedList", (event) => {
event.preventDefault();
if (app.view === "editor") prefixLines(editorView, "- ", "List item");
});
for (const level of [1, 2, 3, 4, 5, 6]) {
registerHotkey(`heading${level}`, (event) => {
event.preventDefault();
if (app.view === "editor")
prefixLines(editorView, "=".repeat(level) + " ", "Heading");
});
}
return () => {
pending.then((unlisten) => unlisten());
downloads.then((unlisten) => unlisten());
unregisterAll();
};
});
@@ -548,7 +603,6 @@
};
</script>
<svelte:window on:keydown={handleKeydown} />
{#snippet statusBadge(
icon: string,
@@ -956,7 +1010,6 @@
scheduleCompile();
scheduleAutosave();
}}
onsave={saveAndCompile}
onlspstatus={(status) => (app.lspStatus = status)}
onready={(view) => (editorView = view)}
/>