Skip to content

Commit 73e7a02

Browse files
committed
fix(core): preserve list item type when pasting into empty inline-content blocks
Fixes #2330 Pasting plain text or a paragraph into an empty bullet/numbered/check list item replaced the list item with a paragraph because BlockNote's serializer wraps content in `blockGroup > blockContainer > paragraph`, producing a closed slice that ProseMirror inserts as a new block rather than splicing inline. `transformPasted` now retypes the leading paragraph in such a slice to match the empty target block, so the list item keeps its type and any trailing blocks become siblings. Also fixes bare `<li>a</li><li>b</li>` HTML parsing: the BulletListItem parse rule requires a `<ul>`/`<ol>` parent, so orphan `<li>`s used to fall back to paragraphs. `nestedListsToBlockNoteStructure` now wraps consecutive orphan `<li>` siblings in a fresh `<ul>` before parsing.
1 parent 531ea32 commit 73e7a02

5 files changed

Lines changed: 430 additions & 0 deletions

File tree

packages/core/src/api/parsers/html/util/__snapshots__/nestedLists.test.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3+
exports[`Lift nested lists > Leaves <li>s already inside a <ul> alone 1`] = `"<ul><li>existing</li></ul><ul><li>orphan</li></ul>"`;
4+
35
exports[`Lift nested lists > Lifts multiple bullet lists 1`] = `
46
"<ul>
57
<div><li>
@@ -142,3 +144,11 @@ exports[`Lift nested lists > Lifts nested numbered lists 1`] = `
142144
</li>
143145
</ol>"
144146
`;
147+
148+
exports[`Lift nested lists > Wraps a single bare <li> in a <ul> 1`] = `"<ul><li>only</li></ul>"`;
149+
150+
exports[`Lift nested lists > Wraps bare <li>s mixed with other top-level content 1`] = `"<p>before</p><ul><li>x</li><li>y</li></ul><p>after</p>"`;
151+
152+
exports[`Lift nested lists > Wraps consecutive bare <li> elements in a <ul> 1`] = `"<ul><li>a</li><li>b</li></ul>"`;
153+
154+
exports[`Lift nested lists > Wraps nested orphan <li>s as inner lists 1`] = `"<ul><li>outer</li><li>inner</li></ul>"`;

packages/core/src/api/parsers/html/util/nestedLists.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,31 @@ describe("Lift nested lists", () => {
143143
await testHTML(html);
144144
});
145145

146+
it("Wraps consecutive bare <li> elements in a <ul>", async () => {
147+
const html = `<li>a</li><li>b</li>`;
148+
await testHTML(html);
149+
});
150+
151+
it("Wraps a single bare <li> in a <ul>", async () => {
152+
const html = `<li>only</li>`;
153+
await testHTML(html);
154+
});
155+
156+
it("Wraps bare <li>s mixed with other top-level content", async () => {
157+
const html = `<p>before</p><li>x</li><li>y</li><p>after</p>`;
158+
await testHTML(html);
159+
});
160+
161+
it("Leaves <li>s already inside a <ul> alone", async () => {
162+
const html = `<ul><li>existing</li></ul><li>orphan</li>`;
163+
await testHTML(html);
164+
});
165+
166+
it("Wraps nested orphan <li>s as inner lists", async () => {
167+
const html = `<li>outer<li>inner</li></li>`;
168+
await testHTML(html);
169+
});
170+
146171
it("Lifts nested mixed lists", async () => {
147172
const html = `<ol>
148173
<li>

packages/core/src/api/parsers/html/util/nestedLists.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,46 @@ function isWhitespaceNode(node: Node) {
66
return node.nodeType === 3 && !/\S/.test(node.nodeValue || "");
77
}
88

