From 673b9df05fa341dc3518cca24bef70a1dbd64ea6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 1 Sep 2026 21:54:55 +0800 Subject: [PATCH] feat(editor): add Markdown formatting shortcuts (#6258) --- .../MemoEditor/Editor/extensions.ts | 37 +++++++++++++-- .../MemoEditor/Editor/formatting.ts | 43 +++++++++-------- web/src/components/MemoEditor/README.md | 2 + web/tests/editor-keys.test.ts | 47 +++++++++++++++++++ 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/web/src/components/MemoEditor/Editor/extensions.ts b/web/src/components/MemoEditor/Editor/extensions.ts index 82869e55..073b4011 100644 --- a/web/src/components/MemoEditor/Editor/extensions.ts +++ b/web/src/components/MemoEditor/Editor/extensions.ts @@ -3,6 +3,8 @@ import { markdown } from "@codemirror/lang-markdown"; 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 { runFormattingCommand } from "@/components/MemoEditor/Editor/formatting"; +import type { EditorCommandId } from "@/components/MemoEditor/formatting/commands"; import { memoMarkdownExtensions } from "@/utils/memo-markdown-extension"; import { headingDecorations } from "./headingDecorations"; import { liftListItem, sinkListItem } from "./listIndent"; @@ -11,8 +13,8 @@ import { tagMentionDecorations } from "./tagMentionDecorations"; import { memoEditorTheme } from "./theme"; import { uploadAnchorField } from "./uploadAnchors"; -// Key bindings layered below the autocomplete keymap so the completion popup's -// own Tab/Escape win while it is open. On a list item, Tab/Shift-Tab nest / +// Key bindings layered below the autocomplete keymap so its completion-specific +// keys win while the popup is open. On a list item, Tab/Shift-Tab nest / // outdent it (marker-aware, CommonMark-valid); elsewhere they fall through to // indentWithTab's plain indent. Escape blurs the editor so keyboard users keep // an escape hatch out of the otherwise Tab-trapping editor. @@ -28,6 +30,31 @@ const editorKeys: KeyBinding[] = [ { key: "Shift-Tab", run: liftListItem }, ]; +const formattingKey = (key: string, command: EditorCommandId): KeyBinding => ({ + key, + run: (view) => { + runFormattingCommand(view, command); + return true; + }, +}); + +// Familiar cross-platform Markdown formatting keys. These stay separate from +// CodeMirror's Markdown keymap, which only handles structural Enter/Backspace. +const formattingKeys: KeyBinding[] = [ + formattingKey("Mod-b", "bold"), + formattingKey("Mod-i", "italic"), + formattingKey("Shift-Mod-s", "strikethrough"), + formattingKey("Mod-e", "code"), + formattingKey("Mod-Alt-c", "codeBlock"), + formattingKey("Mod-Alt-0", "paragraph"), + formattingKey("Shift-Mod-7", "orderedList"), + formattingKey("Shift-Mod-8", "bulletList"), + formattingKey("Shift-Mod-9", "taskList"), + formattingKey("Mod-Alt-1", "heading1"), + formattingKey("Mod-Alt-2", "heading2"), + formattingKey("Mod-Alt-3", "heading3"), +]; + export interface EditorExtensionsOptions { placeholder: string; onChange: (markdown: string) => void; @@ -111,9 +138,11 @@ export function buildEditorExtensions({ headingDecorations, uploadAnchorField, // tagAutocomplete must precede the editing keymap so the completion popup's - // Enter/Tab/arrow bindings win while it is open. + // Enter/Escape/arrow bindings win while it is open. tagAutocomplete(getTags), - keymap.of([...submitKeys, ...editorKeys, indentWithTab, ...defaultKeymap, ...historyKeymap]), + // Formatting keys precede defaultKeymap so the conventional Mod-I italic + // shortcut wins over CodeMirror's generic selectParentSyntax binding. + keymap.of([...submitKeys, ...editorKeys, ...formattingKeys, 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 3b01796d..49ffdd9f 100644 --- a/web/src/components/MemoEditor/Editor/formatting.ts +++ b/web/src/components/MemoEditor/Editor/formatting.ts @@ -316,28 +316,33 @@ function unwrapLink(view: EditorView): boolean { return false; } +/** Apply one formatting verb to a CodeMirror view. Shared by the toolbar controller and keymap. */ +export function runFormattingCommand(view: EditorView, command: EditorCommandId, ctx?: EditorCommandContext): void { + 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 } }); + } +} + 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 } }); - } + runFormattingCommand(view, command, ctx); }, getActiveFormats(): ActiveFormatState { const pos = view.state.selection.main.head; diff --git a/web/src/components/MemoEditor/README.md b/web/src/components/MemoEditor/README.md index 1c1694b3..4b742400 100644 --- a/web/src/components/MemoEditor/README.md +++ b/web/src/components/MemoEditor/README.md @@ -88,6 +88,8 @@ A reducer (`state/reducer.ts`) drives an **external store**, not a `useReducer` `Editor/extensions.ts` exports `buildEditorExtensions()`, which composes the CodeMirror extension set: `@codemirror/lang-markdown` (with GFM), line wrapping, a reconfigurable placeholder, the editor theme, the `#tag`/`@mention` decoration plugin, the `#tag` autocomplete, and an update listener that pushes document changes back to the reducer via `onChange`. It also binds the save shortcut: `Meta-Enter` and `Ctrl-Enter` both call `onSubmit`, bound explicitly rather than through the platform-dependent `Mod-` so either works everywhere, and ordered ahead of `defaultKeymap`'s own `Mod-Enter` (`insertBlankLine`) so saving never also edits the document. Native CodeMirror paste/drop handlers intercept file payloads before its text insertion behavior and pass them to the attachment layer; ordinary markdown text paste/drop remains CodeMirror-owned. +The app formatting keymap sits ahead of CodeMirror's generic `defaultKeymap`: `Mod-B`/`Mod-I` toggle bold/italic, `Mod-Shift-S` toggles strikethrough, `Mod-E` toggles inline code, `Mod-Alt-C` toggles a code block, `Mod-Alt-0` restores paragraph text, `Mod-Alt-1` through `Mod-Alt-3` select heading levels, and `Mod-Shift-7` through `Mod-Shift-9` toggle ordered, bullet, and task lists. CodeMirror's Markdown keymap remains enabled separately for structural `Enter` and `Backspace` behavior. + `Editor/theme.ts` defines the decorated-source look: a `HighlightStyle` over the Lezer markdown highlight tags (headings, strong, emphasis, code, links, quotes, markers) and an `EditorView.theme`. Colors come from CSS custom properties so light/dark themes just work. This is the editor's own styling — the read-only memo view styles itself separately via `@/lib/markdownStyles`. ### Tags and mentions diff --git a/web/tests/editor-keys.test.ts b/web/tests/editor-keys.test.ts index a5b99620..4bfd40da 100644 --- a/web/tests/editor-keys.test.ts +++ b/web/tests/editor-keys.test.ts @@ -27,6 +27,10 @@ function press(view: EditorView, key: string, opts: KeyboardEventInit = {}) { view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts })); } +function mod(opts: KeyboardEventInit = {}): KeyboardEventInit { + return /Mac/.test(navigator.platform) ? { ...opts, metaKey: true } : { ...opts, ctrlKey: true }; +} + /** Place the cursor on the given 1-based line, then press Tab/Shift-Tab. */ function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) { const line = view.state.doc.line(lineNumber); @@ -67,6 +71,49 @@ describe("editor key bindings", () => { view.destroy(); }); + it.each([ + ["bold", "b", {}, "**text**"], + ["italic", "i", {}, "*text*"], + ["strikethrough", "s", { shiftKey: true }, "~~text~~"], + ["inline code", "e", {}, "`text`"], + ["code block", "c", { altKey: true }, "```\ntext\n```"], + ["ordered list", "7", { shiftKey: true }, "1. text"], + ["bullet list", "8", { shiftKey: true }, "- text"], + ["task list", "9", { shiftKey: true }, "- [ ] text"], + ["heading 1", "1", { altKey: true }, "# text"], + ["heading 2", "2", { altKey: true }, "## text"], + ["heading 3", "3", { altKey: true }, "### text"], + ])("applies %s with its formatting shortcut", (_name, key, modifiers, expected) => { + const view = makeView("text"); + view.dispatch({ selection: { anchor: 0, head: 4 } }); + + press(view, key, mod(modifiers)); + + expect(view.state.doc.toString()).toBe(expected); + view.destroy(); + }); + + it("converts a heading to a paragraph with Mod-Alt-0", () => { + const view = makeView("## text"); + view.dispatch({ selection: { anchor: 4 } }); + + press(view, "0", mod({ altKey: true })); + + expect(view.state.doc.toString()).toBe("text"); + view.destroy(); + }); + + it("uses Mod-I for italic instead of CodeMirror's select-parent-syntax command", () => { + const view = makeView("plain"); + view.dispatch({ selection: { anchor: 0, head: 5 } }); + + press(view, "i", mod()); + + expect(view.state.doc.toString()).toBe("*plain*"); + expect(view.state.selection.main).toMatchObject({ from: 1, to: 6 }); + view.destroy(); + }); + it("Tab indents a non-list line by two spaces", () => { const view = makeView("hello"); view.dispatch({ selection: { anchor: 0 } });