Set format="markdown" and value, defaultValue, and onChange carry Markdown strings. Incoming Markdown is converted to HTML with marked (MIT) and parsed by the same engine as HTML & paste: sanitizing, tools, cleanup, and the import report still apply. Outgoing Markdown is BlockNote's lossy export, so echoing onChange back is stable even though it won't match the source character for character.
Edit either side: the editor and the Markdown stay in sync.
import { useState } from "react";
import { BlockEditor, type FidelityReport } from "genesis-editor";
const initialMarkdown = `# Release notes
Paste **Markdown** in, get clean HTML out. [Full changelog](https://example.com/changelog).
- Faster paste
- Lists and \`code\` survive
> Edits on either side show up on the other.`;
export default function MarkdownValueExample() {
const [markdown, setMarkdown] = useState(initialMarkdown);
const [report, setReport] = useState<FidelityReport | null>(null);
return (
<div className="grid gap-4 lg:grid-cols-2">
<BlockEditor
format="markdown"
value={markdown}
onChange={(next) => setMarkdown(next)}
onImportReport={setReport}
/>
<div className="flex flex-col gap-2 px-4 lg:px-0 lg:pr-4">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Markdown (editable)</span>
{report && <span data-testid="markdown-score">Fidelity {Math.round(report.score * 100)}%</span>}
</div>
<textarea
aria-label="Markdown"
data-testid="markdown-source"
className="min-h-64 flex-1 rounded-md border bg-muted/30 p-3 font-mono text-xs leading-relaxed"
spellCheck={false}
value={markdown}
onChange={(event) => setMarkdown(event.target.value)}
/>
</div>
</div>
);
}
A controlled value is parsed only when it differs from the Markdown the editor last emitted, so passing onChange's string straight back costs nothing and never moves the cursor. onChange runs at most every 150 ms while typing.
Plain-text Markdown from ChatGPT, GitHub, Slack, or a .md file is detected and imported through the HTML engine, in every format. Headings, lists, quotes, code fences, tables, rules, and Markdown links or images count; a lone URL or ordinary sentence stays with the default handler. Real HTML pastes still win: copying a rendered page keeps the HTML path. A trivial <pre> or <div> wrapper around the same Markdown source is treated as Markdown.
Inline links stay links. Scripts and javascript: URLs are stripped by the HTML engine after conversion. BlockNote's own clipboard and VS Code snippets are unchanged, and pastes into code blocks stay plain text.
import { useRef, useState } from "react";
import { BlockEditor, type BlockEditorHandle, type FidelityReport } from "genesis-editor";
const samples = [
{
name: "GitHub",
markdown: `# Quarterly plan
**Goal:** ship the editor.
- [x] Parser
- [ ] Docs
See the [changelog](https://example.com/changelog).`,
},
{
name: "ChatGPT",
markdown: `## Summary
Here is a drop-in snippet:
\`\`\`ts
export const n = 1;
\`\`\`
> Keep the HTML engine in the loop.`,
},
];
function pasteMarkdown(editor: BlockEditorHandle, markdown: string) {
const doc = editor.editor.document;
editor.editor.setTextCursorPosition(doc[doc.length - 1], "end");
editor.focus();
const data = new DataTransfer();
data.setData("text/plain", markdown);
data.setData("text/html", `<pre>${markdown.replace(/&/g, "&").replace(/</g, "<")}</pre>`);
editor.editor.domElement?.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
}
export default function MarkdownPasteExample() {
const editor = useRef<BlockEditorHandle>(null);
const [report, setReport] = useState<FidelityReport | null>(null);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2 px-15">
{samples.map((sample) => (
<button
key={sample.name}
className="rounded-md border px-2.5 py-1 text-sm hover:bg-muted"
onClick={() => editor.current && pasteMarkdown(editor.current, sample.markdown)}
>
Paste {sample.name}
</button>
))}
</div>
<BlockEditor
ref={editor}
onImportReport={setReport}
/>
{report && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 px-15 text-xs" data-testid="markdown-paste-report">
<dt className="text-muted-foreground">Fidelity</dt>
<dd data-testid="markdown-paste-score">{Math.round(report.score * 100)}%</dd>
<dt className="text-muted-foreground">Blocks</dt>
<dd data-testid="markdown-paste-blocks">{report.blocks}</dd>
</dl>
)}
</div>
);
}
Ref
setMarkdown replaces the document the same way setHTML does, and returns the import report. getMarkdown is the lossy export, in every format.
const editor = useRef<BlockEditorHandle>(null);editor.current.getMarkdown(); // lossy exportconst report = editor.current.setMarkdown(md); // via the HTML engineeditor.current.lastReport;
markdownToHTML, parseMarkdown, and parseMarkdownAsync live on genesis-editor/parser. They do not sanitize on their own: always run the HTML they produce through parseHTML (which parseMarkdown already does).
import { parseMarkdown, markdownToHTML } from "genesis-editor/parser";import { linkedomDom } from "genesis-editor/parser/linkedom";const { blocks, report } = parseMarkdown(md, { dom: linkedomDom, tools: ["heading", "bold", "link"] });const html = markdownToHTML(md); // then parseHTML(html, { … }) if you already have HTML options
Every option and report field is listed in the API reference.