Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Lift nested lists > Leaves <li>s already inside a <ul> alone 1`] = `"<ul><li>existing</li></ul><ul><li>orphan</li></ul>"`;

exports[`Lift nested lists > Lifts multiple bullet lists 1`] = `
"<ul>
<div><li>
Expand Down Expand Up @@ -142,3 +144,11 @@ exports[`Lift nested lists > Lifts nested numbered lists 1`] = `
</li>
</ol>"
`;

exports[`Lift nested lists > Wraps a single bare <li> in a <ul> 1`] = `"<ul><li>only</li></ul>"`;

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>"`;

exports[`Lift nested lists > Wraps consecutive bare <li> elements in a <ul> 1`] = `"<ul><li>a</li><li>b</li></ul>"`;

exports[`Lift nested lists > Wraps nested orphan <li>s as inner lists 1`] = `"<ul><li>outer</li><li>inner</li></ul>"`;
25 changes: 25 additions & 0 deletions packages/core/src/api/parsers/html/util/nestedLists.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,31 @@ describe("Lift nested lists", () => {
await testHTML(html);
});

it("Wraps consecutive bare <li> elements in a <ul>", async () => {
const html = `<li>a</li><li>b</li>`;
await testHTML(html);
});

it("Wraps a single bare <li> in a <ul>", async () => {
const html = `<li>only</li>`;
await testHTML(html);
});

it("Wraps bare <li>s mixed with other top-level content", async () => {
const html = `<p>before</p><li>x</li><li>y</li><p>after</p>`;
await testHTML(html);
});

it("Leaves <li>s already inside a <ul> alone", async () => {
const html = `<ul><li>existing</li></ul><li>orphan</li>`;
await testHTML(html);
});

it("Wraps nested orphan <li>s as inner lists", async () => {
const html = `<li>outer<li>inner</li></li>`;
await testHTML(html);
});

it("Lifts nested mixed lists", async () => {
const html = `<ol>
<li>
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/api/parsers/html/util/nestedLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ function isWhitespaceNode(node: Node) {
return node.nodeType === 3 && !/\S/.test(node.nodeValue || "");
}

/**
* Step 0, wraps any `<li>` element that is not inside a `<ul>`/`<ol>` in a
* fresh `<ul>` so the existing parse rules (which require an `<ul>`/`<ol>`
* parent) match. Consecutive orphan `<li>` siblings are grouped under a
* single `<ul>`.
*
* Without this, pasting bare `<li>a</li><li>b</li>` HTML would parse as two
* paragraphs because the BulletListItem parse rule only matches `<li>`
* whose parent is `<ul>`.
*/
function wrapOrphanListItems(element: HTMLElement) {
const orphans = Array.from(element.querySelectorAll("li")).filter(
(li) => li.closest("ul, ol") === null,
);
const orphanSet = new Set(orphans);
const handled = new Set<Element>();

for (const orphan of orphans) {
if (handled.has(orphan)) {
continue;
}

const group: Element[] = [orphan];
handled.add(orphan);

let next = orphan.nextElementSibling;
while (next && next.tagName === "LI" && orphanSet.has(next as HTMLElement)) {
group.push(next);
handled.add(next);
next = next.nextElementSibling;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

const ul = orphan.ownerDocument.createElement("ul");
orphan.parentNode!.insertBefore(ul, orphan);
for (const li of group) {
ul.appendChild(li);
}
}
}

/**
* Step 1, Turns:
*
Expand Down Expand Up @@ -117,6 +157,7 @@ export function nestedListsToBlockNoteStructure(
element.innerHTML = elementOrHTML;
elementOrHTML = element;
}
wrapOrphanListItems(elementOrHTML);
liftNestedListsToParent(elementOrHTML);
createGroups(elementOrHTML);
return elementOrHTML;
Expand Down
285 changes: 285 additions & 0 deletions packages/core/src/editor/transformPasted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { TextSelection } from "@tiptap/pm/state";
import { describe, expect, it } from "vitest";

import { BlockNoteEditor } from "./BlockNoteEditor.js";

/**
* @vitest-environment jsdom
*/

function mountEditor(editor: BlockNoteEditor<any, any, any>) {
editor.mount(document.createElement("div"));
}

function selectStartOfFirstBlock(editor: BlockNoteEditor) {
editor.transact((tr) => {
let pos: number | undefined;
tr.doc.descendants((node, nodePos) => {
if (node.type.spec.group === "blockContent") {
pos = nodePos + 1;
return false;
}
return pos === undefined;
});
tr.setSelection(TextSelection.create(tr.doc, pos!));
});
}

describe("paste into empty inline-content block", () => {
it.each(["bulletListItem", "numberedListItem", "checkListItem"] as const)(
"pastes a paragraph into an empty %s without replacing it",
(type) => {
const editor = BlockNoteEditor.create({
initialContent: [{ type, content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<p>Pasted</p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe(type);
expect(blocks[0].content).toEqual([
{ type: "text", text: "Pasted", styles: {} },
]);
},
);

it("inserts paragraph content into an empty list item without dropping marks", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<p>Hello <strong>world</strong></p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Hello ", styles: {} },
{ type: "text", text: "world", styles: { bold: true } },
]);
});

it("merges leading paragraph into empty list item and inserts rest as siblings", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<p>First</p><p>Second</p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "First", styles: {} },
]);
expect(blocks[1].type).toBe("paragraph");
expect(blocks[1].content).toEqual([
{ type: "text", text: "Second", styles: {} },
]);
});

it("replaces an empty list item with a heading when pasting a heading", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<h1>Heading</h1>`);

// The empty list item is replaced by the heading rather than absorbing
// its inline content. Headings carry semantic meaning the user explicitly
// chose, so we keep them as-is.
const blocks = editor.document;
expect(blocks[0].type).toBe("heading");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Heading", styles: {} },
]);
});

