feat(editor): add Markdown formatting shortcuts (#6258)
This commit is contained in:
parent
ee3a0a2bba
commit
673b9df05f
4 changed files with 106 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue