Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Lift nested lists > Does not merge <li> nodes separated by non-whitespace text 1`] = `"<ul><li>a</li></ul>text<ul><li>b</li></ul>"`;

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 +146,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>"`;
30 changes: 30 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,36 @@ 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("Does not merge <li> nodes separated by non-whitespace text", async () => {
const html = `<li>a</li>text<li>b</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
56 changes: 56 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,61 @@ 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: Set<Element> = 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);

// Walk siblings via nextSibling (not nextElementSibling) so we can stop
// at meaningful text between orphans — only whitespace text is allowed
// to bridge two orphan <li>s into the same <ul>.
let next: Node | null = orphan.nextSibling;
while (next) {
if (isWhitespaceNode(next)) {
next = next.nextSibling;
continue;
}
if (
next.nodeType === 1 &&
(next as Element).tagName === "LI" &&
orphanSet.has(next as Element)
) {
group.push(next as Element);
handled.add(next as Element);
next = next.nextSibling;
continue;
}
break;
}

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 +172,7 @@ export function nestedListsToBlockNoteStructure(
element.innerHTML = elementOrHTML;
elementOrHTML = element;
}
wrapOrphanListItems(elementOrHTML);
liftNestedListsToParent(elementOrHTML);
createGroups(elementOrHTML);
return elementOrHTML;
Expand Down
Loading
Loading