Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Upload Files
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Authored Changes
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting
  • Locations
  • Transactions
  • Serializing
  • Roots
  • Document Meta
  • Clipboard and Paste
  • Decorations and annotations
  • Schema
  • History
  • Pagination
  • Annotations
  • DOM Coverage
  • External Text Views
  • Virtualized Rendering

Clipboard And Paste

PreviousNext

Route copy, paste, drop, and fitted slice replacement through Plate's editor, DOM, and plugin layers.

Clipboard work crosses browser events, Plate fragments, transactions, DOM coverage, and browser proof. Use this page to decide whether a paste, copy, or drop policy belongs in EditorContent, a plugin, editor.api.dom.clipboard, or a fragment transform.

Choose the right surface

Paste bugs usually come from mixing browser event ownership with model insertion ownership.

NeedStart withOwner
One editor instance needs a local paste/drop hookplugin on.paste or on.dropplatejs/react
Document MetaDecorations and annotations

On This Page

Choose the right surfaceRuntime pipelinePlugin clipboard policyDOM clipboard APICustom DataTransfer formatsReporting what a paste leaves outNull, false, and throwPaste orderCopy orderHTML pastePaste resultsFragment and slice replacementHidden and projected contentBrowser proofRelated docs
Build your editor
Production-ready AI template and reusable components.
Get all-access
A reusable package owns paste/drop import policydomCommands.insertData interceptor in plugin commandsplatejs/dom
A browser MIME representation needs decoding or encodingRegister a DataTransferFormat in dataTransferFormatsplatejs/dom
Framework code needs to import a DataTransfereditor.api.dom.clipboard.insertData(data)platejs/dom through platejs/react
Parsed or structural content is already decodedtx.slice.replace(slice, options?)platejs
Decoded content must fit a detached parentstate.slice.fitContent(slice, { parent, root? })platejs
Copy or drag must include hidden model contentDOM coverage copyPolicy plus model-backed clipboard dataplatejs/dom and platejs/react
The claim depends on real browser clipboard behavior@platejs/test clipboard helpers@platejs/test

Use plugin on handlers for local event interception. Use the DOM insert-data command when the behavior should apply to native paste, drop, browser tests, and every React surface that installs the plugin.

Runtime pipeline

Clipboard data enters Plate through explicit layers.

StageWhat happensOwner
Browser eventThe browser produces paste, cut, copy, dragstart, or drop with a DataTransfer.Browser
EditorContent handlerApp handlers can handle the event or let Plate continue.platejs/react
Insert-data commandTyped domCommands.insertData interceptors can claim, transform, or delegate the payload.platejs/dom
DOM clipboard importPlate reads its internal fragment, then registered DataTransfer formats, then plain text.platejs/dom
TransactionA parsed slice is fitted at the actual range and applied through one canonical replacement.platejs
Commit and renderPlate publishes one change; React renders and repairs selection.platejs and platejs/react
ProofBrowser tests assert model content, DOM/native selection where needed, focus, clipboard payload, and follow-up typing.@platejs/test

Do not close a paste bug with only a model assertion when the failure was in the browser event, DOM clipboard payload, native selection, or follow-up typing.

Plugin clipboard policy

Intercept domCommands.insertData when a feature owns a reusable DOM import rule.

import { definePlugin } from "platejs";
import { domCommands } from "platejs/dom";
 
const pasteTodoPrefix = definePlugin("paste-todo-prefix", {
  commands: ({ around }) => [
    around(domCommands.insertData, ({ input, next, state }) => {
      const text = input.getData("text/plain");
 
      if (!text.startsWith("todo:")) return next();
 
      return state.transaction((tx) => {
        tx.text.insert(text.slice("todo:".length).trim());
      });
    }),
  ],
});
import { definePlugin } from "platejs";
import { domCommands } from "platejs/dom";
 
