Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions openspec/changes/add-toolbar-table-tasklist/proposal.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions openspec/changes/add-toolbar-table-tasklist/tasks.md
Original file line number Diff line number Diff line change
@@ -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`
162 changes: 162 additions & 0 deletions packages/plugin-toolbar/src/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,165 @@ 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.
*
* 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));
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.
// 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);
if (list) {
return `${list[1]}- [ ] ${list[3]}`;
}

const indent = line.match(/^(\s*)/)?.[1] ?? "";
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);
}
26 changes: 26 additions & 0 deletions packages/plugin-toolbar/src/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,29 @@ export function iconHorizontalRule(): HTMLElement {
`<line x1="2" y1="9" x2="16" y2="9"/>`
);
}

export function iconTable(): HTMLElement {
return svgIcon(
`<rect x="2" y="3" width="14" height="12" rx="1"/>` +
`<line x1="2" y1="9" x2="16" y2="9"/>` +
`<line x1="9" y1="3" x2="9" y2="15"/>`
);
}

export function iconTaskList(): HTMLElement {
return svgIcon(
`<rect x="2" y="4" width="7" height="7" rx="1"/>` +
`<path d="M3.5 7.5l1.5 1.5 2.5-3"/>` +
`<line x1="11" y1="7.5" x2="16" y2="7.5"/>` +
`<line x1="11" y1="12" x2="16" y2="12"/>`
);
}

export function iconTaskToggle(): HTMLElement {
return svgIcon(
`<rect x="2" y="3" width="7" height="7" rx="1"/>` +
`<path d="M3.5 6.5l1.5 1.5 2.5-3"/>` +
`<path d="M12 4l2 2 4-4"/>` +
`<line x1="11" y1="13" x2="16" y2="13"/>`
);
}
4 changes: 2 additions & 2 deletions packages/plugin-toolbar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, toggleTaskChecked } from "./formatting";
export { createToolbarUI } from "./toolbar-ui";
export { colorDecorationExtension } from "./color-decoration";
export type { ToolbarUI, ToolbarUIOptions, ToolbarButton, ToolbarGroup } from "./toolbar-ui";
Expand Down Expand Up @@ -48,4 +48,4 @@ export {
ToolbarLifecyclePlugin,
toolbarLifecyclePluginManifest,
type ToolbarLifecyclePluginOptions,
} from "./runtime-plugin";
} from "./runtime-plugin";
26 changes: 25 additions & 1 deletion packages/plugin-toolbar/src/toolbar-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
insertCodeBlock,
insertHorizontalRule,
insertImage,
insertTable,
toggleBlockquote,
toggleOrderedList,
toggleTaskChecked,
toggleTaskList,
toggleUnorderedList,
} from "./formatting";

Expand Down Expand Up @@ -180,4 +183,25 @@ 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,
},
{
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,
},
];
Loading