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(); });