genesis-editor

HTML & paste

Load and save HTML instead of blocks, and paste content from Google Docs, Word, and web pages without carrying their markup along.

HTML in, HTML out#

Set format="html" and value, defaultValue, and onChange carry HTML strings. Incoming HTML is cleaned, sanitized, and mapped to blocks; outgoing HTML is plain semantic markup that loads back to the same document. Markdown import uses the same engine after converting CommonMark to HTML.

const [html, setHtml] = useState("<h2>Hello</h2><p>World</p>");

<BlockEditor format="html" value={html} onChange={(next) => setHtml(next)} />

Edit either side: the editor and the HTML stay in sync.

A controlled value is parsed only when it differs from the HTML the editor last emitted, so passing onChange's HTML straight back costs nothing and never moves the cursor. onChange runs at most every 150 ms while typing, and once more when the editor unmounts if an edit is pending.

Options

<BlockEditor
  format="html"
  defaultValue={savedHtml}
  onChange={(html, { blocks, report, editor }) => save(html)}
  onImportReport={(report) => console.log(report.score, report.dropped)}
  htmlMode="strict"              // snap custom colors to the palette
  baseUrl="https://example.com/" // resolve relative links and images
/>

tools applies to HTML too: content for a tool that's off is downgraded, for example a heading becomes a paragraph, so whatever loads can be edited.

Preview#

The playground and paste landing can show Edit | Preview. Preview renders getHTML() with genesis-editor/article.css on a .genesis-article wrapper and no editor chrome — the same markup a host would store, with the editor's block gaps, links, and code, not a pixel match of Google Docs.

Pasting#

HTML pasted from outside the editor goes through the same engine, in every format. Scripts, event handlers, unsafe URLs, hidden elements, tracking pixels, and utm_ parameters are removed; wrapper elements and inline styles become blocks and formatting. The pasted blocks are inserted as one undo step, and a leading paragraph joins the paragraph at the cursor. Copying and pasting inside the editor keeps BlockNote's own lossless clipboard, and pastes into code blocks stay plain text.

A paste of a single paragraph goes in at the cursor. Anything longer splits the block at the cursor: the text after it joins the last pasted paragraph, or stays a block of its own type when the paste ends with something else.

Page furniture

Larger pastes from web pages also lose their navigation, cookie banners, share bars, related-post lists, newsletter boxes, and comments. A full page is cut down to its article or main, and other containers are scored on their class names, link density, and wording, so it takes several signals to remove anything. Quotes, figures, tables, code, and list items are never scored. A toast says what was removed and offers Undo cleanup, which pastes the same content again without it. Pastes under 3 blocks or 500 characters are left alone.

<BlockEditor
  clean={{
    keepSelectors: [".callout"],   // never removed
    dropSelectors: [".disclaimer"], // always removed
    phrases: ["Werbung"],           // more furniture phrases
  }}
  cleanupToast={false}              // hide the toast; read report.cleanup instead
/>

Try the samples: Paste inserts them directly, and Copy puts them on your clipboard to paste yourself. Blog page shows the cleanup toast.

Whitespace and JSON-encoded HTML

Spaces follow HTML's rules, and &nbsp; padding next to other spaces collapses too, so pasted text doesn't keep double or trailing spaces. Exported HTML keeps intentional spacing in white-space:pre-wrap spans and reads back the same.

HTML copied from an API response, DevTools, or a .json file arrives JSON-encoded, with \n and \" escapes. The parser decodes it when the encoding is unambiguous (a quoted JSON string, JSON holding HTML strings, or escapes that are clearly structural) and adds a warning to the report. Pass decodeEscaped: false to parseHTML to turn this off.

Import report

Every import (the initial HTML value, setHTML, or a paste) calls onImportReport with a FidelityReport: a 0–1 score, the elements dropped and why, wrappers unwrapped, downgrades, colors, headings, and images. The latest one is also on the ref as lastReport.

const editor = useRef<BlockEditorHandle>(null);

editor.current.getHTML();               // "<h2>Hello</h2>\n<p>World</p>"
const report = editor.current.setHTML(html);
editor.current.lastReport;              // the report from the last import

Block metadata#

What a block can't show but shouldn't lose is kept in its meta: the source element's id, lang, and dir, and where a block came from, such as a quote's attribution. It's a top-level object on the block, so databases can query it, and it's left out when there's nothing to keep.

{
  "type": "heading",
  "props": { "level": 2 },
  "content": [{ "type": "text", "text": "Launch notes", "styles": {} }],
  "meta": { "v": 1, "attrs": { "id": "launch", "lang": "en" } }
}

meta moves with its block through copy and paste, drag, undo, and collaboration. Splitting a block copies it to both halves, except attrs.id, which stays on the first; a pasted id that already exists in the document is dropped. HTML export writes only id, lang, and dir, and the clipboard's HTML for other apps never carries it.

<BlockEditor
  meta={{
    policy: "strip-on-export", // keep meta in the document, leave it out of onChange/getBlocks/getHTML
    trackProvenance: true,     // stamp imported blocks with meta.import
  }}
/>

import { stripMeta } from "genesis-editor";
const publicJson = stripMeta(blocks);           // or ref.current.getBlocks({ meta: "strip" })

meta.v is the schema version. Older versions are migrated when a document loads, and meta from a newer version is kept as it is. Integrators and plugins can store their own data under meta.ext[name].

Quotes and images

Quotes keep every paragraph and their <cite> attribution as children, drawn inside the quote and exported inside <blockquote>, plus the cite source URL. Images keep alt apart from the file name, along with srcset/sizes, the intrinsic width and height, a surrounding link (href), and a formatted caption (captionRich, used on export until the caption is edited). Replacing an image's file clears the old srcset. An <img> inside a table cell (or a paragraph) is kept as an inline image: same URL, upload and re-hosting as image blocks, resize capped to the cell, alt text, and an optional link. Export is <td><img data-genesis-inline>. Browser copies that wrap the article (Chrome/Safari/Firefox), <picture>/srcset, and lazy data-src are recovered. An empty header row of empty <th>s is dropped as noise, an image that links to itself loses the link, and empty heading anchors are dropped. A one-row image-beside-text table, or a floated image with wrapping copy, becomes a columns block instead of a data table.

On the server#

The parser is a separate entry point without React or BlockNote, so it also runs in Node, workers, and edge functions, from import or require on Node 18 and newer. Pass a DOM implementation, such as the optional linkedom or jsdom peer:

import { parseHTML, toHTML } from "genesis-editor/parser";
import { linkedomDom } from "genesis-editor/parser/linkedom";
// or: import { jsdomDom } from "genesis-editor/parser/jsdom";

const { blocks, report } = parseHTML(html, { dom: linkedomDom, tools: ["heading", "bold", "link"] });
const clean = toHTML(blocks);

// Any DOM library works: a DOMParser class or instance, or a function returning a Document.
parseHTML(html, { dom: (doc) => new JSDOM(doc).window.document });

Every option and report field is listed in the API reference.