Skip to content

Commit e870540

Browse files
fix: HTML export/parse round trip ignoring empty blocks (BLO-873) (#2931)
* Fixed round trip `blocksToHTMLLossy` -> `tryParseHTMLToBlocks` ignoring empty blocks * Replaced non-breaking white space with Unicode object replacement and fixed tests
1 parent 3d08064 commit e870540

13 files changed

Lines changed: 267 additions & 20 deletions

File tree

‎packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts‎

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ import {
1515
} from "../../../nodeConversions/blockToNode.js";
1616
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";
1717

18+
/**
19+
* Placeholder character inserted into empty inline-content blocks when
20+
* exporting to external HTML. An empty block serializes to an element with no
21+
* children (e.g. `<p></p>`), which is dropped when the HTML is parsed back into
22+
* blocks. Filling it with this character keeps the block alive through the
23+
* round trip, and the parser strips the character again so the block ends up
24+
* empty (see `HTMLToBlocks`).
25+
*
26+
* The Unicode object replacement character (U+FFFC) is used because it's a
27+
* reserved placeholder that users don't type, so it can be safely removed on
28+
* import without discarding legitimate content (unlike a non-breaking space).
29+
*/
30+
export const EMPTY_BLOCK_PLACEHOLDER = "";
31+
1832
function addAttributesAndRemoveClasses(element: HTMLElement) {
1933
// Removes all BlockNote specific class names.
2034
const className =
@@ -265,15 +279,35 @@ function serializeBlock<
265279
}
266280
}
267281

268-
if (ret.contentDOM && block.content) {
269-
const ic = serializeInlineContentExternalHTML(
270-
editor,
271-
block.content as any, // TODO
272-
serializer,
273-
{ ...options, blockType: block.type },
274-
);
282+
if (ret.contentDOM) {
283+
if (block.content) {
284+
const ic = serializeInlineContentExternalHTML(
285+
editor,
286+
block.content as any, // TODO
287+
serializer,
288+
{ ...options, blockType: block.type },
289+
);
290+
291+
ret.contentDOM.appendChild(ic);
292+
}
275293

276-
ret.contentDOM.appendChild(ic);
294+
// Blocks with empty inline content (e.g. an empty paragraph) serialize to
295+
// an element with no children (e.g. `<p></p>`), which is ignored when the
296+
// HTML is parsed back into blocks. To make these blocks survive such a
297+
// round trip, we fill their content with a placeholder character that the
298+
// parser strips out again (see `EMPTY_BLOCK_PLACEHOLDER`).
299+
//
300+
// Only applies to blocks that hold inline content: containers (columns,
301+
// tables) fill their `contentDOM` with child blocks later on, and code
302+
// blocks would turn the placeholder into literal content.
303+
const blockNodeType = editor.pmSchema.nodes[block.type as any];
304+
if (
305+
blockNodeType?.inlineContent &&
306+
!blockNodeType.spec.code &&
307+
ret.contentDOM.childNodes.length === 0
308+
) {
309+
ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER));
310+
}
277311
}
278312

279313
let listType = undefined;

‎packages/core/src/api/exporters/markdown/markdownExporter.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,20 @@ import {
88
StyleSchema,
99
} from "../../../schema/index.js";
1010
import { createExternalHTMLExporter } from "../html/externalHTMLExporter.js";
11+
import { EMPTY_BLOCK_PLACEHOLDER } from "../html/util/serializeBlocksExternalHTML.js";
1112
import { htmlToMarkdown } from "./htmlToMarkdown.js";
1213

1314
// Needs to be sync because it's used in drag handler event (SideMenuPlugin)
1415
export function cleanHTMLToMarkdown(cleanHTMLString: string) {
15-
return htmlToMarkdown(cleanHTMLString);
16+
// The external HTML exporter fills empty inline-content blocks with a
17+
// placeholder character so they survive an HTML round trip (see
18+
// `EMPTY_BLOCK_PLACEHOLDER`). Markdown has no need for that placeholder, so we
19+
// remove it to avoid it showing up as a stray character in the output.
20+
const withoutPlaceholder = cleanHTMLString
21+
.split(EMPTY_BLOCK_PLACEHOLDER)
22+
.join("");
23+
24+
return htmlToMarkdown(withoutPlaceholder);
1625
}
1726

1827
export function blocksToMarkdown<

‎packages/core/src/api/parsers/html/parseHTML.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,51 @@ import {
66
} from "../../../schema/index.js";
77

88
import { Block } from "../../../blocks/defaultBlocks.js";
9+
import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js";
910
import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js";
1011
import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js";
1112
import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js";
1213

