Editing behavior is the path from a key press or transaction command to the final document shape. Use Plugin Rules for declarative node policy, and use Editor Methods when you need an explicit editor.update(...). This guide shows how break, delete, merge, normalize, and selection behavior fit together.
Most editing behavior belongs in plugin.rules. Reach for an explicit transaction command or Plate plugin only when the rule table cannot express the behavior.
| Need | Use |
|---|---|
Change how Enter works in a node. | rules.break |
Change how Backspace works at the start of a block. | rules.delete |
| Decide whether an empty sibling disappears during a merge. | rules.merge |
| Remove empty nodes during normalization. | rules.normalize |
| Control mark or inline boundaries while typing and moving. | rules.selection |
| Apply one plugin's rules to another node type. | rules.match |
| Run a product-specific mutation that rules cannot express. | A constructor update contribution or an explicit editor.update(...) |
| Compose reusable Enter, Delete, or text-input behavior. | A Plate command handler declared through the constructor's root commands field |
Rules keep common editor policy close to the plugin that owns the node. Transaction commands are the right tool for toolbar actions and behavior that depends on app state.
Plate resolves plugins first, then installs the Plate schema and plugin fragments contributed by those plugins.
| Layer | Owner | Handles |
|---|---|---|
OverridePlugin | Core runtime | Node flags, break rules, delete rules, merge rules, normalize rules. |
AffinityPlugin | Core runtime | rules.selection for mark and inline boundaries. |
| Feature plugins | Feature packages | Default rules for headings, callouts, lists, links, tables, marks, and other nodes. |
| App plugins | Your app | Local rules, explicit transaction commands, and Plate command or correction plugins. |
The normal flow is:
key press or command
-> optional input rule for typed patterns
-> plugin rule lookup for the current node
-> tx command
-> merge guard when nodes are joined
-> normalization
-> selection affinity cleanupkey press or command
-> optional input rule for typed patterns
-> plugin rule lookup for the current node
-> tx command
-> merge guard when nodes are joined
-> normalization
-> selection affinity cleanupInput rules are for text patterns such as markdown shortcuts and autolinks. Plugin rules are for node behavior such as "a heading resets to paragraph on Backspace" or "a callout inserts soft breaks on Enter."
rules.break controls the break transaction that Enter calls.
Plate checks the current block and handles these cases in order:
| Case | Rule | What happens |
|---|---|---|
| Empty collapsed block | break.empty | Runs reset, exit, lift, deleteExit, or falls through. |
| Cursor after a trailing newline | break.emptyLineEnd | Runs exit, deleteExit, or falls through. |
| Normal Enter | break.default | Runs lineBreak, exit, deleteExit, or falls through. |
| Split created a new block | break.splitReset | Resets the new block to the default type. |
Use splitReset for blocks that should not keep their type after a normal split.
import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
break: {
splitReset: true,
},
},
});import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
break: {
splitReset: true,
},
},
});Use lineBreak and deleteExit for container-like blocks that need soft lines before they leave the block.
import { CalloutPlugin } from 'platejs/callout/react';
export const AppCalloutPlugin = CalloutPlugin.configure({
rules: {
break: {
default: 'lineBreak',
empty: 'reset',
emptyLineEnd: 'deleteExit',
},
},
});import { CalloutPlugin } from 'platejs/callout/react';
export const AppCalloutPlugin = CalloutPlugin.configure({
rules: {
break: {
default: 'lineBreak',
empty: 'reset',
emptyLineEnd: 'deleteExit',
},
},
});That callout keeps normal Enter inside the callout, resets empty callouts to paragraphs, and exits after a trailing empty line.
rules.delete controls collapsed Backspace behavior. Expanded selections still delete through the normal fragment path unless the whole editor is selected.
Plate checks collapsed deleteBackward in this order:
| Case | Rule | What happens |
|---|---|---|
| Cursor at the start of the current block | delete.start | Runs reset, lift, or falls through. |
| Current block is empty | delete.empty | Runs reset or falls through. |
| Cursor is at the start of the document | Core default | Resets the first block. |
| Nothing handled the case | Plate command | Delegates to the built-in delete command. |
Use start: 'reset' for formatted text blocks that should become paragraphs before they merge into the previous block.
import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
delete: {
start: 'reset',
},
},
});import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
delete: {
start: 'reset',
},
},
});Use start: 'lift' for nested blocks that should move out one ancestor level.
import { schema } from 'platejs';
import { definePlugin } from 'platejs/react';
export const QuoteItemPlugin = definePlugin('quoteItem', {
rules: {
delete: {
start: 'lift',
},
},
schema: {
element: {
content: schema.content.text({ default: 'text', min: 1 }),
type: 'quote_item',
},
},
});import { schema } from 'platejs';
import { definePlugin } from 'platejs/react';
export const QuoteItemPlugin = definePlugin('quoteItem', {
rules: {
delete: {
start: 'lift',
},
},
schema: {
element: {
content: schema.content.text({ default: 'text', min: 1 }),
type: 'quote_item',
},
},
});When a selection spans multiple blocks, Plate deletes the selected content and
then calls tx.nodes.merge(...) at the end boundary. That means cross-block
deletion can still enter the merge pipeline below.
Merge behavior decides whether two nodes can join and whether empty nodes at the boundary disappear.
tx.nodes.merge(...) evaluates Plate's internal
editorReads.nodes.shouldMergeNodesRemovePrevNode descriptor before it applies
the merge. Plate's merge-rule middleware adds three important guards:
| Case | Behavior |
|---|---|
| Empty text node before the merge point | Remove it when it is not the first child. |
| Empty previous sibling | Remove it only when the owning plugin has rules.merge.removeEmpty: true. |
| Target node is void | Do not delete the void target by default; remove the current empty node instead when possible. |
Use removeEmpty: true for text-like blocks such as paragraphs and headings.
import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
merge: {
removeEmpty: true,
},
},
});import { HeadingPlugin } from 'platejs/react';
export const AppHeadingPlugin = HeadingPlugin.configure({
rules: {
merge: {
removeEmpty: true,
},
},
});Keep removeEmpty: false for structural nodes that own layout, children, or wrappers. Tables, rows, cells, columns, and callouts should not disappear just because a merge crosses their boundary.
import { CalloutPlugin } from 'platejs/callout/react';
export const StableCalloutPlugin = CalloutPlugin.configure({
rules: {
merge: {
removeEmpty: false,
},
},
});import { CalloutPlugin } from 'platejs/callout/react';
export const StableCalloutPlugin = CalloutPlugin.configure({
rules: {
merge: {
removeEmpty: false,
},
},
});Merge rules are not table cell merge commands. rules.merge protects
document structure during node joins. Table cell merge and split commands live
on tx.plugin(TablePlugin).merge() and tx.plugin(TablePlugin).split(),
using TablePlugin from platejs/table/react.
rules.normalize runs during Plate normalization.
Use normalize.removeEmpty for elements that should not exist without text content. Links use this shape because an empty link has no useful editing surface.
import { LinkPlugin } from 'platejs/react';
export const AppLinkPlugin = LinkPlugin.configure({
rules: {
normalize: {
removeEmpty: true,
},
},
});import { LinkPlugin } from 'platejs/react';
export const AppLinkPlugin = LinkPlugin.configure({
rules: {
normalize: {
removeEmpty: true,
},
},
});Keep normalization rules boring. If a node needs a richer structural repair,
write a convergent Plate correction and contribute it through
the constructor's root corrections field so the behavior is explicit and
testable.
rules.selection controls how marks and inline-like boundaries behave while typing, deleting, and moving the cursor.
| Affinity | Use for |
|---|---|
default | Normal Plate boundary behavior. |
directional | Links and highlights where cursor direction decides whether typed text stays inside. |
outward | Annotation marks where edge typing should leave the mark. |
hard | Boundaries that should take an extra arrow-key step to cross. |
Element behavior also affects editing. Plate compiles schema.element.inline,
schema.element.void, schema.element.selectable, and the markable-inline void
kind into Plate's schema before selection and transaction logic runs.
| Behavior | Configure |
|---|---|
| Heading resets to paragraph on Backspace. | rules.delete.start: 'reset' |
| Heading splits into paragraph on Enter. | rules.break.splitReset: true |
| Callout keeps Enter inside the block. | rules.break.default: 'lineBreak' |
| Empty callout becomes a paragraph. | rules.break.empty: 'reset' or rules.delete.start: 'reset' |
| Nested item outdents on Backspace. | rules.delete.start: 'lift' |
| Empty text block disappears during merge. | rules.merge.removeEmpty: true |
| Structural wrapper survives merge. | rules.merge.removeEmpty: false |
| Empty inline element disappears. | rules.normalize.removeEmpty: true |
| Link boundary follows cursor direction. | rules.selection.affinity: 'directional' |
For list metadata and code-block children, use rules.match so the feature plugin can apply its rule to the child block that actually contains the selection.
| Surface | Owner | Reference |
|---|---|---|
rules.break | Core rule engine plus feature plugin definition | Plugin Rules |
rules.delete | Core rule engine plus feature plugin definition | Plugin Rules |
rules.merge | Core rule engine plus feature plugin definition | Plugin Rules |
rules.normalize | Core rule engine plus feature plugin definition | Plugin Rules |
rules.selection | Affinity core plugin plus feature plugin definition | Plugin Rules |
rules.match | Core rule lookup plus feature plugin definition | Plugin Rules |
tx.nodes.merge(...) | Plate transaction command | Transforms |
editorReads.nodes.shouldMergeNodesRemovePrevNode | Internal Plate descriptor extended by Plate merge-rule middleware | Plugin Rules |
tx.plugin(TablePlugin).merge() | Table feature package | Table |
Commands read their own draft writes. Construct the intended valid shape before
reading it again; corrections run at transaction closeout. Related writes share
one editor.update(...) and publish one immutable commit.
Each correction declares the node event that can invalidate its rule:
children, content, or properties. Corrections begin from changed ranges.
Further relevant writes queue affected entries in a bounded worklist, so a local
edit does not require a whole-document scan. Each correction must converge and
repair a concrete violation with transaction methods.
Use tx.nodes.unset('url', { at }) to delete an optional field. Valid property
values are determined by the installed schema. Imported DocumentChange values
must validate against that schema; they are not accepted for later repair.
editor.update.value.repair();editor.update.value.repair();Call this when loaded data or an installed correction needs an all-root maintenance pass. It starts a history-skipped update, scans the primary document and named roots, and publishes nothing for an already canonical value. It cannot run inside another update. Ordinary feature commands use changed-range closeout.