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.
Paste bugs usually come from mixing browser event ownership with model insertion ownership.
| Need | Start with | Owner |
|---|---|---|
| One editor instance needs a local paste/drop hook | plugin on.paste or on.drop | platejs/react |
| A reusable package owns paste/drop import policy | domCommands.insertData interceptor in plugin commands | platejs/dom |
| A browser MIME representation needs decoding or encoding | Register a DataTransferFormat in dataTransferFormats | platejs/dom |
Framework code needs to import a DataTransfer | editor.api.dom.clipboard.insertData(data) | platejs/dom through platejs/react |
| Parsed or structural content is already decoded | tx.slice.replace(slice, options?) | platejs |
| Decoded content must fit a detached parent | state.slice.fitContent(slice, { parent, root? }) | platejs |
| Copy or drag must include hidden model content | DOM coverage copyPolicy plus model-backed clipboard data | platejs/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.
Clipboard data enters Plate through explicit layers.
| Stage | What happens | Owner |
|---|---|---|
| Browser event | The browser produces paste, cut, copy, dragstart, or drop with a DataTransfer. | Browser |
| EditorContent handler | App handlers can handle the event or let Plate continue. | platejs/react |
| Insert-data command | Typed domCommands.insertData interceptors can claim, transform, or delegate the payload. | platejs/dom |
| DOM clipboard import | Plate reads its internal fragment, then registered DataTransfer formats, then plain text. | platejs/dom |
| Transaction | A parsed slice is fitted at the actual range and applied through one canonical replacement. | platejs |
| Commit and render | Plate publishes one change; React renders and repairs selection. | platejs and platejs/react |
| Proof | Browser 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.
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.
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.
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.
| Field | Contract |
|---|---|
mimeType | The 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. |
priority | Optional, 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>.
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.
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.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 tries each step until one inserts content:
domCommands.insertData interceptors.priority
first, then by plugin name.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 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.
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.
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.
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.
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.
Clipboard proof should name the layer that can fail.
| Claim | Useful proof |
|---|---|
| The model inserted the right content | model text, fragment, canonical change, and selection |
| The DOM payload was imported correctly | browser clipboard helper or dispatched DataTransfer |
| Hidden content copied correctly | copied plain text, HTML, Plate fragment, and DOM coverage policy |
| Selection survived paste | model selection, DOM/native selection where observable, and follow-up typing |
| A feature owns paste policy | focused DOM contribution test plus browser paste smoke |
Use Browser for clipboard helpers and Editing Behavior for the full event-to-commit pipeline.