14+
/**
15+
* Removes the placeholder character that the external HTML exporter inserts
16+
* into empty inline-content blocks (see `EMPTY_BLOCK_PLACEHOLDER`). The
17+
* placeholder keeps such blocks from being dropped while the HTML is parsed;
18+
* stripping it here lets the block round trip back to genuinely empty content.
19+
*/
20+
function stripEmptyBlockPlaceholder(content: any[]): any[] {
21+
const stripped: any[] = [];
22+
23+
for (const item of content) {
24+
if (item.type === "text") {
25+
const text = item.text.split(EMPTY_BLOCK_PLACEHOLDER).join("");
26+
if (text.length > 0) {
27+
stripped.push({ ...item, text });
28+
}
29+
} else if (Array.isArray(item.content)) {
30+
// Links and custom inline content that hold nested styled text.
31+
stripped.push({
32+
...item,
33+
content: stripEmptyBlockPlaceholder(item.content),
34+
});
35+
} else {
36+
stripped.push(item);
37+
}
38+
}
39+
40+
return stripped;
41+
}
42+
43+
function stripEmptyBlockPlaceholderFromBlocks(blocks: Block<any, any, any>[]) {
44+
for (const block of blocks) {
45+
if (Array.isArray(block.content)) {
46+
(block as any).content = stripEmptyBlockPlaceholder(block.content);
47+
}
48+
if (block.children.length > 0) {
49+
stripEmptyBlockPlaceholderFromBlocks(block.children);
50+
}
51+
}
52+
}
53+
1354
export function HTMLToBlocks<
1455
BSchema extends BlockSchema,
1556
I extends InlineContentSchema,
@@ -33,5 +74,7 @@ export function HTMLToBlocks<
3374
blocks.push(nodeToBlock(parentNode.child(i), parentNode));
3475
}
3576

77+
stripEmptyBlockPlaceholderFromBlocks(blocks);
78+
3679
return blocks;
3780
}

packages/xl-ai/src/api/formats/html-blocks/__snapshots__/htmlBlocks.test.ts/Add/__msw_snapshots__/anthropic.messages/claude-3-7-sonnet-latest (streaming)/add a new paragraph (empty doc)_1_509f796014e0dae8a4a50e1aaab8a378.json renamed to packages/xl-ai/src/api/formats/html-blocks/__snapshots__/htmlBlocks.test.ts/Add/__msw_snapshots__/anthropic.messages/claude-3-7-sonnet-latest (streaming)/add a new paragraph (empty doc)_1_2263f232809497c4f6c0a975957dff6b.json

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,29 @@
22
"request": {
33
"method": "POST",
44
"url": "https://api.anthropic.com/v1/messages",
5-
"body": "{\"model\":\"claude-3-7-sonnet-latest\",\"max_tokens\":64000,\"system\":[{\"type\":\"text\",\"text\":\"You're manipulating a text document using HTML blocks. \\nMake sure to follow the json schema provided. When referencing ids they MUST be EXACTLY the same (including the trailing $). \\nList items are 1 block with 1 list item each, so block content `<ul><li>item1</li></ul>` is valid, but `<ul><li>item1</li><li>item2</li></ul>` is invalid. We'll merge them automatically.\\nFor code blocks, you can use the `data-language` attribute on a <code> block (wrapped with <pre>) to specify the language.\\n\\nIf the user requests updates to the document, use the \\\"applyDocumentOperations\\\" tool to update the document.\\n---\\nIF there is no selection active in the latest state, first, determine what part of the document the user is talking about. You SHOULD probably take cursor info into account if needed.\\n EXAMPLE: if user says \\\"below\\\" (without pointing to a specific part of the document) he / she probably indicates the block(s) after the cursor. \\n EXAMPLE: If you want to insert content AT the cursor position (UNLESS indicated otherwise by the user), then you need `referenceId` to point to the block before the cursor with position `after` (or block below and `before`\\n---\\n \"}],\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"There is no active selection. This is the latest state of the document (ignore previous documents, you MUST issue operations against this latest version of the document). \\nThe cursor is BETWEEN two blocks as indicated by cursor: true.\\nBecause the document is empty, YOU MUST first update the empty block before adding new blocks.\"},{\"type\":\"text\",\"text\":\"[{\\\"id\\\":\\\"ref1$\\\",\\\"block\\\":\\\"<p></p>\\\"},{\\\"cursor\\\":true}]\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"write a new paragraph with the text 'You look great today!'\"}]}],\"tools\":[{\"name\":\"applyDocumentOperations\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"operations\":{\"type\":\"array\",\"items\":{\"anyOf\":[{\"type\":\"object\",\"description\":\"Update a block\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"update\"]},\"id\":{\"type\":\"string\",\"description\":\"id of block to update\"},\"block\":{\"type\":\"string\",\"description\":\"html of block (MUST be a single HTML element)\"}},\"required\":[\"type\",\"id\",\"block\"],\"additionalProperties\":false},{\"type\":\"object\",\"description\":\"Insert new blocks\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"add\"]},\"referenceId\":{\"type\":\"string\",\"description\":\"MUST be an id of a block in the document\"},\"position\":{\"type\":\"string\",\"enum\":[\"before\",\"after\"],\"description\":\"`after` to add blocks AFTER (below) the block with `referenceId`, `before` to add the block BEFORE (above)\"},\"blocks\":{\"items\":{\"type\":\"string\",\"description\":\"html of block (MUST be a single, VALID HTML element)\"},\"type\":\"array\"}},\"required\":[\"type\",\"referenceId\",\"position\",\"blocks\"],\"additionalProperties\":false},{\"type\":\"object\",\"description\":\"Delete a block\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"delete\"]},\"id\":{\"type\":\"string\",\"description\":\"id of block to delete\"}},\"required\":[\"type\",\"id\"],\"additionalProperties\":false}]}}},\"additionalProperties\":false,\"required\":[\"operations\"]}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true}",
6-
"headers": [],
5+
"body": "{\"model\":\"claude-3-7-sonnet-latest\",\"max_tokens\":64000,\"system\":[{\"type\":\"text\",\"text\":\"You're manipulating a text document using HTML blocks. \\nMake sure to follow the json schema provided. When referencing ids they MUST be EXACTLY the same (including the trailing $). \\nList items are 1 block with 1 list item each, so block content `<ul><li>item1</li></ul>` is valid, but `<ul><li>item1</li><li>item2</li></ul>` is invalid. We'll merge them automatically.\\nFor code blocks, you can use the `data-language` attribute on a <code> block (wrapped with <pre>) to specify the language.\\n\\nIf the user requests updates to the document, use the \\\"applyDocumentOperations\\\" tool to update the document.\\n---\\nIF there is no selection active in the latest state, first, determine what part of the document the user is talking about. You SHOULD probably take cursor info into account if needed.\\n EXAMPLE: if user says \\\"below\\\" (without pointing to a specific part of the document) he / she probably indicates the block(s) after the cursor. \\n EXAMPLE: If you want to insert content AT the cursor position (UNLESS indicated otherwise by the user), then you need `referenceId` to point to the block before the cursor with position `after` (or block below and `before`\\n---\\n \"}],\"messages\":[{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"There is no active selection. This is the latest state of the document (ignore previous documents, you MUST issue operations against this latest version of the document). \\nThe cursor is BETWEEN two blocks as indicated by cursor: true.\\nBecause the document is empty, YOU MUST first update the empty block before adding new blocks.\"},{\"type\":\"text\",\"text\":\"[{\\\"id\\\":\\\"ref1$\\\",\\\"block\\\":\\\"<p>\ufffc</p>\\\"},{\\\"cursor\\\":true}]\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"write a new paragraph with the text 'You look great today!'\"}]}],\"tools\":[{\"name\":\"applyDocumentOperations\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"operations\":{\"type\":\"array\",\"items\":{\"anyOf\":[{\"type\":\"object\",\"description\":\"Update a block\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"update\"]},\"id\":{\"type\":\"string\",\"description\":\"id of block to update\"},\"block\":{\"type\":\"string\",\"description\":\"html of block (MUST be a single HTML element)\"}},\"required\":[\"type\",\"id\",\"block\"],\"additionalProperties\":false},{\"type\":\"object\",\"description\":\"Insert new blocks\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"add\"]},\"referenceId\":{\"type\":\"string\",\"description\":\"MUST be an id of a block in the document\"},\"position\":{\"type\":\"string\",\"enum\":[\"before\",\"after\"],\"description\":\"`after` to add blocks AFTER (below) the block with `referenceId`, `before` to add the block BEFORE (above)\"},\"blocks\":{\"items\":{\"type\":\"string\",\"description\":\"html of block (MUST be a single, VALID HTML element)\"},\"type\":\"array\"}},\"required\":[\"type\",\"referenceId\",\"position\",\"blocks\"],\"additionalProperties\":false},{\"type\":\"object\",\"description\":\"Delete a block\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"delete\"]},\"id\":{\"type\":\"string\",\"description\":\"id of block to delete\"}},\"required\":[\"type\",\"id\"],\"additionalProperties\":false}]}}},\"additionalProperties\":false,\"required\":[\"operations\"]}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true}",
6+
"headers": [
7+
[
8+
"anthropic-beta",
9+
"fine-grained-tool-streaming-2025-05-14"
10+
],
11+
[
12+
"anthropic-version",
13+
"2023-06-01"
14+
],
15+
[
16+
"content-type",
17+
"application/json"
18+
],
19+
[
20+
"user-agent",
21+
"ai-sdk/anthropic/3.0.2 ai-sdk/provider-utils/4.0.2 runtime/browser"
22+
],
23+
[
24+
"x-api-key",
25+
"not-available-in-ci"
26+
]
27+
],
728
"cookies": []
829
},
930
"response": {
@@ -12,4 +33,4 @@
1233
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-3-7-sonnet-20250219\",\"id\":\"msg_016r1xY1xw9dBXbuUikPJbe6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1148,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":11,\"service_tier\":\"standard\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_019x69kHjsPjYsLZ1suXT9QU\",\"name\":\"applyDocumentOperations\",\"input\":{}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"operations\\\": [\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\n {\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\n \\\"type\\\":\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\" \\\"update\\\",\\n \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"id\\\": \\\"ref\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"1$\\\",\\n \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"block\\\": \\\"<p\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\">You look great today\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"!</p>\\\"\\n \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\\n]\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1148,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":82} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
1334
"headers": []
1435
}
15-
}
36+
}

0 commit comments

Comments
 (0)