it("keeps the list item but discards the heading wrapper when a paragraph follows the heading", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

// Heading first means the leading block is not a paragraph, so the
// unwrap/retype rule doesn't apply: the empty list item gets replaced
// by the pasted heading and the trailing paragraph follows as a sibling.
editor.pasteHTML(`<h1>Heading</h1><p>Body</p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("heading");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Heading", styles: {} },
]);
expect(blocks[1].type).toBe("paragraph");
expect(blocks[1].content).toEqual([
{ type: "text", text: "Body", styles: {} },
]);
});

it("still replaces the empty list item when pasting another list item", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<ul><li>Pasted item</li></ul>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
// The empty list item should be replaced (not have inline content
// appended in-place), which matches the existing behavior.
expect(blocks[0].content).toEqual([
{ type: "text", text: "Pasted item", styles: {} },
]);
});

it("pastes a paragraph into a non-empty list item without replacing it", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "abc" }],
});
mountEditor(editor);
editor.setTextCursorPosition(editor.document[0].id, "end");

editor.pasteHTML(`<p>hello</p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "abchello", styles: {} },
]);
});

it("pastes bare <li>a</li><li>b</li> into an empty list item as two list items", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<li>a</li><li>b</li>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "a", styles: {} },
]);
expect(blocks[1].type).toBe("bulletListItem");
expect(blocks[1].content).toEqual([
{ type: "text", text: "b", styles: {} },
]);
});

it("pastes bare <li>a</li><li>b</li> into a non-empty list item, splicing the first into the cursor", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "X" }],
});
mountEditor(editor);
editor.setTextCursorPosition(editor.document[0].id, "end");

editor.pasteHTML(`<li>a</li><li>b</li>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Xa", styles: {} },
]);
expect(blocks[1].type).toBe("bulletListItem");
expect(blocks[1].content).toEqual([
{ type: "text", text: "b", styles: {} },
]);
});

it("pastes two list items into an empty list item as two siblings", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`);

// The empty list item is replaced by the two pasted list items.
const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "a", styles: {} },
]);
expect(blocks[1].type).toBe("bulletListItem");
expect(blocks[1].content).toEqual([
{ type: "text", text: "b", styles: {} },
]);
});

it("pastes two list items into a non-empty list item, splicing the first into the cursor", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "X" }],
});
mountEditor(editor);
editor.setTextCursorPosition(editor.document[0].id, "end");

editor.pasteHTML(`<ul><li>a</li><li>b</li></ul>`);

// The first list item's inline content is spliced at the cursor (the
// existing list item becomes "Xa") and the second list item becomes a
// new sibling.
const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Xa", styles: {} },
]);
expect(blocks[1].type).toBe("bulletListItem");
expect(blocks[1].content).toEqual([
{ type: "text", text: "b", styles: {} },
]);
});

it("preserves nested list structure when pasting a nested list into an empty list item", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "bulletListItem", content: "" }],
});
mountEditor(editor);
selectStartOfFirstBlock(editor);

// Pasting a list with nested children: the leading block is a list item,
// so the unwrap/retype rule does not apply and the existing slice-level
// list-nesting fix in `transformPasted` keeps the nested structure
// intact.
editor.pasteHTML(`<ul><li>Outer<ul><li>Inner</li></ul></li></ul>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("bulletListItem");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Outer", styles: {} },
]);
expect(blocks[0].children).toHaveLength(1);
expect(blocks[0].children[0].type).toBe("bulletListItem");
expect(blocks[0].children[0].content).toEqual([
{ type: "text", text: "Inner", styles: {} },
]);
});

it("preserves the existing paragraph behavior when pasting into a non-empty paragraph", () => {
const editor = BlockNoteEditor.create({
initialContent: [{ type: "paragraph", content: "Existing " }],
});
mountEditor(editor);
editor.setTextCursorPosition(editor.document[0].id, "end");

editor.pasteHTML(`<p>added</p>`);

const blocks = editor.document;
expect(blocks[0].type).toBe("paragraph");
expect(blocks[0].content).toEqual([
{ type: "text", text: "Existing added", styles: {} },
]);
});
});
Loading
Loading