From d277011b4bc87055bf0dfb6611466b96eb9082c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E9=91=AB?= <952580475@qq.com>
Date: Mon, 21 Sep 2026 22:56:43 +0800
Subject: [PATCH 1/2] feat(toolbar): add GFM table insertion and task-list
toggle
Add insertTable() and toggleTaskList() formatting commands to the toolbar
plugin, wired as slash commands and toolbar UI buttons.
- insertTable builds a GitHub-Flavored Markdown table (configurable cols,
rows, and an optional header row) as a single atomic replaceRange so the
whole insertion collapses into one undo step; the caret lands inside the
first body cell.
- toggleTaskList converts plain text, bullets, and ordered lists into
- [ ] task items, and toggles them off when every selected line is already
a task item; the block is rewritten in one transaction for atomic undo.
- Registers table/task-list slash commands, adds icons and toolbar buttons,
and exports both commands from the package entry point.
- Covers the new commands with 24 unit + atomic-undo + UI tests.
---
packages/plugin-toolbar/src/formatting.ts | 123 ++++++
packages/plugin-toolbar/src/icons.ts | 17 +
packages/plugin-toolbar/src/index.ts | 4 +-
.../plugin-toolbar/src/toolbar-commands.ts | 18 +-
packages/plugin-toolbar/src/toolbar-ui.ts | 8 +-
.../test/plugin-toolbar.test.ts | 361 ++++++++++++++++++
6 files changed, 527 insertions(+), 4 deletions(-)
diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts
index d261c381..3ef88e23 100644
--- a/packages/plugin-toolbar/src/formatting.ts
+++ b/packages/plugin-toolbar/src/formatting.ts
@@ -208,3 +208,126 @@ export function insertHorizontalRule(editor: EditorAPI): boolean {
editor.setSelection(anchor + hr.length);
return true;
}
+
+export interface InsertTableOptions {
+ /**
+ * Number of columns in the table. Clamped to at least 1.
+ */
+ cols?: number;
+ /**
+ * Number of body rows. Clamped to at least 1. The separator (delimiter) row
+ * and an optional header row are added on top of this count.
+ */
+ rows?: number;
+ /**
+ * Emit a `Header N` row above the delimiter row. Defaults to `true`.
+ */
+ includeHeaderRow?: boolean;
+}
+
+const TASK_MARKER = /^(\s*)([-*+]\s+)\[([ xX])\]\s?(.*)$/;
+const LIST_MARKER = /^(\s*)([-*+]\s+|\d+\.\s+)(.*)$/;
+
+/**
+ * Build a single GFM table row from its cell contents. Each cell is padded with
+ * a single space on both sides (e.g. `| a | b |`) which is the most readable
+ * and widely-supported variant of GFM table syntax.
+ */
+function buildTableRow(cells: readonly string[]): string {
+ return `|${cells.map((cell) => ` ${cell} `).join("|")}|`;
+}
+
+/**
+ * Insert a GitHub-Flavored Markdown table at the caret.
+ *
+ * The table is emitted as one atomic `replaceRange` transaction so the whole
+ * insertion — including the resulting caret move — collapses into a single undo
+ * step (mirrors `applyLines`). The caret is placed inside the first body cell
+ * so the user can start typing immediately.
+ *
+ * Surrounding newlines are added only when needed, so inserting into the middle
+ * of a paragraph or at the very start/end of the document stays well-formed.
+ */
+export function insertTable(editor: EditorAPI, options: InsertTableOptions = {}): boolean {
+ const cols = Math.max(1, Math.floor(options.cols ?? 3));
+ const rows = Math.max(1, Math.floor(options.rows ?? 3));
+ const includeHeader = options.includeHeaderRow ?? true;
+
+ const lines: string[] = [];
+ if (includeHeader) {
+ lines.push(buildTableRow(Array.from({ length: cols }, (_, i) => `Header ${i + 1}`)));
+ }
+ // The delimiter row is mandatory for GFM tables even when there is no header.
+ lines.push(buildTableRow(Array.from({ length: cols }, () => "---")));
+ for (let row = 0; row < rows; row++) {
+ lines.push(buildTableRow(Array.from({ length: cols }, () => "")));
+ }
+ const table = lines.join("\n");
+
+ const doc = editor.getDocument();
+ const { anchor, head } = editor.getSelection();
+ const from = Math.min(anchor, head);
+ const to = Math.max(anchor, head);
+
+ const needsLeadingNewline = from > 0 && doc[from - 1] !== "\n";
+ const needsTrailingNewline = to < doc.length && doc[to] !== "\n";
+ const leading = needsLeadingNewline ? "\n" : "";
+ const trailing = needsTrailingNewline ? "\n" : "";
+ const inserted = `${leading}${table}${trailing}`;
+
+ // Caret offset *inside* `table`, in the first cell of the first body row.
+ const firstBodyRowIndex = (includeHeader ? 1 : 0) + 1; // +1 for the delimiter row
+ let cursorInTable = 0;
+ for (let i = 0; i < firstBodyRowIndex; i++) {
+ cursorInTable += lines[i].length + 1;
+ }
+ cursorInTable += "| ".length;
+ const caretPos = from + leading.length + cursorInTable;
+
+ editor.replaceRange(from, to, inserted, { anchor: caretPos });
+ return true;
+}
+
+/**
+ * Toggle a GitHub-Flavored Markdown task list on the lines intersecting the
+ * current selection (or the line under a collapsed caret).
+ *
+ * - Every intersecting line already a task item → the markers are removed and
+ * the lines revert to plain text (toggle off, mirroring `toggleUnorderedList`).
+ * - Otherwise every line becomes a `- [ ]` task item, converting plain bullets,
+ * ordered list markers, and bare text in one pass.
+ *
+ * The whole block is rewritten in a single `replaceRange` transaction so it is
+ * one undo step and the caret select the transformed block afterwards.
+ */
+export function toggleTaskList(editor: EditorAPI): boolean {
+ const doc = editor.getDocument();
+ const { anchor, head } = editor.getSelection();
+ const from = Math.min(anchor, head);
+ const to = Math.max(anchor, head);
+ const lines = getLinesInRange(doc, from, to);
+
+ const allTasks = lines.every(({ line }) => TASK_MARKER.test(line));
+
+ const newLines = lines.map(({ line }) => {
+ const task = line.match(TASK_MARKER);
+ if (task) {
+ if (allTasks) {
+ // Toggle off: drop the entire `- [ ] ` marker, keep indent + text.
+ return `${task[1]}${task[4]}`;
+ }
+ // Not all lines are tasks yet — normalise existing items to unchecked.
+ return line.replace(TASK_MARKER, "$1$2[ ] $4");
+ }
+
+ const list = line.match(LIST_MARKER);
+ if (list) {
+ return `${list[1]}- [ ] ${list[3]}`;
+ }
+
+ const indent = line.match(/^(\s*)/)?.[1] ?? "";
+ return `${indent}- [ ] ${line.slice(indent.length)}`;
+ });
+
+ return applyLines(editor, lines, newLines);
+}
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/icons.ts b/packages/plugin-toolbar/src/icons.ts
index ab2d9cbb..013475d1 100644
--- a/packages/plugin-toolbar/src/icons.ts
+++ b/packages/plugin-toolbar/src/icons.ts
@@ -174,3 +174,20 @@ export function iconHorizontalRule(): HTMLElement {
``
);
}
+
+export function iconTable(): HTMLElement {
+ return svgIcon(
+ `` +
+ `` +
+ ``
+ );
+}
+
+export function iconTaskList(): HTMLElement {
+ return svgIcon(
+ `` +
+ `` +
+ `` +
+ ``
+ );
+}
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts
index 166eaaed..3537ecbc 100644
--- a/packages/plugin-toolbar/src/index.ts
+++ b/packages/plugin-toolbar/src/index.ts
@@ -10,7 +10,7 @@ import {
toggleStrikethrough,
} from "./toolbar-commands";
-export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight } from "./formatting";
+export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight, insertTable, toggleTaskList } from "./formatting";
export { createToolbarUI } from "./toolbar-ui";
export { colorDecorationExtension } from "./color-decoration";
export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui";
@@ -48,4 +48,4 @@ export {
ToolbarLifecyclePlugin,
toolbarLifecyclePluginManifest,
type ToolbarLifecyclePluginOptions,
-} from "./runtime-plugin";
+} from "./runtime-plugin";
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/toolbar-commands.ts b/packages/plugin-toolbar/src/toolbar-commands.ts
index a77b7615..0873d211 100644
--- a/packages/plugin-toolbar/src/toolbar-commands.ts
+++ b/packages/plugin-toolbar/src/toolbar-commands.ts
@@ -4,8 +4,10 @@ import {
insertCodeBlock,
insertHorizontalRule,
insertImage,
+ insertTable,
toggleBlockquote,
toggleOrderedList,
+ toggleTaskList,
toggleUnorderedList,
} from "./formatting";
@@ -180,4 +182,18 @@ export const toolbarSlashCommands: SlashCommandDef[] = [
keywords: ["hr", "rule", "divider", "separator", "---"],
run: insertHorizontalRule,
},
-];
+ {
+ id: "table",
+ title: "Table",
+ description: "Insert a GitHub-Flavored Markdown table",
+ keywords: ["table", "grid", "gfm", "rows", "columns"],
+ run: (editor) => insertTable(editor),
+ },
+ {
+ id: "task-list",
+ title: "Task list",
+ description: "Toggle a checkbox task list on the selection",
+ keywords: ["task", "todo", "checkbox", "checklist", "list"],
+ run: toggleTaskList,
+ },
+];
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts
index a56ae862..f036fe84 100644
--- a/packages/plugin-toolbar/src/toolbar-ui.ts
+++ b/packages/plugin-toolbar/src/toolbar-ui.ts
@@ -17,6 +17,8 @@ import {
insertImage,
applyTextColor,
applyHighlight,
+ insertTable,
+ toggleTaskList,
} from "./formatting";
import {
iconUndo,
@@ -37,6 +39,8 @@ import {
iconTextColor,
iconHighlight,
iconImage,
+ iconTable,
+ iconTaskList,
iconFullscreen,
} from "./icons";
@@ -165,12 +169,14 @@ function defaultGroups(options?: ToolbarUIOptions): ToolbarGroup[] {
buttons: [
{ id: "blockquote", title: "Blockquote", icon: iconBlockquote, action: toggleBlockquote },
{ id: "code-block", title: "Code block", icon: iconCodeBlock, action: insertCodeBlock },
+ { id: "table", title: "Insert table", icon: iconTable, action: insertTable },
],
},
{
buttons: [
{ id: "ordered-list", title: "Ordered list", icon: iconOrderedList, action: toggleOrderedList },
{ id: "unordered-list", title: "Unordered list", icon: iconUnorderedList, action: toggleUnorderedList },
+ { id: "task-list", title: "Task list", icon: iconTaskList, action: toggleTaskList },
],
},
{
@@ -569,4 +575,4 @@ export function createToolbarUI(editor: EditorAPI, options?: ToolbarUIOptions):
toolbar.remove();
},
};
-}
+}
\ No newline at end of file
diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts
index 3fc97f53..4d40f63a 100644
--- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts
+++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts
@@ -10,6 +10,8 @@ import {
toggleHeading,
toggleOrderedList,
toggleUnorderedList,
+ insertTable,
+ toggleTaskList,
createToolbarPlugin,
createToolbarUI,
} from "../src/index";
@@ -606,3 +608,362 @@ describe("toggleUnorderedList — atomic undo", () => {
editor.destroy();
});
});
+
+describe("insertTable", () => {
+ it("inserts a default 3x3 table with header at the document start", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "" });
+
+ editor.setSelection(0, 0);
+ insertTable(editor);
+
+ const expected = [
+ "| Header 1 | Header 2 | Header 3 |",
+ "| --- | --- | --- |",
+ "| | | |",
+ "| | | |",
+ "| | | |",
+ ].join("\n");
+ expect(editor.getDocument()).toBe(expected);
+ editor.destroy();
+ });
+
+ it("places the caret inside the first body cell", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "" });
+
+ editor.setSelection(0, 0);
+ insertTable(editor);
+
+ const { anchor } = editor.getSelection();
+ // The caret should sit between the two spaces of the first empty cell.
+ expect(editor.getDocument()[anchor - 1]).toBe(" ");
+ expect(editor.getDocument()[anchor]).toBe(" ");
+ expect(editor.getDocument()[anchor + 1]).toBe("|");
+ editor.destroy();
+ });
+
+ it("honours cols / rows / includeHeaderRow options", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "" });
+
+ editor.setSelection(0, 0);
+ insertTable(editor, { cols: 2, rows: 1, includeHeaderRow: false });
+
+ const expected = ["| --- | --- |", "| | |"].join("\n");
+ expect(editor.getDocument()).toBe(expected);
+ editor.destroy();
+ });
+
+ it("emits a header row when includeHeaderRow is true and rows is 1", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "" });
+
+ editor.setSelection(0, 0);
+ insertTable(editor, { cols: 1, rows: 1, includeHeaderRow: true });
+
+ const expected = ["| Header 1 |", "| --- |", "| |"].join("\n");
+ expect(editor.getDocument()).toBe(expected);
+ editor.destroy();
+ });
+
+ it("adds surrounding newlines when inserted into the middle of a line", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "word" });
+
+ editor.setSelection(4, 4); // end of "word"
+ insertTable(editor);
+
+ const doc = editor.getDocument();
+ const table = [
+ "| Header 1 | Header 2 | Header 3 |",
+ "| --- | --- | --- |",
+ "| | | |",
+ "| | | |",
+ "| | | |",
+ ].join("\n");
+ expect(doc.startsWith("word\n")).toBe(true);
+ expect(doc.endsWith(table)).toBe(true);
+ editor.destroy();
+ });
+
+ it("does not add a leading newline at the very start of the document", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "word" });
+
+ editor.setSelection(0, 0);
+ insertTable(editor);
+
+ const doc = editor.getDocument();
+ const table = [
+ "| Header 1 | Header 2 | Header 3 |",
+ "| --- | --- | --- |",
+ "| | | |",
+ "| | | |",
+ "| | | |",
+ ].join("\n");
+ // From=0 → no leading newline; the table is immediately followed by "word".
+ expect(doc.startsWith(table)).toBe(true);
+ expect(doc.endsWith("word")).toBe(true);
+ editor.destroy();
+ });
+});
+
+describe("toggleTaskList", () => {
+ it("converts a plain text line into a task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "buy milk" });
+
+ editor.setSelection(0, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] buy milk");
+ editor.destroy();
+ });
+
+ it("converts an unordered list item into a task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- buy milk" });
+
+ editor.setSelection(0, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] buy milk");
+ editor.destroy();
+ });
+
+ it("converts an ordered list item into a task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "1. buy milk" });
+
+ editor.setSelection(0, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] buy milk");
+ editor.destroy();
+ });
+
+ it("preserves leading indentation when converting", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: " plain" });
+
+ editor.setSelection(2, 2);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe(" - [ ] plain");
+ editor.destroy();
+ });
+
+ it("removes the marker when toggling an existing task item off", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] buy milk" });
+
+ editor.setSelection(0, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("buy milk");
+ editor.destroy();
+ });
+
+ it("removes the marker from a checked task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [x] buy milk" });
+
+ editor.setSelection(0, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("buy milk");
+ editor.destroy();
+ });
+
+ it("toggles every selected line into task items (multi-line)", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "a\nb\nc" });
+
+ editor.setSelection(0, 4); // covers "a\nb", leaves "c" untouched
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b\nc");
+ editor.destroy();
+ });
+
+ it("toggles a multi-line task list off in one pass", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a\n- [ ] b" });
+
+ editor.setSelection(0, 13);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("a\nb");
+ editor.destroy();
+ });
+
+ it("normalises a mixed selection to task items without removing existing ones", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a\nb" });
+
+ editor.setSelection(0, 9);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b");
+ editor.destroy();
+ });
+
+ it("normalises checked items back to unchecked within a mixed toggle", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [x] a\nb" });
+
+ editor.setSelection(0, 9);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b");
+ editor.destroy();
+ });
+
+ it("handles a reversed (head before anchor) selection", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "a\nb" });
+
+ editor.setSelection(3, 0);
+ toggleTaskList(editor);
+
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b");
+ editor.destroy();
+ });
+});
+
+describe("insertTable — atomic undo", () => {
+ it("insert → single undo fully restores the document", () => {
+ const container = document.createElement("div");
+ const original = "hello world";
+ const editor = createEditor({ container, initialValue: original, plugins: [createHistoryPlugin()] });
+
+ editor.setSelection(0, 0);
+ insertTable(editor);
+ expect(editor.getDocument()).not.toBe(original);
+
+ expect(editor.undo()).toBe(true);
+ expect(editor.getDocument()).toBe(original);
+
+ // Exactly one history entry was created.
+ expect(editor.undo()).toBe(false);
+ editor.destroy();
+ });
+
+ it("insert → undo → redo round-trip is consistent", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "text", plugins: [createHistoryPlugin()] });
+
+ editor.setSelection(4, 4);
+ insertTable(editor);
+ const inserted = editor.getDocument();
+ expect(inserted.startsWith("text\n")).toBe(true);
+
+ editor.undo();
+ expect(editor.getDocument()).toBe("text");
+
+ expect(editor.redo()).toBe(true);
+ expect(editor.getDocument()).toBe(inserted);
+ editor.destroy();
+ });
+});
+
+describe("toggleTaskList — atomic undo", () => {
+ it("multi-line toggle → single undo restores the document", () => {
+ const container = document.createElement("div");
+ const original = "a\nb\nc";
+ const editor = createEditor({ container, initialValue: original, plugins: [createHistoryPlugin()] });
+
+ editor.setSelection(0, 4); // covers "a\nb", leaves "c" untouched
+ toggleTaskList(editor);
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b\nc");
+
+ expect(editor.undo()).toBe(true);
+ expect(editor.getDocument()).toBe(original);
+
+ expect(editor.undo()).toBe(false);
+ editor.destroy();
+ });
+
+ it("toggle off → undo restores the task list", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a\n- [ ] b", plugins: [createHistoryPlugin()] });
+
+ editor.setSelection(0, 13);
+ toggleTaskList(editor);
+ expect(editor.getDocument()).toBe("a\nb");
+
+ expect(editor.undo()).toBe(true);
+ expect(editor.getDocument()).toBe("- [ ] a\n- [ ] b");
+ editor.destroy();
+ });
+});
+
+describe("toolbar slash commands — table and task list", () => {
+ it("registers table and task-list as slash commands", () => {
+ const plugin = createToolbarPlugin();
+ const ids = (plugin.slashCommands ?? []).map((c) => c.id);
+ expect(ids).toEqual(expect.arrayContaining(["table", "task-list"]));
+ });
+
+ it("executes the table slash command through the editor", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "", plugins: [createToolbarPlugin()] });
+
+ editor.setSelection(0, 0);
+ const tableCmd = editor.getSlashCommands().find((c) => c.id === "table");
+ expect(tableCmd).toBeDefined();
+ expect(tableCmd?.run?.(editor)).toBe(true);
+ expect(editor.getDocument().startsWith("| Header 1 |")).toBe(true);
+ editor.destroy();
+ });
+
+ it("executes the task-list slash command through the editor", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "todo", plugins: [createToolbarPlugin()] });
+
+ editor.setSelection(0, 0);
+ const cmd = editor.getSlashCommands().find((c) => c.id === "task-list");
+ expect(cmd).toBeDefined();
+ expect(cmd?.run?.(editor)).toBe(true);
+ expect(editor.getDocument()).toBe("- [ ] todo");
+ editor.destroy();
+ });
+});
+
+describe("toolbar UI — table and task-list buttons", () => {
+ it("renders a table button that inserts a table on click", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "" });
+ const toolbar = createToolbarUI(editor);
+ document.body.appendChild(toolbar.element);
+
+ const button = toolbar.element.querySelector('[data-toolbar-action="table"]');
+ expect(button).not.toBeNull();
+ expect(button?.getAttribute("aria-label")).toBe("Insert table");
+
+ button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(editor.getDocument().startsWith("| Header 1 |")).toBe(true);
+
+ toolbar.destroy();
+ editor.destroy();
+ });
+
+ it("renders a task-list button that toggles a task item on click", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "todo" });
+ const toolbar = createToolbarUI(editor);
+ document.body.appendChild(toolbar.element);
+
+ editor.setSelection(0, 0);
+ const button = toolbar.element.querySelector('[data-toolbar-action="task-list"]');
+ expect(button).not.toBeNull();
+ expect(button?.getAttribute("aria-label")).toBe("Task list");
+
+ button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(editor.getDocument()).toBe("- [ ] todo");
+
+ toolbar.destroy();
+ editor.destroy();
+ });
+});
\ No newline at end of file
From 9856755f2cf24138133ebcc612bcc0e896b421c4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E7=8E=8B=E9=91=AB?= <952580475@qq.com>
Date: Mon, 21 Sep 2026 22:56:54 +0800
Subject: [PATCH 2/2] feat(toolbar): add toggleTaskChecked and OpenSpec
proposal for table/task commands
- Add toggleTaskChecked: flips - [ ] <-> - [x] per line, no-op (returns
false) when no task items intersect the selection, atomic single undo
- Wire toggleTaskChecked into exports, slash command (task-toggle) and UI
- Document insertTable selection-replace semantic in JSDoc
- Replace obscure $ substitution in toggleTaskList off-path with explicit
group concatenation for readability and safety
- Add tests for selection-replace, toggleTaskChecked, and task-toggle
- Add OpenSpec change add-toolbar-table-tasklist (proposal/tasks/spec) to
satisfy the new-public-API requirement surfaced in self-review
---
.../add-toolbar-table-tasklist/proposal.md | 18 +++
.../specs/plugin-toolbar/spec.md | 73 ++++++++++
.../add-toolbar-table-tasklist/tasks.md | 24 ++++
packages/plugin-toolbar/src/formatting.ts | 41 +++++-
packages/plugin-toolbar/src/icons.ts | 9 ++
packages/plugin-toolbar/src/index.ts | 2 +-
.../plugin-toolbar/src/toolbar-commands.ts | 8 ++
packages/plugin-toolbar/src/toolbar-ui.ts | 3 +
.../test/plugin-toolbar.test.ts | 130 ++++++++++++++++++
9 files changed, 306 insertions(+), 2 deletions(-)
create mode 100644 openspec/changes/add-toolbar-table-tasklist/proposal.md
create mode 100644 openspec/changes/add-toolbar-table-tasklist/specs/plugin-toolbar/spec.md
create mode 100644 openspec/changes/add-toolbar-table-tasklist/tasks.md
diff --git a/openspec/changes/add-toolbar-table-tasklist/proposal.md b/openspec/changes/add-toolbar-table-tasklist/proposal.md
new file mode 100644
index 00000000..914e85c4
--- /dev/null
+++ b/openspec/changes/add-toolbar-table-tasklist/proposal.md
@@ -0,0 +1,18 @@
+# Change: Add GFM table insertion and task-list commands to the toolbar plugin
+
+## Why
+The toolbar plugin shipped formatting helpers for lists, headings, and inline marks, but had no first-class support for two of the most common GitHub-Flavored Markdown constructs: tables and task lists. Authors who wanted a GFM table had to hand-type the delimiter row; authors who wanted a checklist had to memorize the `- [ ]` syntax. This change exposes three focused, undo-coherent commands — `insertTable`, `toggleTaskList`, `toggleTaskChecked` — and wires them into the toolbar UI, the slash-command registry, and the package's public exports, so the toolbar offers parity with the GFM features users already expect.
+
+## What Changes
+- **ADDED** `insertTable(editor, options?)` to `packages/plugin-toolbar/src/formatting.ts` — inserts a well-formed GFM table (configurable columns/rows, optional header row) as one atomic `replaceRange` transaction; caret lands inside the first body cell; a non-empty selection is replaced
+- **ADDED** `toggleTaskList(editor)` — converts the selection's lines to `- [ ]` task items, converting plain text, ordered/unordered bullets in one pass; when every intersecting line is already a task item the markers are removed (toggle off); single atomic transaction
+- **ADDED** `toggleTaskChecked(editor)` — flips `- [ ]`↔`- [x]` per line; leaves non-task lines untouched; no-op returning `false` when no task items intersect the selection; single atomic transaction
+- **MODIFIED** `packages/plugin-toolbar/src/index.ts` — re-export the three new commands
+- **MODIFIED** `packages/plugin-toolbar/src/toolbar-commands.ts` — register `table`, `task-list`, `task-toggle` slash commands
+- **MODIFIED** `packages/plugin-toolbar/src/toolbar-ui.ts` — add `table`, `task-list`, `task-toggle` buttons to the default toolbar groups
+- **MODIFIED** `packages/plugin-toolbar/src/icons.ts` — add `iconTable`, `iconTaskList`, `iconTaskToggle`
+
+## Impact
+- Affected specs: plugin-toolbar
+- Affected code: `packages/plugin-toolbar/src/formatting.ts`, `index.ts`, `toolbar-commands.ts`, `toolbar-ui.ts`, `icons.ts`
+- **No breaking changes** to existing API
\ No newline at end of file
diff --git a/openspec/changes/add-toolbar-table-tasklist/specs/plugin-toolbar/spec.md b/openspec/changes/add-toolbar-table-tasklist/specs/plugin-toolbar/spec.md
new file mode 100644
index 00000000..cd56e1a1
--- /dev/null
+++ b/openspec/changes/add-toolbar-table-tasklist/specs/plugin-toolbar/spec.md
@@ -0,0 +1,73 @@
+# Plugin Toolbar Spec
+
+## ADDED Requirements
+
+### Requirement: insertTable inserts a well-formed GFM table atomically
+
+`insertTable(editor, options?)` SHALL insert a GitHub-Flavored Markdown table at the caret as a single atomic `replaceRange` transaction (one undo entry). The table SHALL consist of an optional header row, a mandatory delimiter row, and `rows` body rows (each body cell empty). Column count, row count, and header presence SHALL be configurable via `InsertTableOptions` (`cols`, `rows`, `includeHeaderRow`), each clamped to a sensible minimum (at least 1 column, 1 body row; header defaults to `true`). Surrounding newlines SHALL be added only when the insertion site is not already at a line boundary, so insertion into the middle of a paragraph or at document start/end stays well-formed. The caret SHALL be placed inside the first cell of the first body row. A non-empty selection SHALL be replaced by the table (its text discarded), consistent with the other insert commands in this module.
+
+#### Scenario: Default table inserts 3x3 with header
+- **WHEN** `insertTable(editor)` is called at a collapsed caret on an empty document
+- **THEN** a 3-column, 3-body-row table with a header row and delimiter row SHALL be inserted
+- **AND** columns SHALL be separated by `|` and the delimiter row SHALL contain `---` cells
+
+#### Scenario: Caret lands in the first body cell
+- **WHEN** `insertTable` completes
+- **THEN** the caret SHALL be positioned inside the first cell of the first body row so the user can type immediately
+
+#### Scenario: Configured columns and rows
+- **WHEN** `insertTable(editor, { cols: 2, rows: 1, includeHeaderRow: false })` is called
+- **THEN** the inserted table SHALL have 2 columns, 1 body row, no header row, and still include the delimiter row
+
+#### Scenario: Non-empty selection is replaced
+- **WHEN** `insertTable` is called with a non-empty selection
+- **THEN** the selected text SHALL be discarded and replaced by the table
+
+#### Scenario: Atomic undo
+- **WHEN** `insertTable` is called
+- **THEN** a single `undo()` SHALL remove the entire table and restore the caret
+
+### Requirement: toggleTaskList converts and toggles task markers across the selection
+
+`toggleTaskList(editor)` SHALL read the full selection range (normalizing reversed selections) and rewrite every intersecting line via `getLinesInRange` + `applyLines` in one atomic transaction. If **every** intersecting line is already a task item (`/^(\s*)([-*+]\s+)\[([ xX])\]\s?(.*)$/`), the task markers SHALL be removed and the lines revert to plain text (toggle off). Otherwise every intersecting line SHALL become a `- [ ]` task item, converting plain text, unordered bullets, and ordered list markers in one pass. Lines that are already task items when not toggling off SHALL be normalized to an unchecked `- [ ]` marker. The column-0 boundary rule (a line whose start equals the selection end is excluded) SHALL apply as in `getLinesInRange`.
+
+#### Scenario: Plain text becomes task list
+- **WHEN** `toggleTaskList` is called on a plain line
+- **THEN** the line SHALL gain a `- [ ] ` marker
+
+#### Scenario: Unordered bullet becomes task list
+- **WHEN** `toggleTaskList` is called on a `- item` line
+- **THEN** the line SHALL become `- [ ] item`
+
+#### Scenario: Ordered list becomes task list
+- **WHEN** `toggleTaskList` is called on a `1. item` line
+- **THEN** the line SHALL become `- [ ] item`
+
+#### Scenario: All tasks toggle off
+- **WHEN** `toggleTaskList` is called on lines that are all task items
+- **THEN** every task marker SHALL be removed and the original text preserved
+
+#### Scenario: Multi-line conversion in one undo step
+- **WHEN** `toggleTaskList` is called on a multi-line selection
+- **THEN** all lines SHALL become task items
+- **AND** a single `undo()` SHALL restore every line
+
+### Requirement: toggleTaskChecked flips the checked state of task items
+
+`toggleTaskChecked(editor)` SHALL read the full selection range and flip `- [ ]` to `- [x]` and `- [x]`/`-[X]` to `- [ ]` on a per-line basis via `getLinesInRange` + `applyLines` in one atomic transaction. Lines that are NOT task items SHALL be left unchanged. When no intersecting line is a task item, the command SHALL be a no-op and return `false`.
+
+#### Scenario: Unchecked becomes checked
+- **WHEN** `toggleTaskChecked` is called on a `- [ ] item` line
+- **THEN** the line SHALL become `- [x] item`
+
+#### Scenario: Checked becomes unchecked
+- **WHEN** `toggleTaskChecked` is called on a `- [x] item` line
+- **THEN** the line SHALL become `- [ ] item`
+
+#### Scenario: Non-task lines are untouched
+- **WHEN** `toggleTaskChecked` is called on a selection containing only plain text or bullets
+- **THEN** the document SHALL be unchanged and the command SHALL return `false`
+
+#### Scenario: Atomic undo
+- **WHEN** `toggleTaskChecked` flips a task item
+- **THEN** a single `undo()` SHALL restore the previous checked state
\ No newline at end of file
diff --git a/openspec/changes/add-toolbar-table-tasklist/tasks.md b/openspec/changes/add-toolbar-table-tasklist/tasks.md
new file mode 100644
index 00000000..cd7a8306
--- /dev/null
+++ b/openspec/changes/add-toolbar-table-tasklist/tasks.md
@@ -0,0 +1,24 @@
+# Tasks: add-toolbar-table-tasklist
+
+## Phase 1: Core commands
+
+- [x] 1.1 Add `InsertTableOptions` interface and `buildTableRow` helper to `packages/plugin-toolbar/src/formatting.ts`
+- [x] 1.2 Implement `insertTable` — configurable cols/rows/header, atomic `replaceRange`, caret in first body cell, replace non-empty selection
+- [x] 1.3 Implement `toggleTaskList` — convert/toggle task markers across selection using `getLinesInRange` + `applyLines`
+- [x] 1.4 Implement `toggleTaskChecked` — flip `- [ ]`↔`- [x]` per line, no-op when no task items
+- [x] 1.5 Re-export the three commands from `packages/plugin-toolbar/src/index.ts`
+
+## Phase 2: Integration & polish
+
+- [x] 2.1 Add `iconTable`, `iconTaskList`, `iconTaskToggle` to `packages/plugin-toolbar/src/icons.ts`
+- [x] 2.2 Register `table`, `task-list`, `task-toggle` slash commands in `packages/plugin-toolbar/src/toolbar-commands.ts`
+- [x] 2.3 Add `table`, `task-list`, `task-toggle` buttons to default toolbar groups in `packages/plugin-toolbar/src/toolbar-ui.ts`
+- [x] 2.4 Expand JSDoc on `insertTable` to document the selection-replace semantic
+- [x] 2.5 Make `toggleTaskList` off-path use explicit group concatenation instead of a `$`-substitution for readability/safety
+
+## Phase 3: Tests
+
+- [x] 3.1 Add `insertTable` tests (default, options, caret placement, boundary newlines, replace non-empty selection)
+- [x] 3.2 Add `toggleTaskList` tests (add, toggle-off, ordered→task, plain→task, mixed, multi-line, atomic undo)
+- [x] 3.3 Add `toggleTaskChecked` tests (check, uncheck, mixed, no-op, atomic undo)
+- [x] 3.4 Add slash-command + UI-button tests for `table`, `task-list`, `task-toggle`
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/formatting.ts b/packages/plugin-toolbar/src/formatting.ts
index 3ef88e23..c8a6cbe0 100644
--- a/packages/plugin-toolbar/src/formatting.ts
+++ b/packages/plugin-toolbar/src/formatting.ts
@@ -247,6 +247,9 @@ function buildTableRow(cells: readonly string[]): string {
*
* Surrounding newlines are added only when needed, so inserting into the middle
* of a paragraph or at the very start/end of the document stays well-formed.
+ *
+ * A non-empty selection is **replaced** by the table (its text is discarded),
+ * consistent with the other insert commands in this module.
*/
export function insertTable(editor: EditorAPI, options: InsertTableOptions = {}): boolean {
const cols = Math.max(1, Math.floor(options.cols ?? 3));
@@ -317,7 +320,10 @@ export function toggleTaskList(editor: EditorAPI): boolean {
return `${task[1]}${task[4]}`;
}
// Not all lines are tasks yet — normalise existing items to unchecked.
- return line.replace(TASK_MARKER, "$1$2[ ] $4");
+ // Build the replacement from the captured groups explicitly (rather than
+ // a "$1$2[ ] $4" substitution) so the transform stays readable and immune
+ // to accidental `$` characters in the surrounding text.
+ return `${task[1]}${task[2]}[ ] ${task[4]}`;
}
const list = line.match(LIST_MARKER);
@@ -329,5 +335,38 @@ export function toggleTaskList(editor: EditorAPI): boolean {
return `${indent}- [ ] ${line.slice(indent.length)}`;
});
+ return applyLines(editor, lines, newLines);
+}
+
+/**
+ * Flip the checked state of every task item intersecting the selection.
+ *
+ * `- [ ]` becomes `- [x]` and `- [x]` becomes `- [ ]` on a per-line basis.
+ * Lines that are NOT already task items (plain text, bullets, ordered lists)
+ * are left untouched, so on a selection with no task items this is a no-op and
+ * returns `false`.
+ *
+ * Rewritten in one `replaceRange` transaction so the whole flip — including the
+ * caret move — is a single undo step.
+ */
+export function toggleTaskChecked(editor: EditorAPI): boolean {
+ const doc = editor.getDocument();
+ const { anchor, head } = editor.getSelection();
+ const from = Math.min(anchor, head);
+ const to = Math.max(anchor, head);
+ const lines = getLinesInRange(doc, from, to);
+
+ if (!lines.some(({ line }) => TASK_MARKER.test(line))) {
+ return false; // nothing to flip
+ }
+
+ const newLines = lines.map(({ line }) => {
+ const task = line.match(TASK_MARKER);
+ if (!task) return line;
+ // task[3] is the existing mark: " " (unchecked) or "x"/"X" (checked).
+ const checked = task[3] === " " ? "x" : " ";
+ return `${task[1]}${task[2]}[${checked}] ${task[4]}`;
+ });
+
return applyLines(editor, lines, newLines);
}
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/icons.ts b/packages/plugin-toolbar/src/icons.ts
index 013475d1..b10ae79d 100644
--- a/packages/plugin-toolbar/src/icons.ts
+++ b/packages/plugin-toolbar/src/icons.ts
@@ -190,4 +190,13 @@ export function iconTaskList(): HTMLElement {
`` +
``
);
+}
+
+export function iconTaskToggle(): HTMLElement {
+ return svgIcon(
+ `` +
+ `` +
+ `` +
+ ``
+ );
}
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/index.ts b/packages/plugin-toolbar/src/index.ts
index 3537ecbc..b5e390ee 100644
--- a/packages/plugin-toolbar/src/index.ts
+++ b/packages/plugin-toolbar/src/index.ts
@@ -10,7 +10,7 @@ import {
toggleStrikethrough,
} from "./toolbar-commands";
-export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight, insertTable, toggleTaskList } from "./formatting";
+export { toggleBlockquote, toggleOrderedList, toggleUnorderedList, insertCodeBlock, insertImage, insertHorizontalRule, applyTextColor, applyHighlight, insertTable, toggleTaskList, toggleTaskChecked } from "./formatting";
export { createToolbarUI } from "./toolbar-ui";
export { colorDecorationExtension } from "./color-decoration";
export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui";
diff --git a/packages/plugin-toolbar/src/toolbar-commands.ts b/packages/plugin-toolbar/src/toolbar-commands.ts
index 0873d211..27d32b34 100644
--- a/packages/plugin-toolbar/src/toolbar-commands.ts
+++ b/packages/plugin-toolbar/src/toolbar-commands.ts
@@ -7,6 +7,7 @@ import {
insertTable,
toggleBlockquote,
toggleOrderedList,
+ toggleTaskChecked,
toggleTaskList,
toggleUnorderedList,
} from "./formatting";
@@ -196,4 +197,11 @@ export const toolbarSlashCommands: SlashCommandDef[] = [
keywords: ["task", "todo", "checkbox", "checklist", "list"],
run: toggleTaskList,
},
+ {
+ id: "task-toggle",
+ title: "Toggle checkboxes",
+ description: "Flip the checked state of task list items in the selection",
+ keywords: ["task", "checkbox", "check", "tick", "done", "todo"],
+ run: toggleTaskChecked,
+ },
];
\ No newline at end of file
diff --git a/packages/plugin-toolbar/src/toolbar-ui.ts b/packages/plugin-toolbar/src/toolbar-ui.ts
index f036fe84..d36cc438 100644
--- a/packages/plugin-toolbar/src/toolbar-ui.ts
+++ b/packages/plugin-toolbar/src/toolbar-ui.ts
@@ -19,6 +19,7 @@ import {
applyHighlight,
insertTable,
toggleTaskList,
+ toggleTaskChecked,
} from "./formatting";
import {
iconUndo,
@@ -41,6 +42,7 @@ import {
iconImage,
iconTable,
iconTaskList,
+ iconTaskToggle,
iconFullscreen,
} from "./icons";
@@ -177,6 +179,7 @@ function defaultGroups(options?: ToolbarUIOptions): ToolbarGroup[] {
{ id: "ordered-list", title: "Ordered list", icon: iconOrderedList, action: toggleOrderedList },
{ id: "unordered-list", title: "Unordered list", icon: iconUnorderedList, action: toggleUnorderedList },
{ id: "task-list", title: "Task list", icon: iconTaskList, action: toggleTaskList },
+ { id: "task-toggle", title: "Toggle checkboxes", icon: iconTaskToggle, action: toggleTaskChecked },
],
},
{
diff --git a/packages/plugin-toolbar/test/plugin-toolbar.test.ts b/packages/plugin-toolbar/test/plugin-toolbar.test.ts
index 4d40f63a..898a4cf9 100644
--- a/packages/plugin-toolbar/test/plugin-toolbar.test.ts
+++ b/packages/plugin-toolbar/test/plugin-toolbar.test.ts
@@ -12,6 +12,7 @@ import {
toggleUnorderedList,
insertTable,
toggleTaskList,
+ toggleTaskChecked,
createToolbarPlugin,
createToolbarUI,
} from "../src/index";
@@ -963,6 +964,135 @@ describe("toolbar UI — table and task-list buttons", () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(editor.getDocument()).toBe("- [ ] todo");
+ toolbar.destroy();
+ editor.destroy();
+ });
+});
+
+describe("insertTable — replaces a non-empty selection", () => {
+ it("replaces the selected text with the table", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "remove this text" });
+
+ editor.setSelection(0, "remove this text".length);
+ insertTable(editor);
+
+ const doc = editor.getDocument();
+ expect(doc).not.toContain("remove this text");
+ expect(doc.startsWith("| Header 1 |")).toBe(true);
+ editor.destroy();
+ });
+
+ it("keeps surrounding content intact when replacing a mid-document selection", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "before\nmiddle\nafter" });
+
+ // Select the word "middle".
+ editor.setSelection("before\n".length, "before\nmiddle".length);
+ insertTable(editor);
+
+ const doc = editor.getDocument();
+ expect(doc.startsWith("before\n")).toBe(true);
+ expect(doc.endsWith("\nafter")).toBe(true);
+ expect(doc).not.toContain("middle");
+ expect(doc).toContain("| Header 1 |");
+ editor.destroy();
+ });
+});
+
+describe("toggleTaskChecked", () => {
+ it("checks an unchecked task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a" });
+
+ editor.setSelection(0, 0);
+ expect(toggleTaskChecked(editor)).toBe(true);
+ expect(editor.getDocument()).toBe("- [x] a");
+ editor.destroy();
+ });
+
+ it("unchecks a checked task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [x] a" });
+
+ editor.setSelection(0, 0);
+ expect(toggleTaskChecked(editor)).toBe(true);
+ expect(editor.getDocument()).toBe("- [ ] a");
+ editor.destroy();
+ });
+
+ it("flips only the task items in a mixed selection", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a\n- [x] b\nplain" });
+
+ editor.setSelection(0, 15);
+ expect(toggleTaskChecked(editor)).toBe(true);
+ expect(editor.getDocument()).toBe("- [x] a\n- [ ] b\nplain");
+ editor.destroy();
+ });
+
+ it("is a no-op and returns false when there is no task item", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "plain text" });
+
+ editor.setSelection(0, 0);
+ expect(toggleTaskChecked(editor)).toBe(false);
+ expect(editor.getDocument()).toBe("plain text");
+ editor.destroy();
+ });
+
+ it("flips checked state as a single undo step", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] a", plugins: [createHistoryPlugin()] });
+
+ editor.setSelection(0, 0);
+ toggleTaskChecked(editor);
+ expect(editor.getDocument()).toBe("- [x] a");
+
+ expect(editor.undo()).toBe(true);
+ expect(editor.getDocument()).toBe("- [ ] a");
+
+ // Exactly one history entry.
+ expect(editor.undo()).toBe(false);
+ editor.destroy();
+ });
+});
+
+describe("toolbar slash commands — task-toggle", () => {
+ it("registers task-toggle as a slash command", () => {
+ const plugin = createToolbarPlugin();
+ const ids = (plugin.slashCommands ?? []).map((c) => c.id);
+ expect(ids).toEqual(expect.arrayContaining(["task-toggle"]));
+ });
+
+ it("executes the task-toggle slash command through the editor", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] todo", plugins: [createToolbarPlugin()] });
+
+ editor.setSelection(0, 0);
+ const cmd = editor.getSlashCommands().find((c) => c.id === "task-toggle");
+ expect(cmd).toBeDefined();
+ expect(cmd?.run?.(editor)).toBe(true);
+ expect(editor.getDocument()).toBe("- [x] todo");
+ editor.destroy();
+ });
+});
+
+describe("toolbar UI — task-toggle button", () => {
+ it("renders a task-toggle button that flips the checkbox on click", () => {
+ const container = document.createElement("div");
+ const editor = createEditor({ container, initialValue: "- [ ] todo" });
+ const toolbar = createToolbarUI(editor);
+ document.body.appendChild(toolbar.element);
+
+ editor.setSelection(0, 0);
+ const button = toolbar.element.querySelector('[data-toolbar-action="task-toggle"]');
+ expect(button).not.toBeNull();
+ expect(button?.getAttribute("aria-label")).toBe("Toggle checkboxes");
+
+ button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(editor.getDocument()).toBe("- [x] todo");
+
toolbar.destroy();
editor.destroy();
});