HotKeys Bug Fixes
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0",
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
|
"hotkeys-js": "^4.0.4",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
"yjs": "^13.6.31",
|
"yjs": "^13.6.31",
|
||||||
@@ -345,6 +346,8 @@
|
|||||||
|
|
||||||
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"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=="],
|
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
"isomorphic.js": ["[email protected]", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
"isomorphic.js": ["[email protected]", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-typst": "^0.4.0",
|
"codemirror-lang-typst": "^0.4.0",
|
||||||
|
"hotkeys-js": "^4.0.4",
|
||||||
"y-codemirror.next": "^0.3.5",
|
"y-codemirror.next": "^0.3.5",
|
||||||
"y-websocket": "^3.0.0",
|
"y-websocket": "^3.0.0",
|
||||||
"yjs": "^13.6.31"
|
"yjs": "^13.6.31"
|
||||||
|
|||||||
@@ -7,12 +7,7 @@
|
|||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { EditorState, Compartment, StateField } from "@codemirror/state";
|
import { EditorState, Compartment, StateField } from "@codemirror/state";
|
||||||
import {
|
import { defaultKeymap, history, indentWithTab } from "@codemirror/commands";
|
||||||
defaultKeymap,
|
|
||||||
history,
|
|
||||||
historyKeymap,
|
|
||||||
indentWithTab,
|
|
||||||
} from "@codemirror/commands";
|
|
||||||
import {
|
import {
|
||||||
bracketMatching,
|
bracketMatching,
|
||||||
indentOnInput,
|
indentOnInput,
|
||||||
@@ -46,7 +41,6 @@
|
|||||||
diagnostics?: Diagnostic[];
|
diagnostics?: Diagnostic[];
|
||||||
collab?: { text: Y.Text; awareness: Awareness } | null;
|
collab?: { text: Y.Text; awareness: Awareness } | null;
|
||||||
onchange: (value: string) => void;
|
onchange: (value: string) => void;
|
||||||
onsave: () => void;
|
|
||||||
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
onlspstatus?: (status: "off" | "starting" | "on" | "unavailable") => void;
|
||||||
onready?: (view: EditorView | null) => void;
|
onready?: (view: EditorView | null) => void;
|
||||||
}
|
}
|
||||||
@@ -59,7 +53,6 @@
|
|||||||
diagnostics = [],
|
diagnostics = [],
|
||||||
collab = null,
|
collab = null,
|
||||||
onchange,
|
onchange,
|
||||||
onsave,
|
|
||||||
onlspstatus,
|
onlspstatus,
|
||||||
onready,
|
onready,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
@@ -170,17 +163,8 @@
|
|||||||
: [autocompletion({ override: [typstCompletions] })]),
|
: [autocompletion({ override: [typstCompletions] })]),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
keymap.of([
|
keymap.of([
|
||||||
{
|
|
||||||
key: "Mod-s",
|
|
||||||
preventDefault: true,
|
|
||||||
run: () => {
|
|
||||||
onsave();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...closeBracketsKeymap,
|
...closeBracketsKeymap,
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap,
|
|
||||||
indentWithTab,
|
indentWithTab,
|
||||||
]),
|
]),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
import * as api from "$lib/ts/api";
|
import * as api from "$lib/ts/api";
|
||||||
import type { AppInfo, CompatibilityStatus } 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 {
|
import {
|
||||||
app,
|
app,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
@@ -35,6 +43,7 @@
|
|||||||
| "accessibility"
|
| "accessibility"
|
||||||
| "account"
|
| "account"
|
||||||
| "lsp"
|
| "lsp"
|
||||||
|
| "hotkeys"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
const sections: { id: Section; label: string; icon: string }[] = [
|
const sections: { id: Section; label: string; icon: string }[] = [
|
||||||
@@ -43,9 +52,115 @@
|
|||||||
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
|
{ id: "accessibility", label: "Accessibility", icon: "ph:wheelchair" },
|
||||||
{ id: "account", label: "Account", icon: "ph:user-circle" },
|
{ id: "account", label: "Account", icon: "ph:user-circle" },
|
||||||
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
|
{ id: "lsp", label: "Language Server", icon: "ph:plugs-connected" },
|
||||||
|
{ id: "hotkeys", label: "Hotkeys", icon: "ph:keyboard" },
|
||||||
{ id: "about", label: "About", icon: "ph:info" },
|
{ 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> = {
|
const lspLabel: Record<string, string> = {
|
||||||
off: "Off",
|
off: "Off",
|
||||||
starting: "Starting…",
|
starting: "Starting…",
|
||||||
@@ -507,6 +622,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -569,7 +766,8 @@
|
|||||||
section === "about" ||
|
section === "about" ||
|
||||||
section === "appearance" ||
|
section === "appearance" ||
|
||||||
section === "accessibility" ||
|
section === "accessibility" ||
|
||||||
section === "lsp"}
|
section === "lsp" ||
|
||||||
|
section === "hotkeys"}
|
||||||
<button
|
<button
|
||||||
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
class="rounded-md px-3 py-1.5 text-xs text-[var(--color-ink-muted)] hover:bg-[var(--color-surface-sunken)]"
|
||||||
onclick={onclose}
|
onclick={onclose}
|
||||||
|
|||||||
@@ -18,10 +18,40 @@ export function wrapSelection(
|
|||||||
placeholder = "",
|
placeholder = "",
|
||||||
) {
|
) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
const selection = view.state.selection.main;
|
const { state } = view;
|
||||||
const selected = view.state.doc.sliceString(selection.from, selection.to);
|
const selection = state.selection.main;
|
||||||
const body = selected || placeholder;
|
const selected = state.doc.sliceString(selection.from, selection.to);
|
||||||
|
|
||||||
|
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({
|
view.dispatch({
|
||||||
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
|
changes: { from: selection.from, to: selection.to, insert: prefix + body + suffix },
|
||||||
selection: {
|
selection: {
|
||||||
@@ -29,6 +59,7 @@ export function wrapSelection(
|
|||||||
head: selection.from + prefix.length + body.length,
|
head: selection.from + prefix.length + body.length,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
view.focus();
|
view.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const palette: Record<"light" | "dark", ThemeColors> = {
|
|||||||
background: "#16181d",
|
background: "#16181d",
|
||||||
surface: "#1d2026",
|
surface: "#1d2026",
|
||||||
text: "#eef0f4",
|
text: "#eef0f4",
|
||||||
selection: "#2f3a52",
|
selection: "#3f5a91",
|
||||||
activeLine: "#1d2026",
|
activeLine: "#1d2026",
|
||||||
cursor: "#6b93ff",
|
cursor: "#6b93ff",
|
||||||
border: "#2f343d",
|
border: "#2f343d",
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { registerHotkey, unregisterAll } from "$lib/ts/hotkeys";
|
||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { writeImage, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { Image } from "@tauri-apps/api/image";
|
import { Image } from "@tauri-apps/api/image";
|
||||||
@@ -24,7 +25,13 @@
|
|||||||
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
import PageSettingsModal from "$lib/components/PageSettingsModal.svelte";
|
||||||
|
|
||||||
import type { EditorView } from "@codemirror/view";
|
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 * as api from "$lib/ts/api";
|
||||||
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
|
import type { BrowseEntry, CloudFile, CloudFolder } from "$lib/ts/api";
|
||||||
@@ -446,13 +453,6 @@
|
|||||||
setStatus("Conflicts resolved and uploaded");
|
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);
|
let dropActive = $state(false);
|
||||||
|
|
||||||
function dropDestination():
|
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 () => {
|
return () => {
|
||||||
pending.then((unlisten) => unlisten());
|
pending.then((unlisten) => unlisten());
|
||||||
downloads.then((unlisten) => unlisten());
|
downloads.then((unlisten) => unlisten());
|
||||||
|
unregisterAll();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -548,7 +603,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
|
||||||
|
|
||||||
{#snippet statusBadge(
|
{#snippet statusBadge(
|
||||||
icon: string,
|
icon: string,
|
||||||
@@ -956,7 +1010,6 @@
|
|||||||
scheduleCompile();
|
scheduleCompile();
|
||||||
scheduleAutosave();
|
scheduleAutosave();
|
||||||
}}
|
}}
|
||||||
onsave={saveAndCompile}
|
|
||||||
onlspstatus={(status) => (app.lspStatus = status)}
|
onlspstatus={(status) => (app.lspStatus = status)}
|
||||||
onready={(view) => (editorView = view)}
|
onready={(view) => (editorView = view)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user