Skip to main content

Editor Integration

SuperDocs identifies every block-level element in your document with a data-chunk-id attribute. Your editor must preserve those attributes across the round-trip — HTML in, editor model, HTML back out — for surgical edits to work.
This guide covers attribute preservation in a rich-text editor. For rendering inline diff overlays inside the editor, see Human-in-the-Loop → Rendering diffs inline in your editor. For streaming events, see SSE Streaming. If your product doesn’t have an editor — your AI agent is the consumer of SuperDocs, or you’re running server-side / batch — see Agent Tool Integration or Server Integration instead.

Why this matters

Every SuperDocs response returns HTML like this:
<h2 data-chunk-id="550e8400-e29b-41d4-a716-446655440000">Section 1</h2>
<p data-chunk-id="6a7b8c9d-e0f1-2345-6789-abcdef012345"></p>
The data-chunk-id values are how the AI references specific sections when it proposes edits. When your app sends the document back on the next turn, those same IDs must still be on the same blocks. If they aren’t, the AI’s “edit Section 2” request lands on the wrong block — or nothing at all. The failure mode is silent. Most rich-text editors strip unknown HTML attributes by default when parsing input into their internal model, and/or don’t emit them when serialising back to HTML. Edits work intermittently on blocks whose tags happen to survive, and fail mysteriously on the rest. No error is thrown. A five-minute check now saves hours of debugging later.

The pattern, in plain English

Every editor integration follows the same three steps:
  1. Parse incoming HTML from SuperDocs into your editor’s document model, preserving data-chunk-id on every block-level node.
  2. Serialise the editor model back to HTML, preserving the same data-chunk-id attributes.
  3. Render incoming final.updated_html and document_sync.content payloads by re-running step 1 against the new HTML.
The snippets below implement these three steps for the six most common editors. Pick the one matching your stack, paste it in, and verify the round-trip with the test at the bottom of this page.

Working snippets

Install the vanilla ProseMirror packages:
npm install prosemirror-state prosemirror-view prosemirror-model \
  prosemirror-schema-basic prosemirror-schema-list prosemirror-history \
  prosemirror-keymap prosemirror-commands
Your schema needs to do two things:
  1. Add data-chunk-id as a preserved attribute on every standard block node (paragraph, heading, list, blockquote, code block, horizontal rule).
  2. Register a wrapper node for <div data-chunk-id="…">…</div> elements. SuperDocs uses these wrappers when a single chunk spans multiple block elements (e.g. a heading plus the paragraphs that follow it). If your schema has no node that matches div[data-chunk-id], the parser will descend into the children, the wrapper’s data-chunk-id will be silently dropped, and any inline-diff or chunk-targeted edit referencing that ID will fail to render. The basic ProseMirror schema does not include a <div> node — you must add one.
// schema.ts
import { Schema, NodeSpec } from "prosemirror-model";
import { schema as basicSchema } from "prosemirror-schema-basic";
import { addListNodes } from "prosemirror-schema-list";

// TS note: prosemirror-model's ParseRule.getAttrs signature has shifted
// across versions. If your compiler complains about the parameter type,
// leave the signature alone and add `as HTMLElement` at the usage site —
// do not refactor the helper.
function withChunkId(spec: NodeSpec): NodeSpec {
  const attrs = { ...(spec.attrs ?? {}), "data-chunk-id": { default: null } };

  const parseDOM = (spec.parseDOM ?? []).map((rule) => ({
    ...rule,
    getAttrs: (node: string | HTMLElement) => {
      const base =
        typeof rule.getAttrs === "function"
          ? rule.getAttrs(node as HTMLElement) ?? {}
          : rule.attrs ?? {};
      if (typeof node === "string") return base;
      return { ...base, "data-chunk-id": node.getAttribute("data-chunk-id") };
    },
  }));

  const originalToDOM = spec.toDOM;
  const toDOM: NodeSpec["toDOM"] = originalToDOM
    ? (node) => {
        const out = originalToDOM(node);
        if (!Array.isArray(out)) return out;
        const [tag, maybeAttrs, ...rest] = out as [string, unknown, ...unknown[]];
        const chunkId = node.attrs["data-chunk-id"];
        if (!chunkId) return out;
        const isAttrs =
          maybeAttrs &&
          typeof maybeAttrs === "object" &&
          !Array.isArray(maybeAttrs) &&
          !(maybeAttrs as { nodeType?: unknown }).nodeType;
        return isAttrs
          ? [tag, { ...(maybeAttrs as Record<string, unknown>), "data-chunk-id": chunkId }, ...rest]
          : [tag, { "data-chunk-id": chunkId }, maybeAttrs, ...rest];
      }
    : undefined;

  return { ...spec, attrs, parseDOM, toDOM };
}

