From d4fe1b55af10b4c63b8f0ef07f0407ca21b46b8b Mon Sep 17 00:00:00 2001 From: can Date: Mon, 31 Aug 2026 23:25:03 +0100 Subject: [PATCH] feat: add markdown formatting keyboard shortcuts --- .../MemoEditor/Editor/extensions.ts | 5 +- .../MemoEditor/Editor/formatting.ts | 50 ++++++++++++------- .../MemoEditor/Editor/formattingKeymap.ts | 31 ++++++++++++ .../memo-editor-formatting-keymap.test.ts | 46 +++++++++++++++++ 4 files changed, 112 insertions(+), 20 deletions(-) create mode 100644 web/src/components/MemoEditor/Editor/formattingKeymap.ts create mode 100644 web/tests/memo-editor-formatting-keymap.test.ts diff --git a/web/src/components/MemoEditor/Editor/extensions.ts b/web/src/components/MemoEditor/Editor/extensions.ts index 82869e55d5c2e..a80f88062617e 100644 --- a/web/src/components/MemoEditor/Editor/extensions.ts +++ b/web/src/components/MemoEditor/Editor/extensions.ts @@ -4,6 +4,7 @@ import { indentUnit } from "@codemirror/language"; import { Compartment, type Extension } from "@codemirror/state"; import { placeholder as cmPlaceholder, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view"; import { memoMarkdownExtensions } from "@/utils/memo-markdown-extension"; +import { formattingKeymap } from "./formattingKeymap"; import { headingDecorations } from "./headingDecorations"; import { liftListItem, sinkListItem } from "./listIndent"; import { tagAutocomplete } from "./tagAutocomplete"; @@ -113,7 +114,9 @@ export function buildEditorExtensions({ // tagAutocomplete must precede the editing keymap so the completion popup's // Enter/Tab/arrow bindings win while it is open. tagAutocomplete(getTags), - keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]), + // formattingKeymap sits above defaultKeymap so Mod-b/i/e/k reach the + // formatting commands instead of any default binding on the same chord. + keymap.of([...submitKeys, ...editorKeys, ...formattingKeymap, indentWithTab, ...defaultKeymap, ...historyKeymap]), EditorView.updateListener.of((u) => { if (u.docChanged) onChange(u.state.doc.toString()); // Toolbar active-state depends only on the doc and selection; skip the diff --git a/web/src/components/MemoEditor/Editor/formatting.ts b/web/src/components/MemoEditor/Editor/formatting.ts index 3b01796dc80bb..a97dd1cb02586 100644 --- a/web/src/components/MemoEditor/Editor/formatting.ts +++ b/web/src/components/MemoEditor/Editor/formatting.ts @@ -316,28 +316,40 @@ function unwrapLink(view: EditorView): boolean { return false; } +/** + * Apply a formatting command to the live editor. Shared by the controller + * (toolbar, slash menu) and the formatting keymap so every entry point runs + * the exact same toggle logic. + */ +export function runFormattingCommand(view: EditorView, command: EditorCommandId, ctx?: EditorCommandContext) { + if (isMarkCommand(command)) return toggleMark(view, command); + if (command === "codeBlock") return toggleCodeBlock(view); + if (command === "bulletList" || command === "orderedList" || command === "taskList") { + return toggleListLine(view, command); + } + if (command === "heading1") return setHeading(view, 1); + if (command === "heading2") return setHeading(view, 2); + if (command === "heading3") return setHeading(view, 3); + if (command === "paragraph") return setHeading(view, 0); + if (command === "link") { + // Toggle: inside an existing link, unwrap it to its label. + if (unwrapLink(view)) return; + const { from, to } = view.state.selection.main; + const url = ctx?.url ?? ""; + // Empty selection: the URL doubles as the label. + const label = view.state.sliceDoc(from, to) || url; + const insert = `[${label}](${url})`; + // Without a URL the cursor lands between the parens, ready to type the + // target; with one, after the finished link. + const anchor = from + (url ? insert.length : insert.length - 1); + view.dispatch({ changes: { from, to, insert }, selection: { anchor } }); + } +} + export function createFormattingController(view: EditorView, listeners: Set<() => void>): FormattingController { return { run(command: EditorCommandId, ctx?: EditorCommandContext) { - if (isMarkCommand(command)) return toggleMark(view, command); - if (command === "codeBlock") return toggleCodeBlock(view); - if (command === "bulletList" || command === "orderedList" || command === "taskList") { - return toggleListLine(view, command); - } - if (command === "heading1") return setHeading(view, 1); - if (command === "heading2") return setHeading(view, 2); - if (command === "heading3") return setHeading(view, 3); - if (command === "paragraph") return setHeading(view, 0); - if (command === "link") { - // Toggle: inside an existing link, unwrap it to its label. - if (unwrapLink(view)) return; - const { from, to } = view.state.selection.main; - const url = ctx?.url ?? ""; - // Empty selection: the URL doubles as the label. - const label = view.state.sliceDoc(from, to) || url; - const insert = `[${label}](${url})`; - view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length } }); - } + return runFormattingCommand(view, command, ctx); }, getActiveFormats(): ActiveFormatState { const pos = view.state.selection.main.head; diff --git a/web/src/components/MemoEditor/Editor/formattingKeymap.ts b/web/src/components/MemoEditor/Editor/formattingKeymap.ts new file mode 100644 index 0000000000000..1ea70ee8076ea --- /dev/null +++ b/web/src/components/MemoEditor/Editor/formattingKeymap.ts @@ -0,0 +1,31 @@ +import type { KeyBinding } from "@codemirror/view"; +import type { EditorCommandId } from "../formatting/commands"; +import { runFormattingCommand } from "./formatting"; + +// Keyboard shortcuts for the formatting verbs, matching what other markdown +// editors (Obsidian, Notion, chat inputs) have made muscle memory. `Mod-` is +// Cmd on macOS and Ctrl elsewhere. Each binding runs the same command the +// toolbar button does, so toggle/strip behavior can never diverge between +// mouse and keyboard. +// +// Only inline marks and link are bound: block commands (lists, headings, code +// block) have no widely shared convention, and unused bindings would shadow +// browser/OS shortcuts for no gain. +const FORMATTING_BINDINGS: [string, EditorCommandId][] = [ + ["Mod-b", "bold"], + ["Mod-i", "italic"], + ["Mod-Shift-x", "strikethrough"], + ["Mod-e", "code"], + ["Mod-k", "link"], +]; + +export const formattingKeymap: KeyBinding[] = FORMATTING_BINDINGS.map(([key, command]) => ({ + key, + // Always claim the chord, even where the browser has its own use for it + // (e.g. Ctrl-K focuses the address bar in some browsers). + preventDefault: true, + run: (view) => { + runFormattingCommand(view, command); + return true; + }, +})); diff --git a/web/tests/memo-editor-formatting-keymap.test.ts b/web/tests/memo-editor-formatting-keymap.test.ts new file mode 100644 index 0000000000000..132ffef74aa37 --- /dev/null +++ b/web/tests/memo-editor-formatting-keymap.test.ts @@ -0,0 +1,46 @@ +import { markdown } from "@codemirror/lang-markdown"; +import { EditorSelection, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { GFM } from "@lezer/markdown"; +import { describe, expect, it } from "vitest"; +import { createFormattingController } from "@/components/MemoEditor/Editor/formatting"; +import { formattingKeymap } from "@/components/MemoEditor/Editor/formattingKeymap"; + +function setup(doc: string, from: number, to: number) { + const view = new EditorView({ + state: EditorState.create({ doc, extensions: [markdown({ extensions: [GFM] })], selection: EditorSelection.range(from, to) }), + }); + return { view, f: createFormattingController(view, new Set()) }; +} + +describe("formatting keymap", () => { + it("binds the conventional chords, each claiming the browser default", () => { + expect(formattingKeymap.map((b) => b.key)).toEqual(["Mod-b", "Mod-i", "Mod-Shift-x", "Mod-e", "Mod-k"]); + expect(formattingKeymap.every((b) => b.preventDefault)).toBe(true); + }); + + it("runs the same command the toolbar does", () => { + const { view } = setup("hello world", 0, 5); + const bold = formattingKeymap.find((b) => b.key === "Mod-b"); + bold?.run?.(view); + expect(view.state.doc.toString()).toBe("**hello** world"); + bold?.run?.(view); + expect(view.state.doc.toString()).toBe("hello world"); + }); +}); + +describe("link cursor placement", () => { + it("lands between the parens when no url was supplied, ready for typing", () => { + const { view, f } = setup("read this", 5, 9); + f.run("link"); + expect(view.state.doc.toString()).toBe("read [this]()"); + expect(view.state.selection.main.anchor).toBe("read [this](".length); + }); + + it("lands after the finished link when a url was supplied", () => { + const { view, f } = setup("read this", 5, 9); + f.run("link", { url: "https://example.com" }); + expect(view.state.doc.toString()).toBe("read [this](https://example.com)"); + expect(view.state.selection.main.anchor).toBe("read [this](https://example.com)".length); + }); +});