9+
/**
10+
* Step 0, wraps any `<li>` element that is not inside a `<ul>`/`<ol>` in a
11+
* fresh `<ul>` so the existing parse rules (which require an `<ul>`/`<ol>`
12+
* parent) match. Consecutive orphan `<li>` siblings are grouped under a
13+
* single `<ul>`.
14+
*
15+
* Without this, pasting bare `<li>a</li><li>b</li>` HTML would parse as two
16+
* paragraphs because the BulletListItem parse rule only matches `<li>`
17+
* whose parent is `<ul>`.
18+
*/
19+
function wrapOrphanListItems(element: HTMLElement) {
20+
const orphans = Array.from(element.querySelectorAll("li")).filter(
21+
(li) => li.closest("ul, ol") === null,
22+
);
23+
const orphanSet = new Set(orphans);
24+
const handled = new Set<Element>();
25+
26+
for (const orphan of orphans) {
27+
if (handled.has(orphan)) {
28+
continue;
29+
}
30+
31+
const group: Element[] = [orphan];
32+
handled.add(orphan);
33+
34+
let next = orphan.nextElementSibling;
35+
while (next && next.tagName === "LI" && orphanSet.has(next as HTMLElement)) {
36+
group.push(next);
37+
handled.add(next);
38+
next = next.nextElementSibling;
39+
}
40+
41+
const ul = orphan.ownerDocument.createElement("ul");
42+
orphan.parentNode!.insertBefore(ul, orphan);
43+
for (const li of group) {
44+
ul.appendChild(li);
45+
}
46+
}
47+
}
48+
949
/**
1050
* Step 1, Turns:
1151
*
@@ -117,6 +157,7 @@ export function nestedListsToBlockNoteStructure(
117157
element.innerHTML = elementOrHTML;
118158
elementOrHTML = element;
119159
}
160+
wrapOrphanListItems(elementOrHTML);
120161
liftNestedListsToParent(elementOrHTML);
121162
createGroups(elementOrHTML);
122163
return elementOrHTML;
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import { TextSelection } from "@tiptap/pm/state";
2+
import { describe, expect, it } from "vitest";
3+
4+
import { BlockNoteEditor } from "./BlockNoteEditor.js";
5+
6+
/**
7+
* @vitest-environment jsdom
8+
*/
9+
10+
function mountEditor(editor: BlockNoteEditor<any, any, any>) {
11+
editor.mount(document.createElement("div"));
12+
}
13+
14+
function selectStartOfFirstBlock(editor: BlockNoteEditor) {
15+
editor.transact((tr) => {
16+
let pos: number | undefined;
17+
tr.doc.descendants((node, nodePos) => {
18+
if (node.type.spec.group === "blockContent") {
19+
pos = nodePos + 1;
20+
return false;
21+
}
22+
return pos === undefined;
23+
});
24+
tr.setSelection(TextSelection.create(tr.doc, pos!));
25+
});
26+
}
27+
28+
describe("paste into empty inline-content block", () => {
29+
it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)(
30+
"pastes a paragraph into an empty %s without replacing it",
31+
(type) => {
32+
const editor = BlockNoteEditor.create({
33+
initialContent: [{ type, content: "" }],
34+
});
35+
mountEditor(editor);
36+
selectStartOfFirstBlock(editor);
37+
38+
editor.pasteHTML(`<p>Pasted</p>`);
39+
40+
const blocks = editor.document;
41+
expect(blocks[0].type).toBe(type);
42+
expect(blocks[0].content).toEqual([
43+
{ type: "text", text: "Pasted", styles: {} },
44+
]);
45+
},
46+
);
47+
48+
it("inserts paragraph content into an empty list item without dropping marks", () => {
49+
const editor = BlockNoteEditor.create({
50+
initialContent: [{ type: "bulletListItem", content: "" }],
51+
});
52+
mountEditor(editor);
53+
selectStartOfFirstBlock(editor);
54+
55+
editor.pasteHTML(`<p>Hello <strong>world</strong></p>`);
56+
57+
const blocks = editor.document;
58+
expect(blocks[0].type).toBe("bulletListItem");
59+
expect(blocks[0].content).toEqual([
60+
{ type: "text", text: "Hello ", styles: {} },
61+
{ type: "text", text: "world", styles: { bold: true } },
62+
]);
63+
});
64+
65+
it("merges leading paragraph into empty list item and inserts rest as siblings", () => {
66+
const editor = BlockNoteEditor.create({
67+
initialContent: [{ type: "bulletListItem", content: "" }],
68+
});
69+
mountEditor(editor);
70+
selectStartOfFirstBlock(editor);
71+
72+
editor.pasteHTML(`<p>First</p><p>Second</p>`);
73+
74+
const blocks = editor.document;
75+
expect(blocks[0].type).toBe("bulletListItem");
76+
expect(blocks[0].content).toEqual([
77+
{ type: "text", text: "First", styles: {} },
78+
]);
79+
expect(blocks[1].type).toBe("paragraph");
80+
expect(blocks[1].content).toEqual([
81+
{ type: "text", text: "Second", styles: {} },
82+
]);
83+
});
84+
85+
it("replaces an empty list item with a heading when pasting a heading", () => {
86+
const editor = BlockNoteEditor.create({
87+
initialContent: [{ type: "bulletListItem", content: "" }],
88+
});
89+
mountEditor(editor);
90+
selectStartOfFirstBlock(editor);
91+
92+
editor.pasteHTML(`<h1>Heading</h1>`);
93+
94+
// The empty list item is replaced by the heading rather than absorbing
95+
// its inline content. Headings carry semantic meaning the user explicitly
96+
// chose, so we keep them as-is.
97+
const blocks = editor.document;
98+
expect(blocks[0].type).toBe("heading");
99+
expect(blocks[0].content).toEqual([
100+
{ type: "text", text: "Heading", styles: {} },
101+
]);
102+
});
103+
104+
it("keeps the list item but discards the heading wrapper when a paragraph follows the heading", () => {
105+
const editor = BlockNoteEditor.create({
106+
initialContent: [{ type: "bulletListItem", content: "" }],
107+
});
108+
mountEditor(editor);
109+
selectStartOfFirstBlock(editor);
110+
111+
// Heading first means the leading block is not a paragraph, so the
112+
// unwrap/retype rule doesn't apply: the empty list item gets replaced
113+
// by the pasted heading and the trailing paragraph follows as a sibling.
114+
editor.pasteHTML(`<h1>Heading</h1><p>Body</p>`);
115+
116+
const blocks = editor.document;
117+
expect(blocks[0].type).toBe("heading");
118+
expect(blocks[0].content).toEqual([
119+
{ type: "text", text: "Heading", styles: {} },
120+
]);
121+
expect(blocks[1].type).toBe("paragraph");
122+
expect(blocks[1].content).toEqual([
123+
{ type: "text", text: "Body", styles: {} },
124+
]);
125+
});
126+
127+
it("still replaces the empty list item when pasting another list item", () => {
128+
const editor = BlockNoteEditor.create({
129+
initialContent: [{ type: "bulletListItem", content: "" }],
130+
});
131+
mountEditor(editor);
132+
selectStartOfFirstBlock(editor);
133+
134+
editor.pasteHTML(`<ul><li>Pasted item</li></ul>`);
135+
136+
const blocks = editor.document;
137+
expect(blocks[0].type).toBe("bulletListItem");
138+
// The empty list item should be replaced (not have inline content
139+
// appended in-place), which matches the existing behavior.
140+
expect(blocks[0].content).toEqual([
141+
{ type: "text", text: "Pasted item", styles: {} },
142+
]);
143+
});
144+
145+
it("pastes a paragraph into a non-empty list item without replacing it", () => {
146+
const editor = BlockNoteEditor.create({
147+
initialContent: [{ type: "bulletListItem", content: "abc" }],
148+
});
149+
mountEditor(editor);
150+
editor.setTextCursorPosition(editor.document[0].id, "end");
151+
152+
editor.pasteHTML(`<p>hello</p>`);
153+
154+
const blocks = editor.document;
155+
expect(blocks[0].type).toBe("bulletListItem");
156+
expect(blocks[0].content).toEqual([
157+
{ type: "text", text: "abchello", styles: {} },
158+
]);
159+
});
160+
161+
it("pastes bare <li>a</li><li>b</li> into an empty list item as two list items", () => {
162+
const editor = BlockNoteEditor.create({
163+
initialContent: [{ type: "bulletListItem", content: "" }],
164+
});
165+
mountEditor(editor);
166+
selectStartOfFirstBlock(editor);
167+
168+
editor.pasteHTML(`<li>a</li><li>b</li>`);
169+
170+
const blocks = editor.document;
171+
expect(blocks[0].type).toBe("bulletListItem");
172+
expect(blocks[0].content).toEqual([
173+
{ type: "text", text: "a", styles: {} },
174+
]);
175+
expect(blocks[1].type).toBe("bulletListItem");
176+
expect(blocks[1].content).toEqual([
177+
{ type: "text", text: "b", styles: {} },
178+
]);
179+
});
180+
181+
it("pastes bare <li>a</li><li>b</li> into a non-empty list item, splicing the first into the cursor", () => {
182+
const editor = BlockNoteEditor.create({
183+
initialContent: [{ type: "bulletListItem", content: "X" }],
184+
});
185+
mountEditor(editor);
186+
editor.setTextCursorPosition(editor.document[0].id, "end");
187+
188+
editor.pasteHTML(`<li>a</li><li>b</li>`);
189+
190+
const blocks = editor.document;
191+
expect(blocks[0].type).toBe("bulletListItem");
192+
expect(blocks[0].content).toEqual([
193+
{ type: "text", text: "Xa", styles: {} },
194+
]);
195+
expect(blocks[1].type).toBe("bulletListItem");
196+
expect(blocks[1].content).toEqual([
197+
{ type: "text", text: "b", styles: {} },
198+
]);
199+
});
200+
201+
it("pastes two list items into an empty list item as two siblings", () => {
202+
const editor = BlockNoteEditor.create({
203+
initialContent: [{ type: "bulletListItem", content: "" }],
204+
});
205+
mountEditor(editor);
206+
selectStartOfFirstBlock(editor);
207+
208+
editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`);
209+
210+
// The empty list item is replaced by the two pasted list items.
211+
const blocks = editor.document;
212+
expect(blocks[0].type).toBe("bulletListItem");
213+
expect(blocks[0].content).toEqual([
214+
{ type: "text", text: "a", styles: {} },
215+
]);
216+
expect(blocks[1].type).toBe("bulletListItem");
217+
expect(blocks[1].content).toEqual([
218+
{ type: "text", text: "b", styles: {} },
219+
]);
220+
});
221+
222+
it("pastes two list items into a non-empty list item, splicing the first into the cursor", () => {
223+
const editor = BlockNoteEditor.create({
224+
initialContent: [{ type: "bulletListItem", content: "X" }],
225+
});
226+
mountEditor(editor);
227+
editor.setTextCursorPosition(editor.document[0].id, "end");
228+
229+
editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`);
230+
231+
// The first list item's inline content is spliced at the cursor (the
232+
// existing list item becomes "Xa") and the second list item becomes a
233+
// new sibling.
234+
const blocks = editor.document;
235+
expect(blocks[0].type).toBe("bulletListItem");
236+
expect(blocks[0].content).toEqual([
237+
{ type: "text", text: "Xa", styles: {} },
238+
]);
239+
expect(blocks[1].type).toBe("bulletListItem");
240+
expect(blocks[1].content).toEqual([
241+
{ type: "text", text: "b", styles: {} },
242+
]);
243+
});
244+
245+
it("preserves nested list structure when pasting a nested list into an empty list item", () => {
246+
const editor = BlockNoteEditor.create({
247+
initialContent: [{ type: "bulletListItem", content: "" }],
248+
});
249+
mountEditor(editor);
250+
selectStartOfFirstBlock(editor);
251+
252+
// Pasting a list with nested children: the leading block is a list item,
253+
// so the unwrap/retype rule does not apply and the existing slice-level
254+
// list-nesting fix in `transformPasted` keeps the nested structure
255+
// intact.
256+
editor.pasteHTML(`<ul><li>Outer<ul><li>Inner</li></ul></li></ul>`);
257+
258+
const blocks = editor.document;
259+
expect(blocks[0].type).toBe("bulletListItem");
260+
expect(blocks[0].content).toEqual([
261+
{ type: "text", text: "Outer", styles: {} },
262+
]);
263+
expect(blocks[0].children).toHaveLength(1);
264+
expect(blocks[0].children[0].type).toBe("bulletListItem");
265+
expect(blocks[0].children[0].content).toEqual([
266+
{ type: "text", text: "Inner", styles: {} },
267+
]);
268+
});
269+
270+
it("preserves the existing paragraph behavior when pasting into a non-empty paragraph", () => {
271+
const editor = BlockNoteEditor.create({
272+
initialContent: [{ type: "paragraph", content: "Existing " }],
273+
});
274+
mountEditor(editor);
275+
editor.setTextCursorPosition(editor.document[0].id, "end");
276+
277+
editor.pasteHTML(`<p>added</p>`);
278+
279+
const blocks = editor.document;
280+
expect(blocks[0].type).toBe("paragraph");
281+
expect(blocks[0].content).toEqual([
282+
{ type: "text", text: "Existing added", styles: {} },
283+
]);
284+
});
285+
});

0 commit comments

Comments
 (0)