fix: preserve nesting when toggling to/from checklist in nested lists

Converting a list to or from a checklist previously cleared all nodes and
re-wrapped them, which flattened any nested list structure. Convert the
list nodes in place instead, recursively mapping list and item types so
nesting is preserved.

https://claude.ai/code/session_01BGH191WyuL9SgQyjfJ3YsD
This commit is contained in:
Claude
2026-06-13 01:46:17 +00:00
parent 394c6e3b03
commit c0e6884898
2 changed files with 194 additions and 6 deletions
+119 -1
View File
@@ -8,7 +8,8 @@ import {
} from "@shared/test/editor";
import toggleList from "./toggleList";
const { bullet_list, ordered_list, list_item } = schema.nodes;
const { bullet_list, ordered_list, list_item, checkbox_list, checkbox_item } =
schema.nodes;
/**
* Creates a list item node with the given block content.
@@ -17,6 +18,13 @@ function li(content: Node[]) {
return list_item.create(null, content);
}
/**
* Creates a checkbox item node with the given block content.
*/
function cli(content: Node[], checked = false) {
return checkbox_item.create({ checked }, content);
}
/**
* Returns a position inside the first text node matching the given text.
*
@@ -97,6 +105,116 @@ describe("toggleList", () => {
expect(outer?.child(1).child(1).type.name).toBe("bullet_list");
});
it("preserves nesting when converting a bullet list with a nested list to a checklist", () => {
const testDoc = doc([
bullet_list.create(null, [
li([p("one")]),
li([p("two"), bullet_list.create(null, [li([p("nested")])])]),
]),
]);
const result = run(
testDoc,
"two",
toggleList(checkbox_list, checkbox_item)
);
const outer = result.firstChild;
expect(outer?.type.name).toBe("checkbox_list");
expect(outer?.childCount).toBe(2);
expect(outer?.child(0).type.name).toBe("checkbox_item");
expect(outer?.child(1).type.name).toBe("checkbox_item");
const nested = outer?.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("preserves nesting when converting a checklist with a nested checklist to a bullet list", () => {
const testDoc = doc([
checkbox_list.create(null, [
cli([p("one")]),
cli([p("two"), checkbox_list.create(null, [cli([p("nested")])])]),
]),
]);
const result = run(testDoc, "two", toggleList(bullet_list, list_item));
const outer = result.firstChild;
expect(outer?.type.name).toBe("bullet_list");
expect(outer?.childCount).toBe(2);
expect(outer?.child(0).type.name).toBe("list_item");
expect(outer?.child(1).type.name).toBe("list_item");
const nested = outer?.child(1).child(1);
expect(nested?.type.name).toBe("bullet_list");
expect(nested?.child(0).type.name).toBe("list_item");
expect(nested?.child(0).textContent).toBe("nested");
});
it("converts a checklist nested in a bullet list without changing the parent list", () => {
const testDoc = doc([
bullet_list.create(null, [
li([p("one")]),
li([p("two"), checkbox_list.create(null, [cli([p("nested")])])]),
]),
]);
const result = run(testDoc, "nested", toggleList(bullet_list, list_item));
const outer = result.firstChild;
expect(outer?.type.name).toBe("bullet_list");
expect(outer?.child(1).type.name).toBe("list_item");
const nested = outer?.child(1).child(1);
expect(nested?.type.name).toBe("bullet_list");
expect(nested?.child(0).type.name).toBe("list_item");
expect(nested?.child(0).textContent).toBe("nested");
});
it("converts a bullet list nested in a checklist to a checklist without changing the parent list", () => {
const testDoc = doc([
checkbox_list.create(null, [
cli([p("one")]),
cli([p("two"), bullet_list.create(null, [li([p("nested")])])]),
]),
]);
const result = run(
testDoc,
"nested",
toggleList(checkbox_list, checkbox_item)
);
const outer = result.firstChild;
expect(outer?.type.name).toBe("checkbox_list");
expect(outer?.child(1).type.name).toBe("checkbox_item");
const nested = outer?.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("preserves the checked state of items already part of a nested checklist", () => {
const testDoc = doc([
bullet_list.create(null, [
li([p("one"), checkbox_list.create(null, [cli([p("nested")], true)])]),
]),
]);
const result = run(
testDoc,
"one",
toggleList(checkbox_list, checkbox_item)
);
const outer = result.firstChild;
expect(outer?.type.name).toBe("checkbox_list");
expect(outer?.child(0).child(1).child(0).attrs.checked).toBe(true);
});
it("lifts the item out of the list when toggling the same list type", () => {
const testDoc = doc([
bullet_list.create(null, [li([p("one")]), li([p("two")])]),
+75 -5
View File
@@ -1,4 +1,9 @@
import type { NodeType } from "prosemirror-model";
import type {
Attrs,
Node as ProsemirrorNode,
NodeType,
Schema,
} from "prosemirror-model";
import { liftListItem, wrapInList } from "prosemirror-schema-list";
import type { Command } from "prosemirror-state";
import { chainTransactions } from "../lib/chainTransactions";
@@ -40,10 +45,31 @@ export default function toggleList(
const differentType = currentItemType && currentItemType !== itemType;
if (differentType) {
return chainTransactions(
clearNodes(),
wrapInList(listType, { listStyle })
)(state, dispatch);
// Convert the list in place, preserving any nested list structure,
// rather than clearing the nodes and re-wrapping which would flatten
// nesting this path is hit when toggling to or from a checklist.
try {
const converted = convertListType(
parentList.node,
listType,
itemType,
schema,
listStyle ? { listStyle } : undefined
);
dispatch?.(
tr.replaceWith(
parentList.pos,
parentList.pos + parentList.node.nodeSize,
converted
)
);
return true;
} catch (_err) {
return chainTransactions(
clearNodes(),
wrapInList(listType, { listStyle })
)(state, dispatch);
}
}
if (
@@ -85,3 +111,47 @@ export default function toggleList(
);
};
}
/**
* Recursively converts a list node, its items, and any lists nested within
* those items to the given list and item type, preserving nesting structure.
*
* @param node the list node to convert.
* @param listType the node type to convert lists to.
* @param itemType the node type to convert list items to.
* @param schema the document schema.
* @param attrs optional attributes for the converted lists.
* @returns the converted list node.
* @throws if the content of an item is not valid for the new item type.
*/
function convertListType(
node: ProsemirrorNode,
listType: NodeType,
itemType: NodeType,
schema: Schema,
attrs?: Attrs
): ProsemirrorNode {
const items: ProsemirrorNode[] = [];
node.forEach((item) => {
const content: ProsemirrorNode[] = [];
item.forEach((child) => {
content.push(
isList(child, schema)
? convertListType(child, listType, itemType, schema, attrs)
: child
);
});
items.push(
itemType.createChecked(
item.type === itemType ? item.attrs : null,
content,
item.marks
)
);
});
return listType.createChecked(
node.type === listType ? node.attrs : (attrs ?? null),
items,
node.marks
);
}