diff --git a/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts b/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts
index 8a200b4dab..858edfab19 100644
--- a/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/getListItemContent.ts
@@ -1,18 +1,24 @@
import { DOMParser, Fragment, Schema } from "prosemirror-model";
/**
- * This function is used to parse the content of a list item external HTML node.
+ * This function is used to parse the content of a list item external HTML
+ * node. Because the HTML spec supports having block-level elements in `li`
+ * elements, but BlockNote only supports inline content within list items, we
+ * merge the inline content from all elements within the `li` element.
*
- * Due to a change in how prosemirror-model handles parsing elements, we have additional flexibility in how we can "fit" content into a list item.
+ * Ideally, we would instead parse any block-level elements within the `li` as
+ * nested blocks of the list item. In fact, this is what we were previously
+ * doing, see:
+ * https://github.com/TypeCellOS/BlockNote/pull/1661
*
- * We've decided to take an approach that is similar to Notion. The core rules of the algorithm are:
+ * However, this solution failed edge cases, namely when multiple `li` elements
+ * with multiple block-level elements would be consecutively parsed. An example
+ * case of this can be found here:
+ * `tests/src/unit/core/formatConversion/parse/multipleQuoteListItems.json`
*
- * - If the first child of an `li` has ONLY text content, take the text content, and flatten it into the list item. Subsequent siblings are carried over as is, as children of the list item.
- * - e.g. `
Hello
World
->
Hello
World
`
- * - Else, take the content and insert it as children instead.
- * - e.g. `
->
`
- *
- * This ensures that a list item's content is always valid ProseMirror content. Smoothing over differences between how external HTML may be rendered, and how ProseMirror expects content to be structured.
+ * It turns out that fixing these edge cases requires a different approach, and
+ * a detailed write-up regarding this can be found here:
+ * https://linear.app/blocknote/issue/ee0c4bde-341f-4773-8694-336e65e4a686
*/
export function getListItemContent(
/**
@@ -23,30 +29,17 @@ export function getListItemContent(
* The schema to use for parsing.
*/
schema: Schema,
- /**
- * The name of the list item node.
- */
- name: string,
): Fragment {
- /**
- * To actually implement this algorithm, we need to leverage ProseMirror's "fitting" algorithm.
- * Where, if content is parsed which doesn't fit into the current node, it will be moved into the parent node.
- *
- * This allows us to parse multiple pieces of content from within the list item (even though it normally would not match the list item's schema) and "throw" the excess content into the list item's children.
- *
- * The expected return value is a `Fragment` which contains the list item's content as the first element, and the children wrapped in a blockGroup node. Like so:
- * ```
- * Fragment<[Node, Node>>>]>
- * ```
- */
const parser = DOMParser.fromSchema(schema);
- // TODO: This will be unnecessary in the future: https://github.com/ProseMirror/prosemirror-model/commit/166188d4f9db96eb86fb7de62e72049c86c9dd79
+ // TODO: This will be unnecessary in the future:
+ // https://github.com/ProseMirror/prosemirror-model/commit/166188d4f9db96eb86fb7de62e72049c86c9dd79
const node = _node as HTMLElement;
- // Move the `li` element's content into a new `div` element
- // This is a hacky workaround to not re-trigger list item parsing,
- // when we are looking to understand what the list item's content actually is, in terms of the schema.
+ // Move the `li` element's content into a new `div` element. This is a hacky
+ // workaround to not re-trigger list item parsing, when we are looking to
+ // understand what the list item's content actually is, in terms of the
+ // schema.
const clonedNodeDiv = document.createElement("div");
// Mark the `div` element as a `blockGroup` to make the parsing easier.
clonedNodeDiv.setAttribute("data-node-type", "blockGroup");
@@ -55,61 +48,21 @@ export function getListItemContent(
clonedNodeDiv.appendChild(child.cloneNode(true));
}
- // Parses children of the `li` element into a `blockGroup` with `blockContainer` node children
- // This is the structure of list item children, so parsing into this structure allows for
- // easy separation of list item content from child list item content.
- let blockGroupNode = parser.parse(clonedNodeDiv, {
+ // Parses children of the `li` element into a `blockGroup` with
+ // `blockContainer` node children.
+ const blockGroupNode = parser.parse(clonedNodeDiv, {
topNode: schema.nodes.blockGroup.create(),
});
- // There is an edge case where a list item's content may contain a `` element.
- // Causing it to be recognized as a `checkListItem`.
- // We want to skip this, and just parse the list item's content as is.
- if (blockGroupNode.firstChild?.firstChild?.type.name === "checkListItem") {
- // We skip the first child, by cutting it out of the `blockGroup` node.
- // and continuing with the rest of the algorithm.
- blockGroupNode = blockGroupNode.copy(
- blockGroupNode.content.cut(
- blockGroupNode.firstChild.firstChild.nodeSize + 2,
- ),
- );
- }
-
- // Structure above is `blockGroup[]>`
- // We want to extract the first `blockContainer` node's content, and see if it is a text block.
- const listItemsFirstChild = blockGroupNode.firstChild?.firstChild;
-
- // If the first node is not a text block, then it's first child is not compatible with the list item node.
- if (!listItemsFirstChild?.isTextblock) {
- // So, we do not try inserting anything into the list item, and instead return anything we found as children for the list item.
- return Fragment.from(blockGroupNode);
- }
-
- // If it is a text block, then we know it only contains text content.
- // So, we extract it, and insert its content into the `listItemNode`.
- // The remaining nodes in the `blockGroup` stay in-place.
- const listItemNode = schema.nodes[name].create(
- {},
- listItemsFirstChild.content,
+ // Merges the inline content of all `blockContainer` nodes parsed.
+ let listItemMergedContent = Fragment.from(
+ blockGroupNode.firstChild?.firstChild?.content,
);
-
- // We have `blockGroup[]>`
- // We want to extract out the rest of the nodes as `<...blockContainer[]>`
- const remainingListItemChildren = blockGroupNode.content.cut(
- // +2 for the `blockGroup` node's start and end markers
- listItemsFirstChild.nodeSize + 2,
- );
- const hasRemainingListItemChildren = remainingListItemChildren.size > 0;
-
- if (hasRemainingListItemChildren) {
- // Copy the remaining list item children back into the `blockGroup` node.
- // This will make it back into: `blockGroup<...blockContainer[]>`
- const listItemsChildren = blockGroupNode.copy(remainingListItemChildren);
-
- // Return the `listItem` node's content, then add the parsed children after to be lifted out by ProseMirror "fitting" algorithm.
- return listItemNode.content.addToEnd(listItemsChildren);
+ for (let i = 1; i < blockGroupNode.childCount; i++) {
+ listItemMergedContent = listItemMergedContent
+ .append(Fragment.from(schema.nodes["hardBreak"].create()))
+ .append(blockGroupNode.child(i).firstChild!.content);
}
- // Otherwise, just return the `listItem` node's content.
- return listItemNode.content;
+ return listItemMergedContent;
}
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/headingParagraphListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/headingParagraphListItem.json
index 360bf1a698..a7d6fc6b49 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/headingParagraphListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/headingParagraphListItem.json
@@ -1,28 +1,11 @@
[
{
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Bullet List Item",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Bullet List Item",
+ "text": "Bullet List Item
+Bullet List Item",
"type": "text",
},
],
@@ -43,7 +26,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithParagraphListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithParagraphListItem.json
index 3f15f2ced7..127e676297 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithParagraphListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithParagraphListItem.json
@@ -1,39 +1,14 @@
[
{
- "children": [
- {
- "children": [],
- "content": undefined,
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "caption": "",
- "name": "",
- "showPreview": true,
- "textAlignment": "left",
- "url": "http://localhost:3000/exampleURL",
- },
- "type": "image",
- },
+ "children": [],
+ "content": [
{
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Bullet List Item",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
+ "styles": {},
+ "text": "
+Bullet List Item",
+ "type": "text",
},
],
- "content": [],
"id": "1",
"props": {
"backgroundColor": "default",
@@ -51,7 +26,7 @@
"type": "text",
},
],
- "id": "4",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithTextListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithTextListItem.json
index 090daf84e0..bc3008c332 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithTextListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/imageWithTextListItem.json
@@ -1,39 +1,14 @@
[
{
- "children": [
- {
- "children": [],
- "content": undefined,
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "caption": "",
- "name": "",
- "showPreview": true,
- "textAlignment": "left",
- "url": "http://localhost:3000/exampleURL",
- },
- "type": "image",
- },
+ "children": [],
+ "content": [
{
- "children": [],
- "content": [
- {
- "styles": {},
- "text": " Bullet List Item",
- "type": "text",
- },
- ],
- "id": "3",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
+ "styles": {},
+ "text": "
+ Bullet List Item",
+ "type": "text",
},
],
- "content": [],
"id": "1",
"props": {
"backgroundColor": "default",
@@ -51,7 +26,7 @@
"type": "text",
},
],
- "id": "4",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleParagraphListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleParagraphListItem.json
index 360bf1a698..a7d6fc6b49 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleParagraphListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleParagraphListItem.json
@@ -1,28 +1,11 @@
[
{
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Bullet List Item",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "paragraph",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Bullet List Item",
+ "text": "Bullet List Item
+Bullet List Item",
"type": "text",
},
],
@@ -43,7 +26,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleQuoteListItems.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleQuoteListItems.json
new file mode 100644
index 0000000000..1a670071de
--- /dev/null
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/multipleQuoteListItems.json
@@ -0,0 +1,56 @@
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Bullet List Item 1
+Quote 1",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "bulletListItem",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Bullet List Item 2
+Quote 2",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "bulletListItem",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Bullet List Item 3
+Quote 3",
+ "type": "text",
+ },
+ ],
+ "id": "3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "bulletListItem",
+ },
+]
\ No newline at end of file
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphHeadingListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphHeadingListItem.json
index fd13958db6..a7d6fc6b49 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphHeadingListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphHeadingListItem.json
@@ -1,30 +1,11 @@
[
{
- "children": [
- {
- "children": [],
- "content": [
- {
- "styles": {},
- "text": "Bullet List Item",
- "type": "text",
- },
- ],
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "isToggleable": false,
- "level": 1,
- "textAlignment": "left",
- "textColor": "default",
- },
- "type": "heading",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Bullet List Item",
+ "text": "Bullet List Item
+Bullet List Item",
"type": "text",
},
],
@@ -45,7 +26,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphWithImageListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphWithImageListItem.json
index 5b9be57500..38dc551e6e 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphWithImageListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/paragraphWithImageListItem.json
@@ -1,25 +1,11 @@
[
{
- "children": [
- {
- "children": [],
- "content": undefined,
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "caption": "",
- "name": "",
- "showPreview": true,
- "textAlignment": "left",
- "url": "http://localhost:3000/exampleURL",
- },
- "type": "image",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Bullet List Item",
+ "text": "Bullet List Item
+",
"type": "text",
},
],
@@ -40,7 +26,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/styledTextWithImageListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/styledTextWithImageListItem.json
index 7e87d012cc..cfea39c04e 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/styledTextWithImageListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/styledTextWithImageListItem.json
@@ -1,21 +1,6 @@
[
{
- "children": [
- {
- "children": [],
- "content": undefined,
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "caption": "",
- "name": "",
- "showPreview": true,
- "textAlignment": "left",
- "url": "http://localhost:3000/exampleURL",
- },
- "type": "image",
- },
- ],
+ "children": [],
"content": [
{
"styles": {
@@ -26,7 +11,8 @@
},
{
"styles": {},
- "text": " Bullet List Item",
+ "text": " Bullet List Item
+",
"type": "text",
},
],
@@ -47,7 +33,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/textWithImageListItem.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/textWithImageListItem.json
index 5b9be57500..38dc551e6e 100644
--- a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/textWithImageListItem.json
+++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/textWithImageListItem.json
@@ -1,25 +1,11 @@
[
{
- "children": [
- {
- "children": [],
- "content": undefined,
- "id": "2",
- "props": {
- "backgroundColor": "default",
- "caption": "",
- "name": "",
- "showPreview": true,
- "textAlignment": "left",
- "url": "http://localhost:3000/exampleURL",
- },
- "type": "image",
- },
- ],
+ "children": [],
"content": [
{
"styles": {},
- "text": "Bullet List Item",
+ "text": "Bullet List Item
+",
"type": "text",
},
],
@@ -40,7 +26,7 @@
"type": "text",
},
],
- "id": "3",
+ "id": "2",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
index 26258e6124..3bb60a7897 100644
--- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
+++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts
@@ -385,6 +385,26 @@ export const parseTestInstancesHTML: TestInstance<