Skip to content

Commit 932e3ab

Browse files
authored
fix(core): preserve list item type when pasting into empty list items (#2722)
* 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. * fix(core): type orphan list item set as Set<Element> for tsc strict check * fix(core): address review feedback on paste/parse fixes - nestedLists: walk siblings via nextSibling so meaningful (non-whitespace) text between bare <li>s prevents them from being merged into one <ul>. Whitespace text nodes still bridge consecutive orphans. - transformPasted: bail out of retypeLeadingParagraphForEmptyTarget during drop events (view.dragging is set), since the slice is inserted at the drop point rather than the current selection. * fix(core): preserve list item type when pasting into empty inline-content blocks Match orphan `<li>` (no `<ul>`/`<ol>` ancestor) as a `bulletListItem` in the parse rule, so pasting bare `<li>a</li><li>b</li>` HTML produces two list items instead of falling through to paragraphs. Replaces the previous `wrapOrphanListItems` HTML preprocessing step.
1 parent a40b2ea commit 932e3ab

4 files changed

Lines changed: 371 additions & 6 deletions

File tree

packages/core/src/blocks/ListItem/BulletListItem/block.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,20 @@ export const createBulletListItemBlockSpec = createBlockSpec(
3737

3838
const parent = element.parentElement;
3939

40-
if (parent === null) {
41-
return undefined;
42-
}
43-
4440
if (
41+
parent === null ||
4542
parent.tagName === "UL" ||
4643
(parent.tagName === "DIV" && parent.parentElement?.tagName === "UL")
4744
) {
4845
return parseDefaultProps(element);
4946
}
5047

48+
// Orphan `<li>` (no <ul>/<ol> ancestor) — match as bulletListItem so
49+
// pasting bare `<li>` HTML doesn't fall back to a paragraph.
50+
if (!element.closest("ul, ol")) {
51+
return parseDefaultProps(element);
52+
}
53+
5154
return undefined;
5255
},
5356
// As `li` elements can contain multiple paragraphs, we need to merge their contents
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)