Exporting documents to Email (HTML)
This example exports the current document (all blocks) as an HTML file for use in emails, and downloads it to your computer.
Try it out: Edit the document and click "Download email .html" at the top to download the HTML file.
import { testDocumentBlocks } from "./testDocumentBlocks";import { BlockNoteSchema, COLORS_DARK_MODE_DEFAULT, COLORS_DEFAULT, combineByGroup, withPageBreak,} from "@blocknote/core";import { filterSuggestionItems } from "@blocknote/core/extensions";import "@blocknote/core/fonts/inter.css";import { BlockNoteView } from "@blocknote/mantine";import "@blocknote/mantine/style.css";import { SuggestionMenuController, getDefaultReactSlashMenuItems, getPageBreakReactSlashMenuItems, useBlockNoteContext, useCreateBlockNote, usePrefersColorScheme,} from "@blocknote/react";import { createReactDiagramBlockSpec } from "@blocknote/diagram-block";import { createDiagramBlockMapping } from "@blocknote/diagram-block/email-exporter";import { createReactInlineMathSpec, createReactMathBlockSpec,} from "@blocknote/math-block";import { createInlineMathMapping, createMathBlockMapping,} from "@blocknote/math-block/email-exporter";import { ReactEmailExporter, reactEmailDefaultSchemaMappings,} from "@blocknote/xl-email-exporter";import { getMultiColumnSlashMenuItems, locales as multiColumnLocales, multiColumnDropCursor, withMultiColumn,} from "@blocknote/xl-multi-column";import * as locales from "@blocknote/core/locales";import { useEffect, useMemo, useState } from "react";import "./styles.css";export default function App() { // Stores the editor's contents as an email HTML string for download and // displaying the email. const [emailDocument, setEmailDocument] = useState<string>(""); // Creates a new editor instance. const editor = useCreateBlockNote({ // Adds support for page breaks & multi-column blocks. // Adds support for math & diagram blocks. schema: withMultiColumn(withPageBreak(BlockNoteSchema.create())).extend({ blockSpecs: { mathBlock: createReactMathBlockSpec(), diagram: createReactDiagramBlockSpec(), }, inlineContentSpecs: { math: createReactInlineMathSpec(), }, }), dropCursor: multiColumnDropCursor, dictionary: { ...locales.en, multi_column: multiColumnLocales.en, }, // Adds support for advanced table features. tables: { splitCells: true, cellBackgroundColor: true, cellTextColor: true, headers: true, }, // Sets initial editor content. initialContent: [ ...testDocumentBlocks, // The math & diagram blocks aren't part of the shared test document, // since the exporter unit tests' schemas don't register them, so they're // appended here instead. { type: "mathBlock", content: "a^2 = \\sqrt{b^2 + c^2}", }, { type: "diagram", content: `graph TD A[Start] --> B{Works?} B -->|Yes| C[Ship it] B -->|No| A`, }, { type: "paragraph", content: [ { type: "text", text: "Inline math: ", styles: {}, }, { type: "math", content: "e^{i\\pi} + 1 = 0", }, ], }, ], }); // Additional Slash Menu items for page breaks. const getSlashMenuItems = useMemo( () => async (query: string) => filterSuggestionItems( combineByGroup( getDefaultReactSlashMenuItems(editor), getPageBreakReactSlashMenuItems(editor), getMultiColumnSlashMenuItems(editor), ), query, ), [editor], ); const existingContext = useBlockNoteContext(); const systemColorScheme = usePrefersColorScheme(); // Exports the editor document to email whenever it changes. const onChange = async () => { const colorScheme = existingContext?.colorSchemePreference || systemColorScheme; const exporter = new ReactEmailExporter( editor.schema, { ...reactEmailDefaultSchemaMappings, blockMapping: { ...reactEmailDefaultSchemaMappings.blockMapping, // Renders math blocks & diagrams as images with the source as alt // text. Embedded as data URLs by default - when actually sending // emails, deliver them as inline attachments instead (which Gmail // & Outlook also display) by passing an `imageDelivery` from // `createCIDImageDelivery()` to each mapping, and handing its // `attachments` to your mailer alongside the HTML. mathBlock: createMathBlockMapping(), diagram: createDiagramBlockMapping(), }, inlineContentMapping: { ...reactEmailDefaultSchemaMappings.inlineContentMapping, math: createInlineMathMapping(), }, }, { colors: colorScheme === "dark" ? COLORS_DARK_MODE_DEFAULT : COLORS_DEFAULT, }, ); const emailHtml = await exporter.toReactEmailDocument(editor.document); setEmailDocument(emailHtml); }; // Exports the inital editor document to PDF. useEffect(() => { void onChange(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Downloads the email in HTML format. const onDownloadClick = async () => { const blob = new Blob([emailDocument], { type: "text/html" }); const link = document.createElement("a"); link.href = window.URL.createObjectURL(blob); link.download = "My Document (blocknote export).html"; document.body.appendChild(link); link.dispatchEvent( new MouseEvent("click", { bubbles: true, cancelable: true, view: window, }), ); link.remove(); window.URL.revokeObjectURL(link.href); }; // Renders the editor instance and Email view. return ( <div className="views"> <div className="view-wrapper"> <div className="view-label">Editor Input</div> <div className="view"> <BlockNoteView editor={editor} slashMenu={false} onChange={onChange}> <SuggestionMenuController triggerCharacter={"/"} getItems={getSlashMenuItems} /> </BlockNoteView> </div> </div> <div className="view-wrapper"> <div className="view-label"> Email Output <span className="view-label-download" onClick={onDownloadClick}> Download </span> </div> <div className="view"> <div className="email" dangerouslySetInnerHTML={{ __html: emailDocument }} /> </div> </div> </div> );}.views { container-name: views; container-type: inline-size; display: flex; flex-direction: row; flex-wrap: wrap; gap: 8px; height: 100%; padding: 8px;}.view-wrapper { display: flex; flex-direction: column; height: calc(50% - 4px); width: 100%;}@container views (width > 1024px) { .view-wrapper { height: 100%; width: calc(50% - 4px); }}.view-label { color: #0090ff; display: flex; font-size: 12px; font-weight: bold; justify-content: space-between; margin-inline: 16px;}.view-label-download { cursor: pointer; text-decoration: underline;}.view { border: solid #0090ff 1px; border-radius: 16px; flex: 1; height: 0; padding: 8px;}.view .bn-container { height: 100%; margin: 0; max-width: none; padding: 0;}.view .bn-editor { height: 100%; overflow: auto;}.view .email { height: 100%; overflow: auto;}// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY.// Generated from shared/testDocumentBlocks.ts β run `pnpm run gen` to update.import type { PartialBlock } from "@blocknote/core";/** * The shared example/test document, as partial blocks. * * This file is intentionally self-contained β it has only a type-only import * from `@blocknote/core` (no runtime imports) β so the example generator can * copy it verbatim into each exporter playground example and the examples stay * runnable on their own (e.g. opened directly in StackBlitz). * * It is the single source of truth for both the playground examples' editor * `initialContent` and the exporters' `testDocument` unit-test fixture. */export const testDocumentBlocks: PartialBlock<any, any, any>[] = [ { type: "paragraph", content: [ { type: "text", text: "Welcome to this ", styles: { italic: true, }, }, { type: "text", text: "demo π!", styles: { italic: true, bold: true, }, }, ], children: [ { type: "paragraph", content: "Hello World nested", children: [ { type: "paragraph", content: "Hello World double nested", }, ], }, ], }, { type: "paragraph", content: [ { type: "text", text: "This paragraph has a background color", styles: { bold: true }, }, ], props: { backgroundColor: "red", }, }, { type: "paragraph", content: "Paragraph", }, // An empty paragraph: a blank line in the editor, which exporters must // preserve as vertical space rather than dropping the block. { type: "paragraph", }, { type: "heading", content: "Heading", }, { type: "heading", content: "Heading right", props: { textAlignment: "right", }, }, { type: "heading", content: "Heading 2", props: { level: 2 }, }, { type: "heading", content: "Heading 3", props: { level: 3 }, }, { type: "heading", content: "Heading 4", props: { level: 4 }, }, { type: "heading", content: "Heading 5", props: { level: 5 }, }, { type: "heading", content: "Heading 6", props: { level: 6 }, }, { type: "paragraph", content: "Emojis: π π π π ππ½ π πΆββοΈ", }, { type: "paragraph", content: "Centered paragraph", props: { textAlignment: "center", }, }, { type: "paragraph", content: "justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", props: { textAlignment: "justify", }, }, { type: "pageBreak" }, { type: "bulletListItem", content: "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", children: [ { type: "bulletListItem", content: "Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", }, { type: "bulletListItem", content: "Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", props: { textAlignment: "right", }, }, { type: "numberedListItem", content: "Numbered List Item 1", }, { type: "numberedListItem", content: "Numbered List Item 2", children: [ { type: "numberedListItem", content: "Numbered List Item Nested 1", }, { type: "numberedListItem", content: "Numbered List Item Nested 2", }, { type: "numberedListItem", content: "Numbered List Item Nested funky right", props: { textAlignment: "right", backgroundColor: "red", textColor: "blue", }, }, { type: "numberedListItem", content: "Numbered List Item Nested funky center", props: { textAlignment: "center", backgroundColor: "red", textColor: "blue", }, }, ], }, ], }, { type: "numberedListItem", content: "Numbered List Item", }, { type: "checkListItem", content: "Check List Item", }, { type: "checkListItem", content: "Checked List Item", props: { checked: true, }, }, { type: "numberedListItem", content: "Numbered List Item starting at 5", props: { start: 5, }, }, { type: "numberedListItem", content: "Numbered List Item 6", }, { type: "toggleListItem", content: "Toggle List Item", children: [ { type: "paragraph", content: "Content nested inside the toggle list item.", }, { type: "bulletListItem", content: "A nested bullet inside the toggle", }, ], }, { type: "heading", content: "Toggle Heading", props: { level: 2, isToggleable: true, }, children: [ { type: "paragraph", content: "Content nested inside the toggle heading.", }, ], }, { type: "table", content: { type: "tableContent", columnWidths: [200, undefined, undefined], rows: [ { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, { cells: ["Wide Cell", "Table Cell", "Table Cell"], }, ], }, }, { type: "file", }, { type: "image", props: { url: "https://placehold.co/332x322.jpg", caption: "From https://placehold.co/332x322.jpg", }, }, { type: "image", props: { previewWidth: 200, url: "https://placehold.co/332x322.jpg", textAlignment: "right", }, }, { type: "video", props: { url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", caption: "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", }, }, { type: "audio", props: { url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", caption: "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", }, }, { type: "paragraph", }, { type: "audio", props: { caption: "Audio file caption", name: "audio.mp3", }, }, { type: "paragraph", content: [ { type: "text", text: "Inline Content:", styles: { bold: true }, }, ], }, { type: "paragraph", content: [ { type: "text", text: "Styled Text", styles: { bold: true, italic: true, textColor: "red", backgroundColor: "blue", }, }, { type: "text", text: " ", styles: {}, }, { type: "text", text: "underlined", styles: { underline: true }, }, { type: "text", text: " ", styles: {}, }, { type: "text", text: "strikethrough", styles: { strike: true }, }, { type: "text", text: " ", styles: {}, }, { type: "link", content: "Link", href: "https://www.blocknotejs.org", }, ], }, { type: "table", content: { type: "tableContent", headerRows: 1, rows: [ { cells: ["Table Header 1", "Table Header 2", "Table Header 3"], }, { cells: [ "Table Cell 4", [ { type: "text", text: "Table Cell Bold Colored 5", styles: { bold: true, textColor: "red", backgroundColor: "blue", }, }, ], "Table Cell 6", ], }, { cells: ["Table Cell 7", "Table Cell 8", "Table Cell 9"], }, ], }, }, // An "advanced" table: two header rows and merged cells (colspan / // rowspan) - the features behind the editor's `splitCells` / // `headers` table options, which exporters must place correctly. { type: "table", content: { type: "tableContent", // Explicit widths for all three tracks: the merged first-row cell // means the row alone doesn't reveal the column count. columnWidths: [undefined, undefined, undefined], headerRows: 2, rows: [ { cells: [ { type: "tableCell", content: "Merged Header", props: { colspan: 2 }, }, { type: "tableCell", content: "Header C" }, ], }, { cells: ["Header A", "Header B", "Header C2"], }, { cells: [ { type: "tableCell", content: "Merged Rows", props: { rowspan: 2 }, }, { type: "tableCell", content: "Cell B1" }, { type: "tableCell", content: "Cell C1" }, ], }, { cells: ["Cell B2", "Cell C2"], }, ], }, }, // A hard line break (shift+enter) inside one paragraph - a single block // whose text spans two lines. { type: "paragraph", content: "A hard line break\nwithin a single paragraph", }, { type: "codeBlock", props: { language: "javascript", }, content: `const helloWorld = (message) => { console.log("Hello World", message);};`, }, { type: "paragraph", content: [ { type: "text", text: "Some inline code: ", styles: { bold: true }, }, { type: "text", text: "var foo = 'bar';", styles: { code: true }, }, ], }, { type: "columnList", children: [ { type: "column", props: { width: 0.8 }, children: [ { type: "paragraph", content: "This paragraph is in a column!" }, ], }, { type: "column", props: { width: 1.4 }, children: [{ type: "heading", content: "So is this heading!" }], }, { type: "column", props: { width: 0.8 }, children: [ { type: "paragraph", content: "You can have multiple blocks in a column too", }, { type: "bulletListItem", content: "Block 1" }, { type: "bulletListItem", content: "Block 2" }, { type: "bulletListItem", content: "Block 3" }, ], }, ], }, { type: "divider" }, { type: "quote", content: "All those moments will be lost in time, like tears in rain.", },];/// <reference types="vite-plus/client" />