feat: convert plain list to checklist when typing checkbox marker

Typing "[ ] " at the start of an existing bullet or ordered list item now
converts the whole list to a checklist, preserving nested list structure,
reusing the in-place list conversion utility.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD
This commit is contained in:
Claude
2026-06-13 02:32:22 +00:00
parent c0e6884898
commit a34d7790f9
3 changed files with 203 additions and 3 deletions
+151
View File
@@ -0,0 +1,151 @@
import type { InputRule } from "prosemirror-inputrules";
import type { Node } from "prosemirror-model";
import {
createEditorStateWithSelection,
doc,
p,
schema,
} from "@shared/test/editor";
import { checkboxListInputRule } from "./listInputRule";
const { bullet_list, ordered_list, list_item, checkbox_list, checkbox_item } =
schema.nodes;
/**
* Creates a list item node with the given block content.
*/
function li(content: Node[]) {
return list_item.create(null, content);
}
/**
* Returns the position directly after the first occurrence of the given text.
*
* @throws if no matching text node exists in the document.
*/
function posAfterText(node: Node, text: string) {
let found = -1;
node.descendants((child, pos) => {
if (found === -1 && child.isText && child.text?.startsWith(text)) {
found = pos;
}
return found === -1;
});
if (found === -1) {
throw new Error(`Text "${text}" not found in document`);
}
return found + text.length;
}
/**
* Simulates typing the trigger character of an input rule, mirroring the way
* prosemirror-inputrules invokes a rule's handler, and returns the resulting
* document (or the unchanged document when the rule does not fire).
*/
function typeTrigger(
rule: InputRule,
testDoc: Node,
markerInDoc: string,
triggerChar: string
) {
let state = createEditorStateWithSelection(
testDoc,
posAfterText(testDoc, markerInDoc)
);
const { from, to } = state.selection;
const $from = state.doc.resolve(from);
const textBefore =
$from.parent.textBetween(
Math.max(0, $from.parentOffset - 500),
$from.parentOffset,
null,
""
) + triggerChar;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = (rule as any).match.exec(textBefore) as RegExpMatchArray | null;
if (!match) {
return state.doc;
}
const startPos = from - (match[0].length - triggerChar.length);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tr = (rule as any).handler(state, match, startPos, to);
if (tr) {
state = state.apply(tr);
}
return state.doc;
}
const rule = checkboxListInputRule(
/^-?\s*(\[\s?\])\s$/i,
checkbox_list,
checkbox_item
);
describe("checkboxListInputRule", () => {
it("converts a plain bullet list to a checklist", () => {
const testDoc = doc([
bullet_list.create(null, [li([p("[ ]")]), li([p("two")])]),
]);
const result = typeTrigger(rule, testDoc, "[ ]", " ");
const list = result.firstChild;
expect(list?.type.name).toBe("checkbox_list");
expect(list?.childCount).toBe(2);
expect(list?.child(0).type.name).toBe("checkbox_item");
expect(list?.child(0).textContent).toBe("");
expect(list?.child(1).textContent).toBe("two");
});
it("converts a plain ordered list to a checklist", () => {
const testDoc = doc([
ordered_list.create(null, [li([p("[ ]")]), li([p("two")])]),
]);
const result = typeTrigger(rule, testDoc, "[ ]", " ");
expect(result.firstChild?.type.name).toBe("checkbox_list");
});
it("preserves nesting when converting a list with a nested list", () => {
const testDoc = doc([
bullet_list.create(null, [
li([p("[ ]")]),
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
]),
]);
const result = typeTrigger(rule, testDoc, "[ ]", " ");
const list = result.firstChild;
expect(list?.type.name).toBe("checkbox_list");
const nested = list?.child(1).child(1);
expect(nested?.type.name).toBe("checkbox_list");
expect(nested?.child(0).type.name).toBe("checkbox_item");
expect(nested?.child(0).textContent).toBe("nested");
});
it("does nothing when already in a checklist", () => {
const testDoc = doc([
checkbox_list.create(null, [
checkbox_item.create(null, p("[ ]")),
checkbox_item.create(null, p("two")),
]),
]);
const result = typeTrigger(rule, testDoc, "[ ]", " ");
// The rule should not fire; the marker text is left untouched.
expect(result.firstChild?.type.name).toBe("checkbox_list");
expect(result.firstChild?.child(0).textContent).toBe("[ ]");
});
it("does nothing when not inside a list", () => {
const testDoc = doc([p("[ ]")]);
const result = typeTrigger(rule, testDoc, "[ ]", " ");
expect(result.firstChild?.type.name).toBe("paragraph");
expect(result.firstChild?.textContent).toBe("[ ]");
});
});
+40
View File
@@ -4,7 +4,10 @@ import type {
Node as ProsemirrorNode,
Attrs,
} from "prosemirror-model";
import toggleList from "../commands/toggleList";
import { findParentNodeClosestToPos } from "../queries/findParentNode";
import { isInHeading } from "../queries/isInHeading";
import { isList } from "../queries/isList";
/**
* A wrapper for wrappingInputRule that prevents execution inside heading nodes.
@@ -31,3 +34,40 @@ export function listWrappingInputRule(
return (rule as any).handler(state, match, start, end);
});
}
/**
* An input rule that converts an existing plain list (bullet or ordered) to a
* checklist when the checkbox marker is typed at the start of a list item,
* preserving any nested list structure.
*
* @param regexp the pattern matching the checkbox marker, e.g. "[ ] ".
* @param listType the checkbox list node type to convert to.
* @param itemType the checkbox item node type to convert items to.
* @returns the input rule.
*/
export function checkboxListInputRule(
regexp: RegExp,
listType: NodeType,
itemType: NodeType
): InputRule {
return new InputRule(regexp, (state, _match, start, end) => {
const { schema } = state;
// Only act when the selection sits inside a plain (non-checkbox) list —
// converting a paragraph is already handled by listWrappingInputRule.
const list = findParentNodeClosestToPos(state.selection.$from, (node) =>
isList(node, schema)
);
if (!list || list.node.type === listType) {
return null;
}
// Remove the typed marker, then convert the list in place.
const tr = state.tr.delete(start, end);
const after = state.apply(tr);
toggleList(listType, itemType)(after, (toggleTr) => {
toggleTr.steps.forEach((step) => tr.step(step));
});
return tr;
});
}
+12 -3
View File
@@ -8,7 +8,10 @@ import { Plugin } from "prosemirror-state";
import { v4 as generateUuid } from "uuid";
import toggleList from "../commands/toggleList";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { listWrappingInputRule } from "../lib/listInputRule";
import {
checkboxListInputRule,
listWrappingInputRule,
} from "../lib/listInputRule";
import { findBlockNodes } from "../queries/findChildren";
import { CheckboxListView } from "./CheckboxListView";
import Node from "./Node";
@@ -84,8 +87,14 @@ export default class CheckboxList extends Node {
return () => toggleList(type, schema.nodes.checkbox_item);
}
inputRules({ type }: { type: NodeType }) {
return [listWrappingInputRule(/^-?\s*(\[\s?\])\s$/i, type)];
inputRules({ type, schema }: { type: NodeType; schema: Schema }) {
const pattern = /^-?\s*(\[\s?\])\s$/i;
return [
// Convert an existing plain list to a checklist, keeping nesting intact.
checkboxListInputRule(pattern, type, schema.nodes.checkbox_item),
// Wrap a plain paragraph into a new checklist.
listWrappingInputRule(pattern, type),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {