feat(editor): add Markdown formatting shortcuts (#6258)

This commit is contained in:
Johnny 2026-09-01 21:54:55 +08:00 committed by GitHub
parent ee3a0a2bba
commit 673b9df05f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 106 additions and 23 deletions

View file

@ -3,6 +3,8 @@ import { markdown } from "@codemirror/lang-markdown";
import { indentUnit } from "@codemirror/language"; import { indentUnit } from "@codemirror/language";
import { Compartment, type Extension } from "@codemirror/state"; import { Compartment, type Extension } from "@codemirror/state";
import { placeholder as cmPlaceholder, dropCursor, EditorView, type KeyBinding, keymap } from "@codemirror/view"; 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 { memoMarkdownExtensions } from "@/utils/memo-markdown-extension";
import { headingDecorations } from "./headingDecorations"; import { headingDecorations } from "./headingDecorations";
import { liftListItem, sinkListItem } from "./listIndent"; import { liftListItem, sinkListItem } from "./listIndent";
@ -11,8 +13,8 @@ import { tagMentionDecorations } from "./tagMentionDecorations";
import { memoEditorTheme } from "./theme"; import { memoEditorTheme } from "./theme";
import { uploadAnchorField } from "./uploadAnchors"; import { uploadAnchorField } from "./uploadAnchors";
// Key bindings layered below the autocomplete keymap so the completion popup's // Key bindings layered below the autocomplete keymap so its completion-specific
// own Tab/Escape win while it is open. On a list item, Tab/Shift-Tab nest / // 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 // outdent it (marker-aware, CommonMark-valid); elsewhere they fall through to
// indentWithTab's plain indent. Escape blurs the editor so keyboard users keep // indentWithTab's plain indent. Escape blurs the editor so keyboard users keep
// an escape hatch out of the otherwise Tab-trapping editor. // an escape hatch out of the otherwise Tab-trapping editor.
@ -28,6 +30,31 @@ const editorKeys: KeyBinding[] = [
{ key: "Shift-Tab", run: liftListItem }, { 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 { export interface EditorExtensionsOptions {
placeholder: string; placeholder: string;
onChange: (markdown: string) => void; onChange: (markdown: string) => void;
@ -111,9 +138,11 @@ export function buildEditorExtensions({
headingDecorations, headingDecorations,
uploadAnchorField, uploadAnchorField,
// tagAutocomplete must precede the editing keymap so the completion popup's // 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), 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) => { EditorView.updateListener.of((u) => {
if (u.docChanged) onChange(u.state.doc.toString()); if (u.docChanged) onChange(u.state.doc.toString());
// Toolbar active-state depends only on the doc and selection; skip the // Toolbar active-state depends only on the doc and selection; skip the

View file

@ -316,28 +316,33 @@ function unwrapLink(view: EditorView): boolean {
return false; 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 { export function createFormattingController(view: EditorView, listeners: Set<() => void>): FormattingController {
return { return {
run(command: EditorCommandId, ctx?: EditorCommandContext) { run(command: EditorCommandId, ctx?: EditorCommandContext) {
if (isMarkCommand(command)) return toggleMark(view, command); runFormattingCommand(view, command, ctx);
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 } });
}
}, },
getActiveFormats(): ActiveFormatState { getActiveFormats(): ActiveFormatState {
const pos = view.state.selection.main.head; const pos = view.state.selection.main.head;

View file

@ -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. `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`. `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 ### Tags and mentions

View file

@ -27,6 +27,10 @@ function press(view: EditorView, key: string, opts: KeyboardEventInit = {}) {
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts })); 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. */ /** Place the cursor on the given 1-based line, then press Tab/Shift-Tab. */
function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) { function tabOnLine(view: EditorView, lineNumber: number, shiftKey = false) {
const line = view.state.doc.line(lineNumber); const line = view.state.doc.line(lineNumber);
@ -67,6 +71,49 @@ describe("editor key bindings", () => {
view.destroy(); 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", () => { it("Tab indents a non-list line by two spaces", () => {
const view = makeView("hello"); const view = makeView("hello");
view.dispatch({ selection: { anchor: 0 } }); view.dispatch({ selection: { anchor: 0 } });