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
104 changes: 81 additions & 23 deletions packages/core/src/api/exporters/markdown/htmlToMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,8 +520,10 @@ function serializeVideo(el: HTMLElement, ctx: SerializeContext): string {
function serializeAudio(el: HTMLElement, ctx: SerializeContext): string {
const src = el.getAttribute("src") || "";
if (!src) {return "\n\n";}
// Audio has no visible representation in markdown; output as link with empty text
return ctx.indent + `[](${src})\n\n`;
// Audio has no markdown syntax, so emit raw HTML. The markdown parser
// passes <audio> blocks through verbatim and BlockNote's audio block parser
// recognizes them, giving a clean round-trip.
return ctx.indent + `<audio src="${escapeHtmlAttr(src)}" controls></audio>\n\n`;
}

function serializeEmbed(el: HTMLElement, ctx: SerializeContext): string {
Expand All @@ -531,41 +533,97 @@ function serializeEmbed(el: HTMLElement, ctx: SerializeContext): string {
}

function serializeFigure(el: HTMLElement, ctx: SerializeContext): string {
let result = "";

// Find the media element
const img = el.querySelector("img");
const video = el.querySelector("video");
const audio = el.querySelector("audio");
const link = el.querySelector("a");

const figcaption = el.querySelector("figcaption");
const captionText = figcaption?.textContent?.trim() || "";

if (img) {
const src = img.getAttribute("src") || "";
const alt = img.getAttribute("alt") || "";
result += ctx.indent + `![${alt}](${src})\n\n`;
} else if (video) {
return serializeMediaFigure(
"img",
img.getAttribute("src") || "",
img.getAttribute("alt") || "",
captionText,
ctx,
);
}
if (video) {
const src =
video.getAttribute("src") || video.getAttribute("data-url") || "";
const name =
video.getAttribute("data-name") || video.getAttribute("title") || "";
result += ctx.indent + `![${name}](${src})\n\n`;
} else if (audio) {
const src = audio.getAttribute("src") || "";
result += ctx.indent + `[](${src})\n\n`;
} else if (link) {
result += serializeBlockLink(link as HTMLElement, ctx);
return serializeMediaFigure("video", src, name, captionText, ctx);
}
if (audio) {
return serializeMediaFigure(
"audio",
audio.getAttribute("src") || "",
"",
captionText,
ctx,
);
}
if (link) {
return serializeBlockLink(link as HTMLElement, ctx);
}
return "";
}

// Caption
const figcaption = el.querySelector("figcaption");
if (figcaption) {
const caption = figcaption.textContent?.trim() || "";
if (caption) {
result += ctx.indent + caption + "\n\n";
}
function serializeMediaFigure(
kind: "img" | "video" | "audio",
src: string,
descriptor: string,
captionText: string,
ctx: SerializeContext,
): string {
if (!src) {return "";}

// No caption + has a markdown shorthand → use it.
if (!captionText && kind !== "audio") {
return ctx.indent + `![${descriptor}](${src})\n\n`;
}

return result;
// The descriptor (alt / data-name) is dropped when it duplicates the
// caption text; otherwise on round-trip both `name` and `caption` would
// get set to the same string (BlockNote's HTML exporter writes alt =
// name || caption, so a caption-only image has alt === figcaption text).
const showDescriptor = descriptor && descriptor !== captionText;
const descAttr =
!showDescriptor
? ""
: kind === "img"
? ` alt="${escapeHtmlAttr(descriptor)}"`
: kind === "video"
? ` data-name="${escapeHtmlAttr(descriptor)}"`
: "";

const tag =
kind === "img"
? `<img${descAttr} src="${escapeHtmlAttr(src)}">`
: `<${kind} src="${escapeHtmlAttr(src)}"${descAttr} controls></${kind}>`;

const captionPart = captionText
? `<figcaption>${escapeHtmlText(captionText)}</figcaption>`
: "";
return ctx.indent + `<figure>${tag}${captionPart}</figure>\n\n`;
}

function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

function serializeBlockLink(el: HTMLElement, ctx: SerializeContext): string {
Expand Down
57 changes: 35 additions & 22 deletions packages/core/src/api/parsers/markdown/markdownToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,13 @@ function parseImage(
);

if (isVideoUrl(url)) {
// Match remark-rehype behavior: data-name comes from the title, not alt
// Use the alt text as the video's display name (falling back to the
// title) so a video link written with the standard `![name](url)` form
// round-trips into BlockNote's video block. Captioned videos go through
// raw `<figure>` HTML instead, see htmlToMarkdown.serializeMediaFigure.
const name = alt || title;
return {
html: `<video src="${escapeHtml(url)}"${title !== undefined ? ` data-name="${escapeHtml(title)}"` : ""} data-url="${escapeHtml(url)}" controls></video>`,
html: `<video src="${escapeHtml(url)}"${name ? ` data-name="${escapeHtml(name)}"` : ""} data-url="${escapeHtml(url)}" controls></video>`,
end: parenEnd + 1,
};
}
Expand Down Expand Up @@ -573,19 +577,21 @@ type Token =
| RawHtmlToken;

/**
* HTML block-level tag names (from the CommonMark type-6 list). When a line
* starts with `<` followed by one of these tag names, the run of non-blank
* lines is emitted verbatim as raw HTML rather than wrapped in a paragraph.
* HTML block-level tag names (from the CommonMark type-6 list, plus `audio`
* which BlockNote serializes as raw HTML since markdown has no shorthand
* for it). When a line starts with `<` followed by one of these tag names,
* the run of non-blank lines is emitted verbatim as raw HTML rather than
* wrapped in a paragraph.
*/
const HTML_BLOCK_TAGS = new Set([
"address", "article", "aside", "base", "basefont", "blockquote", "body",
"caption", "center", "col", "colgroup", "dd", "details", "dialog", "dir",
"div", "dl", "dt", "fieldset", "figcaption", "figure", "footer", "form",
"frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header",
"hr", "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem",
"nav", "noframes", "ol", "optgroup", "option", "p", "param", "section",
"source", "summary", "table", "tbody", "td", "tfoot", "th", "thead",
"title", "tr", "track", "ul",
"address", "article", "aside", "audio", "base", "basefont", "blockquote",
"body", "caption", "center", "col", "colgroup", "dd", "details", "dialog",
"dir", "div", "dl", "dt", "fieldset", "figcaption", "figure", "footer",
"form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head",
"header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu",
"menuitem", "nav", "noframes", "ol", "optgroup", "option", "p", "param",
"section", "source", "summary", "table", "tbody", "td", "tfoot", "th",
"thead", "title", "tr", "track", "ul",
]);

function isHtmlBlockStart(line: string): boolean {
Expand Down Expand Up @@ -1140,21 +1146,28 @@ function getEffectiveListType(
function emitTable(table: TableToken): string {
let html = "<table>";

// Header row
html += "<thead><tr>";
for (let c = 0; c < table.headers.length; c++) {
const align = table.alignments[c];
const alignAttr = align ? ` align="${align}"` : "";
html += `<th${alignAttr}>${parseInline(table.headers[c])}</th>`;
// BlockNote tables have no required header row, but the markdown table
// syntax does. When we serialize a headerless BlockNote table to markdown
// we emit an empty header row; on re-parse, treat that empty header as
// "no header" so the round-trip is stable (issue #739).
const headerIsEmpty = table.headers.every((h) => h.trim() === "");
const colCount = table.headers.length;

if (!headerIsEmpty) {
html += "<thead><tr>";
for (let c = 0; c < colCount; c++) {
const align = table.alignments[c];
const alignAttr = align ? ` align="${align}"` : "";
html += `<th${alignAttr}>${parseInline(table.headers[c])}</th>`;
}
html += "</tr></thead>";
}
html += "</tr></thead>";

// Body rows
if (table.rows.length > 0) {
html += "<tbody>";
for (const row of table.rows) {
html += "<tr>";
for (let c = 0; c < table.headers.length; c++) {
for (let c = 0; c < colCount; c++) {
const cell = c < row.length ? row[c] : "";
const align = table.alignments[c];
const alignAttr = align ? ` align="${align}"` : "";
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/blocks/Video/parseVideoElement.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const parseVideoElement = (videoElement: HTMLVideoElement) => {
const url = videoElement.src || undefined;
const previewWidth = videoElement.width || undefined;
const name = videoElement.getAttribute("data-name") || undefined;

return { url, previewWidth };
return { url, previewWidth, name };
};
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,7 @@ Paragraph

* list item

![Example](exampleURL)

Caption
<figure><img alt="Example" src="exampleURL"><figcaption>Caption</figcaption></figure>

[Example](exampleURL)

Expand Down Expand Up @@ -221,31 +219,14 @@ exports[`Test ServerBlockNoteEditor > converts to and from markdown (blocksToMar
"id": "3",
"props": {
"backgroundColor": "default",
"caption": "",
"caption": "Caption",
"name": "Example",
"showPreview": true,
"textAlignment": "left",
"url": "exampleURL",
},
"type": "image",
},
{
"children": [],
"content": [
{
"styles": {},
"text": "Caption",
"type": "text",
},
],
"id": "4",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "paragraph",
},
{
"children": [],
"content": [
Expand All @@ -261,7 +242,7 @@ exports[`Test ServerBlockNoteEditor > converts to and from markdown (blocksToMar
"type": "link",
},
],
"id": "5",
"id": "4",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
Expand All @@ -278,7 +259,7 @@ exports[`Test ServerBlockNoteEditor > converts to and from markdown (blocksToMar
"type": "text",
},
],
"id": "6",
"id": "5",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<div class="bn-block-group" data-node-type="blockGroup">
<div class="bn-block-outer" data-node-type="blockOuter" data-id="1">
<div class="bn-block" data-node-type="blockContainer" data-id="1">
<div
class="bn-block-content"
data-content-type="image"
data-url="exampleURL"
data-file-block=""
>
<div
class="bn-file-block-content-wrapper"
style="position: relative; width: fit-content;"
>
<div class="bn-visual-media-wrapper">
<img
class="bn-visual-media"
src="exampleURL"
alt="BlockNote image"
draggable="false"
/>
<div class="bn-resize-handle" style="left: 4px; display: none;"></div>
<div class="bn-resize-handle" style="right: 4px; display: none;"></div>
</div>
</div>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<img src="exampleURL" alt="BlockNote image" data-url="exampleURL" />
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[](https://example.com/audio.mp3)
<audio src="https://example.com/audio.mp3" controls></audio>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[](https://example.com/audio.mp3)
<audio src="https://example.com/audio.mp3" controls></audio>
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
![example](exampleURL)

Caption
<figure><img alt="example" src="exampleURL"><figcaption>Caption</figcaption></figure>
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
![Caption](exampleURL)
<figure><img src="exampleURL"><figcaption>Caption</figcaption></figure>

Caption

![Caption](exampleURL)

Caption
<figure><img src="exampleURL"><figcaption>Caption</figcaption></figure>
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
![Caption](exampleURL)

Caption
<figure><img src="exampleURL"><figcaption>Caption</figcaption></figure>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
![BlockNote image](exampleURL)
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
![Example Image](https://example.com/image.png)

This is a caption
<figure><img alt="Example Image" src="https://example.com/image.png"><figcaption>This is a caption</figcaption></figure>
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
![](https://example.com/video.mp4)

Video caption
<figure><video src="https://example.com/video.mp4" controls></video><figcaption>Video caption</figcaption></figure>
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[
{
"attrs": {
"id": "1",
},
"content": [
{
"attrs": {
"backgroundColor": "default",
"caption": "",
"name": "",
"previewWidth": undefined,
"showPreview": true,
"textAlignment": "left",
"url": "exampleURL",
},
"type": "image",
},
],
"type": "blockContainer",
},
]
Loading
Loading