Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion web/src/components/MemoEditor/Editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
50 changes: 31 additions & 19 deletions web/src/components/MemoEditor/Editor/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions web/src/components/MemoEditor/Editor/formattingKeymap.ts
Original file line number Diff line number Diff line change
@@ -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;
},
}));
46 changes: 46 additions & 0 deletions web/tests/memo-editor-formatting-keymap.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading