Add Markdown formatting toolbar and editor shortcuts

This commit is contained in:
avi 2026-08-17 12:28:49 -05:00
commit c09f4adc30
8 changed files with 718 additions and 2 deletions

235
tests/run-format-test.mjs Normal file
View file

@ -0,0 +1,235 @@
// Standalone test for the Markdown formatting commands (md-format.ts).
// Bundles with esbuild and drives the commands through a plain
// { state, dispatch } target, so no DOM is required.
// Run with: node tests/run-format-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
// Write the bundle inside the repo so node can resolve the external
// @codemirror/state peer dependency (same instance as the one imported below).
const out = path.resolve(`tests/.tmp-folio-format-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/renderer/md-format.ts")],
bundle: true,
platform: "node",
format: "esm",
external: ["@codemirror/state"],
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
toggleBold,
toggleItalic,
toggleStrikethrough,
toggleInlineCode,
toggleLink,
setHeading,
toggleBulletList,
toggleOrderedList,
toggleBlockquote,
insertHorizontalRule,
clearFormatting,
headingLevelAt,
} = mod;
// Build a minimal { state, dispatch } target around an EditorState.
import { EditorState } from "@codemirror/state";
function target(doc, anchor, head = anchor) {
let state = EditorState.create({
doc,
selection: { anchor, head },
});
const t = {
get state() {
return state;
},
dispatch(spec) {
state = state.update(spec).state;
return true;
},
};
return { t, text: () => state.doc.toString(), sel: () => state.selection.main };
}
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function eq(actual, expected, msg) {
ok(actual === expected, `${msg} (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`);
}
// ---- bold ----
{
const { t, text, sel } = target("say hello world", 4, 9); // "hello"
toggleBold(t);
eq(text(), "say **hello** world", "bold wraps selection");
eq(sel().from, 6, "bold keeps selection on inner text (from)");
eq(sel().to, 11, "bold keeps selection on inner text (to)");
toggleBold(t); // cursor/selection still over inner text -> unwrap
eq(text(), "say hello world", "bold toggles back off");
}
// ---- bold with empty cursor ----
{
const { t, text, sel } = target("abc", 1);
toggleBold(t);
eq(text(), "a****bc", "bold on empty cursor inserts marker pair");
eq(sel().from, 3, "bold cursor placed between markers");
toggleBold(t);
eq(text(), "abc", "bold on cursor toggles back off");
}
// ---- bold unwrap when whole span selected ----
{
const { t, text, sel } = target("x **bold** y", 2, 10);
toggleBold(t);
eq(text(), "x bold y", "bold unwraps when selection includes markers");
eq(sel().from, 2, "unwrapped selection anchor");
eq(sel().to, 6, "unwrapped selection head");
}
// ---- italic must not unwrap bold markers ----
{
const { t, text } = target("**bold**", 2, 6); // inside bold
toggleItalic(t);
eq(text(), "***bold***", "italic inside bold produces bold+italic, not a broken span");
const { t: t2, text: text2 } = target("*em*", 1, 3); // inside italic
toggleItalic(t2);
eq(text2(), "em", "italic toggles off when already inside italic markers");
const { t: t3, text: text3 } = target("*em*", 0, 4); // whole italic span
toggleItalic(t3);
eq(text3(), "em", "italic unwraps whole span");
}
// ---- strikethrough ----
{
const { t, text, sel } = target("strike this text", 0, 6);
toggleStrikethrough(t);
eq(text(), "~~strike~~ this text", "strikethrough wraps selection");
eq(sel().from, 2, "strikethrough selection from");
toggleStrikethrough(t);
eq(text(), "strike this text", "strikethrough toggles back off");
}
// ---- inline code ----
{
const { t, text } = target("run `npm` and code here", 5, 8);
toggleInlineCode(t);
eq(text(), "run npm and code here", "inline code toggles off when already in a code span");
const { t: t2, text: text2 } = target("var x = 1;", 0, 9);
toggleInlineCode(t2);
eq(text2(), "`var x = 1`;", "inline code wraps normal text");
}
// ---- links ----
{
const { t, text, sel } = target("visit example", 6, 13);
toggleLink(t);
eq(text(), "visit [example](url)", "link wraps selection with url placeholder");
eq(sel().from, 16, "link selects url placeholder (from)");
eq(sel().to, 19, "link selects url placeholder (to)");
}
{
const { t, text, sel } = target("ab", 1);
toggleLink(t);
eq(text(), "a[](url)b", "link on empty cursor inserts empty label");
eq(sel().from, 2, "link cursor inside label brackets");
}
// ---- headings ----
{
const { t, text } = target("Chapter one", 0);
setHeading(2)(t);
eq(text(), "## Chapter one", "setHeading adds level 2 marker");
setHeading(3)(t);
eq(text(), "### Chapter one", "setHeading changes existing level");
setHeading(3)(t);
eq(text(), "### Chapter one", "setHeading same level is a no-op");
setHeading(0)(t);
eq(text(), "Chapter one", "setHeading paragraph clears heading");
}
{
const { t } = target("# Title", 3);
ok(headingLevelAt(t.state, 3) === 1, "headingLevelAt detects level 1");
const { t: t2 } = target("plain", 2);
ok(headingLevelAt(t2.state, 2) === 0, "headingLevelAt returns 0 for plain line");
}
// ---- bullet list ----
{
const { t, text } = target("one\ntwo\nthree", 0, 13);
toggleBulletList(t);
eq(text(), "- one\n- two\n- three", "bullet list prefixes each line");
toggleBulletList(t);
eq(text(), "one\ntwo\nthree", "bullet list toggles back off");
}
{
const { t, text } = target("already listed", 0);
toggleBulletList(t);
eq(text(), "- already listed", "bullet list works with cursor only");
}
// ---- numbered list ----
{
const { t, text } = target("one\ntwo", 0, 7);
toggleOrderedList(t);
eq(text(), "1. one\n2. two", "numbered list numbers each line");
toggleOrderedList(t);
eq(text(), "one\ntwo", "numbered list toggles back off");
}
// ---- blockquote ----
{
const { t, text } = target("quoted words", 0);
toggleBlockquote(t);
eq(text(), "> quoted words", "blockquote prefixes line");
toggleBlockquote(t);
eq(text(), "quoted words", "blockquote toggles back off");
}
// ---- horizontal rule ----
{
const { t, text, sel } = target("", 0);
insertHorizontalRule(t);
eq(text(), "---\n\n", "hr inserts at doc start");
eq(sel().from, 5, "hr cursor after rule");
}
{
const { t, text } = target("para\n\nnext", 5); // start of the empty line
insertHorizontalRule(t);
eq(text(), "para\n\n---\n\nnext", "hr inserts on empty line");
}
{
const { t, text } = target("mid | line", 4);
insertHorizontalRule(t);
eq(text(), "mid \n\n---\n\n| line", "hr splits a line in the middle");
}
// ---- clear formatting ----
{
const { t, text } = target("## **Bold** and ~~strike~~", 0, 26);
clearFormatting(t);
eq(text(), "Bold and strike", "clear formatting strips heading, bold, strike");
}
{
const { t, text } = target("- item\n> quote\n1. thing\n`code` and [label](https://x.com)", 0, 57);
clearFormatting(t);
eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links");
}
fs.rmSync(out, { force: true });
console.log("\nFormat tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);

View file

@ -109,6 +109,43 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
typedContent.includes("[link](https://example.com)") &&
typedContent.includes("`inline code`");
// --- formatting toolbar + keyboard shortcuts ---
if (cm) {
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" }, selection: { anchor: 0 } });
cm.focus();
cm.dispatch({ changes: { from: 0, to: 0, insert: "Hello World" }, selection: { anchor: 0, head: 5 } });
document.querySelector('[data-cmd="bold"]').click();
await new Promise((r) => setTimeout(r, 50));
out.toolbarBoldOk = cm.state.doc.toString() === "**Hello** World";
out.focusAfterBold = document.activeElement === cm.contentDOM;
cm.dispatch({ selection: { anchor: 10, head: 15 } }); // "World"
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.shortcutBoldOk = cm.state.doc.toString() === "**Hello** **World**";
const headingSel = document.getElementById("headingSelect");
headingSel.value = "2";
headingSel.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.headingOk = cm.state.doc.toString().startsWith("## ");
document.querySelector('[data-cmd="undo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.undoOk = cm.state.doc.toString() === "**Hello** **World**";
document.querySelector('[data-cmd="redo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.redoOk = cm.state.doc.toString().startsWith("## ");
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
out.formattingAutosaved =
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
}
out.all = true;
} catch (e) {
out.error = String((e && e.message) || e);