mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
@@ -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")])]),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user