Add Obsidian-like full-featured editor toolbar

This commit is contained in:
avi 2026-08-17 14:34:36 -05:00
commit 11dd692ec4
10 changed files with 1211 additions and 28 deletions

View file

@ -37,6 +37,21 @@ const {
insertHorizontalRule,
clearFormatting,
headingLevelAt,
toggleUnderline,
toggleHighlight,
toggleTextColor,
toggleHighlightColor,
removeColor,
removeHighlight,
toggleChecklist,
insertWikilink,
insertImage,
insertTable,
insertCallout,
toggleAlign,
toggleFencedCode,
detectFormatting,
applyFormatPainter,
} = mod;
// Build a minimal { state, dispatch } target around an EditorState.
@ -266,6 +281,189 @@ function eq(actual, expected, msg) {
clearFormatting(t);
eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links");
}
{
const { t, text } = target("==hi== and <u>under</u> and <span style=\"color:#e11d48\">red</span>", 0, 64);
clearFormatting(t);
eq(text(), "hi and under and red", "clear formatting strips highlight, underline, color");
}
// ---- underline ----
{
const { t, text, sel } = target("some text", 5, 9);
toggleUnderline(t);
eq(text(), "some <u>text</u>", "underline wraps selection");
eq(sel().from, 8, "underline selection from");
toggleUnderline(t);
eq(text(), "some text", "underline toggles back off");
}
{
const { t, text } = target("a <u>mid</u> b", 6); // cursor inside <u>…</u>
toggleUnderline(t);
eq(text(), "a mid b", "underline unwraps when cursor is inside");
}
{
const { t, text, sel } = target("ab", 1);
toggleUnderline(t);
eq(text(), "a<u></u>b", "underline on empty cursor inserts tag pair");
eq(sel().from, 4, "underline cursor between tags");
}
// ---- highlight (Obsidian ==) ----
{
const { t, text } = target("mark this", 0, 4);
toggleHighlight(t);
eq(text(), "==mark== this", "highlight wraps selection");
toggleHighlight(t);
eq(text(), "mark this", "highlight toggles back off");
}
// ---- text color ----
{
const { t, text, sel } = target("red text", 0, 3);
toggleTextColor("#e11d48")(t);
eq(text(), '<span style="color:#e11d48">red</span> text', "text color wraps selection");
eq(sel().from, 28, "text color keeps selection on text");
toggleTextColor("#e11d48")(t);
eq(text(), "red text", "text color toggles off with same color");
}
{
const { t, text } = target("change color", 0, 6);
toggleTextColor("#e11d48")(t);
toggleTextColor("#3b82f6")(t);
eq(text(), '<span style="color:#3b82f6">change</span> color', "text color replaces existing color");
}
// ---- highlight color ----
{
const { t, text } = target("mark this", 0, 4);
toggleHighlightColor("#fde047")(t);
eq(text(), '<span style="background-color:#fde047">mark</span> this', "highlight color wraps selection");
removeHighlight(t);
eq(text(), "mark this", "removeHighlight strips background color");
}
{
const { t, text } = target("==plain==", 0, 8);
removeHighlight(t);
eq(text(), "plain", "removeHighlight strips Obsidian markers");
}
{
const { t, text } = target("colored", 0, 7);
toggleTextColor("#e11d48")(t);
removeColor(t);
eq(text(), "colored", "removeColor strips text color");
}
// ---- checklist ----
{
const { t, text } = target("buy milk\nwrite note", 0, 19);
toggleChecklist(t);
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist prefixes lines");
toggleChecklist(t);
eq(text(), "- [x] buy milk\n- [x] write note", "checklist toggles to checked");
toggleChecklist(t);
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist toggles back unchecked");
}
{
const { t, text } = target("1. numbered item", 0, 16);
toggleChecklist(t);
eq(text(), "- [ ] numbered item", "checklist converts numbered item");
}
// ---- wikilink ----
{
const { t, text, sel } = target("see intro", 4, 9);
insertWikilink("Introduction")(t);
eq(text(), "see [[Introduction]]", "wikilink inserts target");
eq(sel().from, 20, "wikilink cursor after insertion");
}
{
const { t, text } = target("see intro", 4, 9);
insertWikilink("Introduction", "intro")(t);
eq(text(), "see [[Introduction|intro]]", "wikilink with alias label");
}
// ---- image ----
{
const { t, text, sel } = target("before", 6);
insertImage("alt", "https://x.com/a.png")(t);
eq(text(), "before![alt](https://x.com/a.png)", "image inserts markdown");
eq(sel().from, 13, "image selects url start");
eq(sel().to, 32, "image selects url end");
}
// ---- table ----
{
const { t, text } = target("para", 4);
insertTable(3, 2)(t);
eq(
text(),
"para\n| Col 1 | Col 2 |\n| --- | --- |\n| | |\n| | |\n",
"table inserts header, separator, body rows"
);
}
// ---- callout ----
{
const { t, text } = target("body here", 0);
insertCallout("note", "Tip")(t);
eq(text(), "> [!note] Tip\n> body here", "callout inserts Obsidian blockquote syntax");
}
// ---- alignment ----
{
const { t, text } = target("centered line", 0);
toggleAlign("center")(t);
eq(text(), '<div align="center">centered line</div>', "align wraps paragraph in HTML div");
toggleAlign("center")(t);
eq(text(), "centered line", "align toggles back off");
toggleAlign("center")(t);
toggleAlign("right")(t);
eq(text(), '<div align="right">centered line</div>', "align replaces alignment");
toggleAlign("left")(t);
eq(text(), "centered line", "align left clears alignment");
}
// ---- fenced code block ----
{
const { t, text } = target("single line", 0);
toggleFencedCode(t);
eq(text(), "```\nsingle line\n```", "code block wraps single line");
toggleFencedCode(t);
eq(text(), "single line", "code block toggles off");
}
// ---- detectFormatting ----
{
const { t } = target("**bold** and *italic* and <u>under</u> and ==hl==", 6);
const f = detectFormatting(t.state, 6);
ok(f.bold === true, "detectFormatting finds bold");
ok(f.italic === false, "detectFormatting bold not italic");
}
{
const { t } = target("# Heading\nplain", 3);
ok(detectFormatting(t.state, 3).heading === 1, "detectFormatting finds heading");
}
{
const { t } = target("- [ ] task", 3);
ok(detectFormatting(t.state, 3).list === "checklist", "detectFormatting finds checklist");
}
{
const { t } = target("- item", 3);
ok(detectFormatting(t.state, 3).list === "bullet", "detectFormatting finds bullet list");
}
{
const { t } = target("1. item", 3);
ok(detectFormatting(t.state, 3).list === "ordered", "detectFormatting finds ordered list");
}
// ---- format painter ----
{
const { t, text } = target("**bold source** plain target", 0, 16);
const fmt = { ...detectFormatting(t.state, 8), bold: true };
const { t: t2, text: text2 } = target("**bold source** plain target", 16, 28);
applyFormatPainter(t2, fmt);
eq(text2(), "**bold source** **plain target**", "format painter applies bold");
}
fs.rmSync(out, { force: true });

