Add Obsidian-like full-featured editor toolbar
This commit is contained in:
parent
45990bbd62
commit
11dd692ec4
10 changed files with 1211 additions and 28 deletions
|
|
@ -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", "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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue