Skip to content
Merged
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
Expand Up @@ -15,6 +15,20 @@ import {
} from "../../../nodeConversions/blockToNode.js";
import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js";

/**
* Placeholder character inserted into empty inline-content blocks when
* exporting to external HTML. An empty block serializes to an element with no
* children (e.g. `<p></p>`), which is dropped when the HTML is parsed back into
* blocks. Filling it with this character keeps the block alive through the
* round trip, and the parser strips the character again so the block ends up
* empty (see `HTMLToBlocks`).
*
* The Unicode object replacement character (U+FFFC) is used because it's a
* reserved placeholder that users don't type, so it can be safely removed on
* import without discarding legitimate content (unlike a non-breaking space).
*/
export const EMPTY_BLOCK_PLACEHOLDER = "";

function addAttributesAndRemoveClasses(element: HTMLElement) {
// Removes all BlockNote specific class names.
const className =
Expand Down Expand Up @@ -265,15 +279,35 @@ function serializeBlock<
}
}

if (ret.contentDOM && block.content) {
const ic = serializeInlineContentExternalHTML(
editor,
block.content as any, // TODO
serializer,
{ ...options, blockType: block.type },
);
if (ret.contentDOM) {
if (block.content) {
const ic = serializeInlineContentExternalHTML(
editor,
block.content as any, // TODO
serializer,
{ ...options, blockType: block.type },
);

ret.contentDOM.appendChild(ic);
}

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

let listType = undefined;
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/api/exporters/markdown/markdownExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@ import {
StyleSchema,
} from "../../../schema/index.js";
import { createExternalHTMLExporter } from "../html/externalHTMLExporter.js";
import { EMPTY_BLOCK_PLACEHOLDER } from "../html/util/serializeBlocksExternalHTML.js";
import { htmlToMarkdown } from "./htmlToMarkdown.js";

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

return htmlToMarkdown(withoutPlaceholder);
}

export function blocksToMarkdown<
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/api/parsers/html/parseHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,51 @@ import {
} from "../../../schema/index.js";

import { Block } from "../../../blocks/defaultBlocks.js";
import { EMPTY_BLOCK_PLACEHOLDER } from "../../exporters/html/util/serializeBlocksExternalHTML.js";
import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js";
import { nestedListsToBlockNoteStructure } from "./util/nestedLists.js";
import { preprocessHTMLWhitespace } from "./util/normalizeWhitespace.js";

/**
* Removes the placeholder character that the external HTML exporter inserts
* into empty inline-content blocks (see `EMPTY_BLOCK_PLACEHOLDER`). The
* placeholder keeps such blocks from being dropped while the HTML is parsed;
* stripping it here lets the block round trip back to genuinely empty content.
*/
function stripEmptyBlockPlaceholder(content: any[]): any[] {
const stripped: any[] = [];

for (const item of content) {
if (item.type === "text") {
const text = item.text.split(EMPTY_BLOCK_PLACEHOLDER).join("");
if (text.length > 0) {
stripped.push({ ...item, text });
}
} else if (Array.isArray(item.content)) {
// Links and custom inline content that hold nested styled text.
stripped.push({
...item,
content: stripEmptyBlockPlaceholder(item.content),
});
} else {
stripped.push(item);
}
}

return stripped;
}

function stripEmptyBlockPlaceholderFromBlocks(blocks: Block<any, any, any>[]) {
for (const block of blocks) {
if (Array.isArray(block.content)) {
(block as any).content = stripEmptyBlockPlaceholder(block.content);
}
if (block.children.length > 0) {
stripEmptyBlockPlaceholderFromBlocks(block.children);
}
}
}

export function HTMLToBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
Expand All @@ -33,5 +74,7 @@ export function HTMLToBlocks<
blocks.push(nodeToBlock(parentNode.child(i), parentNode));
}

stripEmptyBlockPlaceholderFromBlocks(blocks);

return blocks;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,29 @@
"request": {
"method": "POST",
"url": "https://api.anthropic.com/v1/messages",
"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}",
"headers": [],
"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}",
"headers": [
[
"anthropic-beta",
"fine-grained-tool-streaming-2025-05-14"
],
[
"anthropic-version",
"2023-06-01"
],
[
"content-type",
"application/json"
],
[
"user-agent",
"ai-sdk/anthropic/3.0.2 ai-sdk/provider-utils/4.0.2 runtime/browser"
],
[
"x-api-key",
"not-available-in-ci"
]
],
"cookies": []
},
"response": {
Expand All @@ -12,4 +33,4 @@
"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",
"headers": []
}
}
}
Loading
Loading