View file

@ -158,10 +158,55 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
changes: { from: 0, to: 0, insert: "line one\nline two\n" },
selection: { anchor: 0, head: 16 },
});
document.querySelector('[data-cmd="code"]').click();
document.querySelector('[data-cmd="codeblock"]').click();
await new Promise((r) => setTimeout(r, 50));
out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```");
// underline shortcut (Ctrl+U)
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "Under me" }, selection: { anchor: 0, head: 5 } });
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "u", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.underlineOk = cm.state.doc.toString().startsWith("<u>Under</u>");
// task list via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "task one\ntask two" }, selection: { anchor: 0, head: 8 } });
document.querySelector('[data-cmd="checklist"]').click();
await new Promise((r) => setTimeout(r, 50));
out.checklistOk = cm.state.doc.toString().startsWith("- [ ] task one");
// highlight via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "cell text" }, selection: { anchor: 0, head: 4 } });
document.querySelector('[data-cmd="highlight"]').click();
await new Promise((r) => setTimeout(r, 50));
out.highlightOk = cm.state.doc.toString().startsWith("==cell==");
// table via toolbar dropdown config
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
document.getElementById("tableRows").value = "3";
document.getElementById("tableCols").value = "2";
document.querySelector('[data-action="table"]').click();
await new Promise((r) => setTimeout(r, 50));
out.tableOk = cm.state.doc.toString().startsWith("| Col 1 | Col 2 |\n| --- | --- |");
// format painter
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "**src** dst" }, selection: { anchor: 3, head: 3 } });
document.querySelector('[data-cmd="painter"]').click();
cm.dispatch({ selection: { anchor: 8, head: 11 } });
document.querySelector('[data-cmd="painter"]').click();
await new Promise((r) => setTimeout(r, 50));
out.painterOk = cm.state.doc.toString() === "**src** **dst**";
// leave some formatted content on disk for the autosave check
cm.dispatch({
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
selection: { anchor: 0 },
});
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
out.formattingAutosaved =
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
@ -182,6 +227,11 @@ const BOOK_DIR = __BOOKDIR_VALUE__;
"redoOk",
"strikeShortcutOk",
"fencedCodeOk",
"underlineOk",
"checklistOk",
"highlightOk",
"tableOk",
"painterOk",
"formattingAutosaved",
];
out.all = okFlags.every((f) => out[f] === true);