const pasteTodoPrefix = definePlugin("paste-todo-prefix", {
  commands: ({ around }) => [
    around(domCommands.insertData, ({ input, next, state }) => {
      const text = input.getData("text/plain");
 
      if (!text.startsWith("todo:")) return next();
 
      return state.transaction((tx) => {




The interceptor receives the DataTransfer as input and returns a pure transaction spec. Return next() when Plate should keep running the internal slice, DataTransfer-format, and plain-text import path. Keep DataTransfer at the DOM boundary; headless commands start from a ContentSlice.

Use this for package-owned import rules such as custom inline syntax, pasted URLs, product fragments, and table-specific paste policy. Do not put those rules in Plate core unless the rule is part of Plate's model contract.

DOM clipboard API

React editors expose DOM clipboard helpers through editor.api.dom.clipboard.

editor.api.dom.clipboard.insertData(dataTransfer);
editor.api.dom.clipboard.insertFragmentData(dataTransfer);
editor.api.dom.clipboard.insertTextData(dataTransfer);
editor.api.dom.clipboard.readSlice(dataTransfer);
editor.api.dom.clipboard.writeSelection(dataTransfer);
editor.api.dom.clipboard.writeSlice(dataTransfer, { slice });
editor.api.dom.clipboard.insertData(dataTransfer);
editor.api.dom.clipboard.insertFragmentData(dataTransfer);
editor.api.dom.clipboard.insertTextData(dataTransfer);
editor.api.dom.clipboard.readSlice(dataTransfer);
editor.api.dom.clipboard.writeSelection(dataTransfer);
editor.api.dom.clipboard.writeSlice(dataTransfer, { slice });

Use these APIs from framework bridges, tests, or low-level event code that already has a DataTransfer. insertData owns a transaction when called directly and joins the active transaction when framework code already opened one. Command interceptors compose a transaction spec through state.

readSlice distinguishes { kind: "absent" }, malformed MIME or HTML data as { kind: "invalid", source }, and { kind: "slice", slice }. writeSlice writes one exact ContentSlice plus optional transfer formats. This keeps missing, invalid, and valid empty clipboard payloads distinct. Formats supplied to writeSlice are authoritative, including an intentional empty string. Installed serializers fill only formats the caller omitted.

Plate writes plain text, HTML, and an internal Plate fragment payload. The fragment payload uses application/${clipboardFormatKey}, so editors with different keys do not blindly import each other's internal JSON.

Custom DataTransfer formats

A DataTransferFormat reads or writes one browser MIME type as a whole payload. Register one when your app puts its own type on the clipboard, or when a source needs cleanup before Plate's HTML parser sees it. Mappings for single nodes and marks belong in the plugin's formats instead; see Serializing.

import { ContentSlice, NodeApi, definePlugin } from "platejs";
 
const MAX_NOTES_LENGTH = 1_000_000;
 
const readNotes = (data: string): string[] | null => {
  try {
    const payload = JSON.parse(data);
 
    if (payload?.version !== 1 || !Array.isArray(payload.notes)) return null;
 
    return payload.notes.filter((note: unknown) => typeof note === "string");
  } catch {
    return null;
  }
};
 
export const NotesTransferPlugin = definePlugin("notesTransfer", {
  dataTransferFormats: [
    {
      mimeType: "application/x-acme-notes+json",
      // Higher priority runs first; outrank generic text/plain readers.
      priority: 50,
      // This plugin owns no node type, so it claims the whole schema.
      scope: "document",
      accept: ({ data }) => data.length <= MAX_NOTES_LENGTH,
      decode: ({ data }) => {
        const notes = readNotes(data);
 
        if (!notes) return null;
 
        return ContentSlice.closed(
          notes.map((text) => ({ type: "paragraph", children: [{ text }] }))
        );
      },
      encode: ({ slice }) => {
        const notes = slice.content
          .map((node) => NodeApi.string(node))
          .filter(Boolean);
 
        if (notes.length === 0) return null;
 
        return JSON.stringify({ version: 1, notes });
      },
    },
  ],
});
import { ContentSlice, NodeApi, definePlugin } from "platejs";
 
const MAX_NOTES_LENGTH = 1_000_000;
 
const readNotes = (data: string): string[] | null => {
  try {
    const payload = JSON.parse(data);
 
    if (payload?.version !== 1 || !Array.isArray(payload.notes)) return null;
 
    return payload.notes.filter((note: unknown) => typeof note === "string");


































Each declaration has one mimeType and at least one of decode and encode.

FieldContract
mimeTypeThe MIME type read from and written to the DataTransfer. Declare it once per plugin, with decode and encode in the same object.
accept(context)Optional. Return false to skip this format before decode runs.
decode(context)Return a ContentSlice, or null to let the next format read the payload.
encode(context)Return the string to write, or null to leave this MIME type to the next encoder.
priorityOptional, default 0. Higher runs first.
scope'document' claims the whole schema. Without it, the format claims the plugin's own element type and properties, so the plugin must own one.

accept and decode receive { data, mimeType, report, snapshot, state }: the payload for this MIME type, a function that reports what the paste leaves out, a read-only snapshot of every type and file on the transfer, and read-only editor state. encode receives { mimeType, slice, state }. Both also get the plugin's name, pluginState, registry, and schema. Callbacks do not receive the editor, the live DataTransfer, or a transaction. Plate keys each format as plate:<plugin>:<mimeType>.

Reporting what a paste leaves out

Call report(diagnostic) from accept or decode to describe what the payload loses. A diagnostic is { impact, message }: 'lossy' when pasted content is left out or loses meaning, 'lossless' for harmless cleanup such as dropped metadata. report works only while the callback runs.

decode: ({ data, report }) => {
  const { droppedEmbeds, slice } = parseNotes(data);
 
  if (droppedEmbeds > 0) {
    report({ impact: 'lossy', message: 'Embedded notes were left out.' });
  }
 
  return slice;
},
decode: ({ data, report }) => {
  const { droppedEmbeds, slice } = parseNotes(data);
 
  if (droppedEmbeds > 0) {
    report({ impact: 'lossy', message: 'Embedded notes were left out.' });
  }
 
  return slice;
},

Returning null or false without reporting stays silent. If accept or decode throws, Plate discards that format's reports and sends the error to lifecycleErrorSink.

Null, false, and throw

  • accept returning false means the format does not apply here. Use it for cheap checks before parsing.
  • decode returning null means the payload is foreign, malformed, or empty. Clipboard data is untrusted, so return null for bad input instead of throwing.
  • encode returning null means the slice has nothing to write for this MIME type. The next encoder for the same type can still write it.
  • A throw is a bug. Plate catches it, sends it to the editor's lifecycleErrorSink, and moves on to the next format, so a broken format cannot block paste or copy. A decode result that is not a valid ContentSlice, or an encode result that is neither a string nor null, is reported the same way.
const editor = createEditor({
  plugins: [NotesTransferPlugin],
  lifecycleErrorSink: (error) => {
    if ("source" in error && error.source === "data-transfer-format") {
      reportBug(error.cause, { key: error.key, phase: error.phase });
    }
  },
});
const editor = createEditor({
  plugins: [NotesTransferPlugin],
  lifecycleErrorSink: (error) => {
    if ("source" in error && error.source === "data-transfer-format") {
      reportBug(error.cause, { key: error.key, phase: error.phase });
    }
  },
});

A format error carries key, mimeType, pluginName, and phase ('accept', 'decode', or 'encode'). Without a sink, Plate logs it with console.error.

Paste order

Paste tries each step until one inserts content:

  1. domCommands.insertData interceptors.
  2. Plate's own fragment payload.
  3. Declared formats whose MIME type is on the transfer, highest priority first, then by plugin name.
  4. Plate's HTML format.
  5. Plain text.

Plate fits each decoded slice at the actual insertion range and keeps its open edges and detached roots. A slice that does not fit writes nothing, and the next format tries. Plain text is the final fallback.

Formats with the same MIME type, direction, and priority must claim disjoint schema. Overlapping claims throw when the editor is created, so two document-scoped formats for one MIME type need different priorities.

Copy order

Copy runs encoders in the same order and writes each MIME type once: the first encoder that returns a string wins. A declared text/html or text/plain encoder therefore replaces Plate's built-in one, and returning null falls back to it. writeDataTransferFragment(editor, data, slice) from platejs/dom runs the encoders into any setData target and returns the MIME types it wrote.

HTML paste

Plate's HTML format parses like editor.api.html.parseSlice with lossPolicy: 'allow'. Before any mapping runs, it removes comments, metadata, scripts, style sheets, embedded objects, SVG and MathML, event handlers, srcdoc and srcset, URLs that fail their role (see HTML safety), and style attributes that load resources. Embedded media without an installed mapping keeps only its fallback content. If nothing insertable remains, the HTML format still reports what it removed, then returns null and plain text handles the paste.

Paste results

EditorContent calls onPasteResult once for each paste that Plate's built-in formats handle: after the pasted content commits (inserted: true), or when no format can insert it (inserted: false).

<EditorContent
  onPasteResult={({ diagnostics, inserted }) => {
    if (diagnostics.some(({ impact }) => impact === 'lossy')) {
      toast.warning(
        inserted
          ? 'Some pasted content was left out.'
          : 'The pasted content could not be inserted.'
      );
    }
  }}
/>
<EditorContent
  onPasteResult={({ diagnostics, inserted }) => {
    if (diagnostics.some(({ impact }) => impact === 'lossy')) {
      toast.warning(
        inserted
          ? 'Some pasted content was left out.'
          : 'The pasted content could not be inserted.'
      );
    }
  }}
/>

diagnostics holds the reports of the format whose content was inserted. Loss reported by an earlier format that could not be used stays in the list unless the inserted format decoded the same MIME type: plain text that matches the HTML text does not recover what the HTML lost.

Only the editor surface that received the paste is called, and only while it is mounted. onPasteResult is not called when onPaste or a domCommands.insertData handler handles the paste without the built-in formats, for drops, or for a separate editor.api.dom.clipboard.insertData call, which returns a boolean. An onPaste handler that calls insertData during the paste gets that insertion's result.

Plate and the registry Editor show no paste-result UI by default. Add onPasteResult when your application can explain the loss or offer a useful recovery action.

Paste results do not name the format that handled a paste. When a source keeps losing content, add the missing mapping: an HTML rule in the owning plugin's formats, or a format for that MIME type.

Fragment and slice replacement

Use tx.fragment.replace(...) for known-closed content. The compiled schema fits the content at the actual target.

editor.update((tx) => {
  tx.fragment.replace([
    {
      type: "paragraph",
      children: [{ text: "Pasted paragraph" }],
    },
  ]);
});
editor.update((tx) => {
  tx.fragment.replace([
    {
      type: "paragraph",
      children: [{ text: "Pasted paragraph" }],
    },
  ]);
});

DataTransfer formats and transport boundaries preserve open edges with ContentSlice.

import { ContentSlice } from "platejs";
 
const slice = ContentSlice.fromJSON({
  content: decodedContent,
  openEnd: 1,
  openStart: 1,
  roots: {
    "note:1": decodedNote,
  },
});
 
editor.update.slice.replace(slice);
import { ContentSlice } from "platejs";
 
const slice = ContentSlice.fromJSON({
  content: decodedContent,
  openEnd: 1,
  openStart: 1,
  roots: {
    "note:1": decodedNote,
  },
});
 
editor.update.slice.replace(slice);

ContentSlice has one transport shape: { content, openStart, openEnd, roots? }. roots carries the transitive detached secondary roots referenced by the slice content. Inserting the slice remaps copied keys deterministically and keeps shared aliases together.

Core slice replacement is structural and schema-fitted. Grid-aware table paste, spreadsheet mapping, and product-specific merge rules belong in the table or product plugin that understands those structures.

When table code has a detached destination cell, call state.slice.fitContent(slice, { parent, root? }). It returns frozen, grammar-valid children or null without publishing editor state. The table plugin still owns row/column mapping, spans, and multi-cell replacement.

Hidden and projected content

Copy and drag can involve app-hidden or virtualized model content whose DOM is not mounted. DOM coverage boundaries decide whether covered content uses model serialization or is excluded. Model serialization writes the selected plain text, HTML, and Plate fragment without mounting every selected block.

Use DOM Coverage Boundaries for copyPolicy, selectionPolicy, and materialization behavior. Use Selection And DOM when a copy or paste bug also depends on caret position or native selection repair.

Browser proof

Clipboard proof should name the layer that can fail.

ClaimUseful proof
The model inserted the right contentmodel text, fragment, canonical change, and selection
The DOM payload was imported correctlybrowser clipboard helper or dispatched DataTransfer
Hidden content copied correctlycopied plain text, HTML, Plate fragment, and DOM coverage policy
Selection survived pastemodel selection, DOM/native selection where observable, and follow-up typing
A feature owns paste policyfocused DOM contribution test plus browser paste smoke

Use Browser for clipboard helpers and Editing Behavior for the full event-to-commit pipeline.

Related docs

  • EditorContent Component
  • React Editor
  • Plate DOM
  • DOM Coverage Boundaries
  • Canonical Change Substrate
  • Transforms API
tx.text.insert(text.slice("todo:".length).trim());
});
}),
],
});
} catch {
return null;
}
};
export const NotesTransferPlugin = definePlugin("notesTransfer", {
dataTransferFormats: [
{
mimeType: "application/x-acme-notes+json",
// Higher priority runs first; outrank generic text/plain readers.
priority: 50,
// This plugin owns no node type, so it claims the whole schema.
scope: "document",
accept: ({ data }) => data.length <= MAX_NOTES_LENGTH,
decode: ({ data }) => {
const notes = readNotes(data);
if (!notes) return null;
return ContentSlice.closed(
notes.map((text) => ({ type: "paragraph", children: [{ text }] }))
);
},
encode: ({ slice }) => {
const notes = slice.content
.map((node) => NodeApi.string(node))
.filter(Boolean);
if (notes.length === 0) return null;
return JSON.stringify({ version: 1, notes });
},
},
],
});