// Wrapper node for multi-element chunks: <div data-chunk-id="…">…</div>.
// Holds one or more block children; preserves the chunk-id on round-trip.
const chunkWrapperSpec: NodeSpec = {
  group: "block",
  content: "block+",
  attrs: { "data-chunk-id": { default: null } },
  parseDOM: [{
    tag: "div[data-chunk-id]",
    getAttrs: (node) =>
      typeof node === "string"
        ? {}
        : { "data-chunk-id": node.getAttribute("data-chunk-id") },
  }],
  toDOM: (node) => [
    "div",
    node.attrs["data-chunk-id"]
      ? { "data-chunk-id": node.attrs["data-chunk-id"] }
      : {},
    0,
  ],
};

let nodes = basicSchema.spec.nodes;
for (const name of ["paragraph", "blockquote", "heading", "horizontal_rule", "code_block"]) {
  const spec = nodes.get(name);
  if (spec) nodes = nodes.update(name, withChunkId(spec));
}
nodes = addListNodes(nodes, "paragraph block*", "block");
for (const name of ["ordered_list", "bullet_list", "list_item"]) {
  const spec = nodes.get(name);
  if (spec) nodes = nodes.update(name, withChunkId(spec));
}
nodes = nodes.addToEnd("chunk_wrapper", chunkWrapperSpec);

export const schema = new Schema({ nodes, marks: basicSchema.spec.marks });
Use this schema when creating your EditorState. Use DOMParser.fromSchema(schema).parse(htmlElement) to load SuperDocs HTML and DOMSerializer.fromSchema(schema).serializeFragment(doc.content) to serialise it back out.Where this slots in: typically inside a React / Vue / Svelte component that mounts ProseMirror via new EditorView(domNode, { state }). Expose two methods on a ref — getHtml() and setHtml(html) — so the chat panel can read the current document before each send and write the updated HTML after each final event.

Other editors and custom implementations

The principle is the same regardless of editor: preserve unknown HTML attributes on block-level elements across parse and serialise. Three things to verify in your editor’s documentation:
  1. Does the parser strip unknown attributes by default? Most do. Look for “custom attributes” or “attribute preservation” in the docs.
  2. Does the serialiser emit them when converting back to HTML? If the parser accepted them, the serialiser usually does — but not always.
  3. Does the internal model store them on every block type you plan to use? Often there’s a schema or node definition that has to be extended per node type.

Verification test

Paste any SuperDocs response into your editor, read the HTML back out, and diff against the input. Every data-chunk-id must survive.
const incoming = `<h1 data-chunk-id="abc123">Title</h1><p data-chunk-id="def456">Body</p>`;

editor.setHtml(incoming);
const roundTripped = editor.getHtml();

const inIds = [...incoming.matchAll(/data-chunk-id="([^"]+)"/g)].map(m => m[1]);
const outIds = [...roundTripped.matchAll(/data-chunk-id="([^"]+)"/g)].map(m => m[1]);

console.assert(
  inIds.every(id => outIds.includes(id)),
  "Chunk IDs did not survive the round-trip",
  { inIds, outIds }
);
Run this on every block type your product uses — headings, paragraphs, lists, list items, blockquotes, code blocks, horizontal rules, tables. A gap in any single type will surface as occasional silent edit failures in production.

Stuck?

If your editor isn’t covered here, or chunk-id round-trip is failing despite following the pattern, email hello@superdocs.app or book a 15-minute integration call at cal.com/superdocs. We’ll add your editor’s pattern to this guide.