Plugin Rules control how editor nodes respond to common user actions. Instead of overriding the editor methods, you can configure these behaviors directly on a plugin's rules property.
This guide shows you how to use rules.break, rules.delete, rules.merge, rules.normalize, rules.selection
and rules.match to create intuitive editing experiences.
Plugin rules use specific action names to define behavior:
'default': Default Plite behavior.'reset': Changes the current block to a default paragraph, keeping content.'exit': Exits the current block, inserting a new paragraph after it. See Exit Break to learn more about this behavior.'lift': Lifts the current block out of the nearest matching ancestor container, changing one structural level at a time.'deleteExit': Deletes content then exits the block.'lineBreak': Inserts a line break (\n) instead of splitting the block.Standard Plite behavior. For rules.break, splits the block. For rules.delete, merges with the previous block.
<p>
Hello world|
</p><p>
Hello world|
</p>After pressing Enter:
<p>Hello world</p>
<p>
|
</p><p>Hello world</p>
<p>
|
</p>After pressing Backspace:
<p>Hello world|</p><p>Hello world|</p>Converts the current block to a default paragraph while preserving content. Custom properties are removed.
<h3 listStyle="disc">
|
</h3><h3 listStyle="disc">
|
</h3>After pressing Enter with rules: { break: { empty: 'reset' } }:
<p>
|
</p><p>
|
</p>Exits the current block structure by inserting a new paragraph after it.
<blockquote>
|
</blockquote><blockquote>
|
</blockquote>After pressing Enter with rules: { break: { empty: 'exit' } }:
<blockquote>
<text />
</blockquote>
<p>
|
</p><blockquote>
<text />
</blockquote>
<p>
|
</p>Lifts the current block out of the nearest matching ancestor container.
<blockquote>
<p>
|
</p>
</blockquote><blockquote>
<p>
|
</p>
</blockquote>After pressing Enter with rules: { break: { empty: 'lift' } }:
<p>
|
</p><p>
|
</p>Deletes content then exits the block.
<blockquote>
line1
|
</blockquote><blockquote>
line1
|
</blockquote>After pressing Enter with rules: { break: { emptyLineEnd: 'deleteExit' } }:
<blockquote>line1</blockquote>
<p>
|
</p><blockquote>line1</blockquote>
<p>
|
</p>Inserts a soft line break (\n) instead of splitting the block.
<blockquote>
Hello|
</blockquote><blockquote>
Hello|
</blockquote>After pressing Enter with rules: { break: { default: 'lineBreak' } }:
<blockquote>
Hello
|
</blockquote><blockquote>
Hello
|
</blockquote>Controls what happens when users press Enter within specific block types.
CalloutPlugin.configure({
rules: {
break: {
// Action when Enter is pressed normally
default: 'default' | 'lineBreak' | 'exit' | 'deleteExit',
// Action when Enter is pressed in an empty block
empty: 'default' | 'reset' | 'exit' | 'lift' | 'deleteExit',
// Action when Enter is pressed at end of empty line
emptyLineEnd: 'default' | 'exit' | 'deleteExit',
// If true, the new block after splitting will be reset
splitReset: boolean,
},
},
});CalloutPlugin.configure({
rules: {
break: {
// Action when Enter is pressed normally
default: 'default' | 'lineBreak' | 'exit' | 'deleteExit',
// Action when Enter is pressed in an empty block
empty: 'default' | 'reset' | 'exit' | 'lift' | 'deleteExit',
// Action when Enter is pressed at end of empty line
emptyLineEnd: 'default' | 'exit' | 'deleteExit',
// If true, the new block after splitting will be reset
splitReset: boolean,
},
},
});Each property controls a specific scenario:
default
empty
emptyLineEnd
splitReset: If true, resets the new block to the default type after a split. This is useful for exiting a formatted block like a heading.
Reset heading on break:
import { HeadingPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin.configure({
rules: {
break: {
splitReset: true,
},
},
}),
];import { HeadingPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin.configure({
rules: {
break: {
splitReset: true,
},
},
}),
];Before pressing Enter:
<h1>
Heading|text
</h1><h1>
Heading|text
</h1>After (split and reset):
<h1>
Heading
</h1>
<p>
|text
</p><h1>
Heading
</h1>
<p>
|text
</p>Callout with line breaks and smart exits:
import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
break: {
default: 'lineBreak',
empty: 'reset',
emptyLineEnd: 'deleteExit',
},
},
}),
];import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
break: {
default: 'lineBreak',
empty: 'reset',
emptyLineEnd: 'deleteExit',
},
},
}),
];Before pressing Enter in callout:
<callout>
Quote text|
</callout><callout>
Quote text|
</callout>After (line break):
<callout>
Quote text
|
</callout><callout>
Quote text
|
</callout>Code block with custom empty handling:
import { CodeBlockPlugin } from '@platejs/code-block/react';
const plugins = [
// ...otherPlugins,
CodeBlockPlugin.configure({
rules: {
delete: { empty: 'reset' },
match: ({ read, rule }) => {
return rule === 'delete.empty' && read.isEmpty();
},
},
}),
];import { CodeBlockPlugin } from '@platejs/code-block/react';
const plugins = [
// ...otherPlugins,
CodeBlockPlugin.configure({
rules: {
delete: { empty: 'reset' },
match: ({ read, rule }) => {
return rule === 'delete.empty' && read.isEmpty();
},
},
}),
];Before pressing Backspace in empty code block:
<codeBlock>
<codeLine>
|
</codeLine>
</codeBlock><codeBlock>
<codeLine>
|
</codeLine>
</codeBlock>After (reset):
<paragraph>
|
</paragraph><paragraph>
|
</paragraph>Controls what happens when users press Backspace at specific positions.
import { HeadingPlugin } from '@platejs/basic-nodes/react';
HeadingPlugin.configure({
rules: {
delete: {
// Action when Backspace is pressed at block start
start: 'default' | 'reset' | 'lift',
// Action when Backspace is pressed in empty block
empty: 'default' | 'reset',
},
},
});import { HeadingPlugin } from '@platejs/basic-nodes/react';
HeadingPlugin.configure({
rules: {
delete: {
// Action when Backspace is pressed at block start
start: 'default' | 'reset' | 'lift',
// Action when Backspace is pressed in empty block
empty: 'default' | 'reset',
},
},
});Each property controls a specific scenario:
Reset callouts at start:
import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
delete: { start: 'reset' },
},
}),
];import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
delete: { start: 'reset' },
},
}),
];Before pressing Backspace at start:
<callout>
|Callout content
</callout><callout>
|Callout content
</callout>After (reset):
<p>
|Callout content
</p><p>
|Callout content
</p>List items with start reset:
import { ListPlugin } from '@platejs/list/react';
const plugins = [
// ...otherPlugins,
ListPlugin.configure({
rules: {
delete: { start: 'reset' },
match: ({ rule, node }) => {
return rule === 'delete.start' && Boolean(node.listStyle);
},
},
}),
];import { ListPlugin } from '@platejs/list/react';
const plugins = [
// ...otherPlugins,
ListPlugin.configure({
rules: {
delete: { start: 'reset' },
match: ({ rule, node }) => {
return rule === 'delete.start' && Boolean(node.listStyle);
},
},
}),
];Before pressing Backspace at start of list item:
<p listStyle="disc">
|List item content
</p><p listStyle="disc">
|List item content
</p>After (reset):
<p>
|List item content
</p><p>
|List item content
</p>Controls how blocks behave when merging with previous blocks.
ParagraphPlugin.configure({
rules: {
merge: {
// Whether to remove empty blocks when merging
removeEmpty: boolean,
},
},
});ParagraphPlugin.configure({
rules: {
merge: {
// Whether to remove empty blocks when merging
removeEmpty: boolean,
},
},
});Only paragraph and heading plugins enable removal by default. Most other plugins use false:
import { HeadingPlugin } from '@platejs/basic-nodes/react';
import { ParagraphPlugin } from 'platejs/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin, // rules.merge: { removeEmpty: true } by default
ParagraphPlugin, // rules.merge: { removeEmpty: true } by default
];import { HeadingPlugin } from '@platejs/basic-nodes/react';
import { ParagraphPlugin } from 'platejs/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin, // rules.merge: { removeEmpty: true } by default
ParagraphPlugin, // rules.merge: { removeEmpty: true } by default
];Before pressing Backspace at start:
<p>
<text />
</p>
<h1>
|Heading content
</h1><p>
<text />
</p>
<h1>
|Heading content
</h1>After (empty paragraph removed):
<h1>
|Heading content
</h1><h1>
|Heading content
</h1>Callout with removal disabled:
import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
merge: { removeEmpty: false }, // Default
},
}),
];import { CalloutPlugin } from '@platejs/callout/react';
const plugins = [
// ...otherPlugins,
CalloutPlugin.configure({
rules: {
merge: { removeEmpty: false }, // Default
},
}),
];Before pressing Backspace at start:
<p>
<text />
</p>
<callout>
|Callout content
</callout><p>
<text />
</p>
<callout>
|Callout content
</callout>After (empty paragraph preserved):
<p>
|Code content
</p><p>
|Code content
</p>Table cells preserve structure during merge:
import { TablePlugin } from '@platejs/table/react';
const plugins = [
// ...otherPlugins,
TablePlugin, // Table cells have rules.merge: { removeEmpty: false }
];import { TablePlugin } from '@platejs/table/react';
const plugins = [
// ...otherPlugins,
TablePlugin, // Table cells have rules.merge: { removeEmpty: false }
];Before pressing Delete at end of paragraph:
<p>
Content|
</p>
<table>
<tr>
<td>
<p>Cell data</p>
</td>
<td>
<p>More data</p>
</td>
</tr>
</table><p>
Content|
</p>
<table>
<tr>
<td>
<p>Cell data</p>
</td>
<td>
<p>More data</p>
</td>
</tr>
</table>After (cell content merged, structure preserved):
<p>
Content|Cell data
</p>
<table>
<tr>
<td>
<p>
<text />
</p>
</td>
<td>
<p>More data</p>
</td>
</tr>
</table><p>
Content|Cell data
</p>
<table>
<tr>
<td>
<p>
<text />
</p>
</td>
<td>
<p>More data</p>
</td>
</tr>
</table>Plite's default is true since the default block (paragraph) is first-class, while Plate plugins are likely used to define other node behaviors that shouldn't automatically remove empty predecessors.
Controls how nodes are normalized during the normalization process.
LinkPlugin.configure({
rules: {
normalize: {
// Whether to remove nodes with empty text
removeEmpty: boolean,
},
},
});LinkPlugin.configure({
rules: {
normalize: {
// Whether to remove nodes with empty text
removeEmpty: boolean,
},
},
});Remove empty link nodes:
import { LinkPlugin } from '@platejs/link/react';
const plugins = [
// ...otherPlugins,
LinkPlugin.configure({
rules: {
normalize: { removeEmpty: true },
},
}),
];import { LinkPlugin } from '@platejs/link/react';
const plugins = [
// ...otherPlugins,
LinkPlugin.configure({
rules: {
normalize: { removeEmpty: true },
},
}),
];Before normalization:
<p>
<a href="http://google.com">
<text />
</a>
<cursor />
</p><p>
<a href="http://google.com">
<text />
</a>
<cursor />
</p>After normalization (empty link removed):
<p>
<cursor />
</p><p>
<cursor />
</p>The match function in plugin rules allows you to override the default behavior of specific plugins based on node properties beyond just type matching. This is particularly useful when you want to extend existing node types with new behaviors.
Code block with custom empty detection:
import { CodeBlockPlugin } from '@platejs/code-block/react';
const plugins = [
// ...otherPlugins,
CodeBlockPlugin.configure({
rules: {
delete: { empty: 'reset' },
match: ({ read, rule }) => {
return rule === 'delete.empty' && read.isEmpty();
},
},
}),
];import { CodeBlockPlugin } from '@platejs/code-block/react';
const plugins = [
// ...otherPlugins,
CodeBlockPlugin.configure({
rules: {
delete: { empty: 'reset' },
match: ({ read, rule }) => {
return rule === 'delete.empty' && read.isEmpty();
},
},
}),
];The reset rule action converts the current block to Plate's default block type. If a feature needs a different structural change, expose that behavior as a plugin transaction command and call it explicitly.
CodeBlockPlugin ships codeBlock.resetBlock(), which unwraps the current code block:
import { CodeBlockPlugin } from '@platejs/code-block/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [CodeBlockPlugin],
});
editor.update((tx) => {
tx.codeBlock.resetBlock();
});import { CodeBlockPlugin } from '@platejs/code-block/react';
import { createPlateEditor } from 'platejs/react';
const editor = createPlateEditor({
plugins: [CodeBlockPlugin],
});
editor.update((tx) => {
tx.codeBlock.resetBlock();
});You can combine different rules for comprehensive block behavior:
import { HeadingPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin.configure({
rules: {
break: {
empty: 'reset',
splitReset: true,
},
delete: {
start: 'reset',
},
},
}),
];import { HeadingPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
HeadingPlugin.configure({
rules: {
break: {
empty: 'reset',
splitReset: true,
},
delete: {
start: 'reset',
},
},
}),
];Line break behavior (default):
<blockquote>
Hello|
</blockquote><blockquote>
Hello|
</blockquote>After Enter:
<blockquote>
Hello
|
</blockquote><blockquote>
Hello
|
</blockquote>Empty reset behavior:
<blockquote>
|
</blockquote><blockquote>
|
</blockquote>After Enter:
<p>
|
</p><p>
|
</p>Start reset behavior:
<blockquote>
|Quote content
</blockquote><blockquote>
|Quote content
</blockquote>After Backspace:
<p>
|Quote content
</p><p>
|Quote content
</p>Use a Plite command handler when behavior must compose with the built-in Enter,
Delete, or text-input command pipeline. Declare the handler through the
constructor's root commands field; return false when the plugin does not own
the command.
import { editorCommands } from 'platejs';
import { defineBasePlugin } from 'platejs';
const SingleLinePlugin = defineBasePlugin('singleLine', {
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) =>
state.transaction(() => {})
),
handle(editorCommands.insertSoftBreak, ({ state }) =>
state.transaction(() => {})
),
],
});import { editorCommands } from 'platejs';
import { defineBasePlugin } from 'platejs';
const SingleLinePlugin = defineBasePlugin('singleLine', {
commands: ({ handle }) => [
handle(editorCommands.insertBreak, ({ state }) =>
state.transaction(() => {})
),
handle(editorCommands.insertSoftBreak, ({ state }) =>
state.transaction(() => {})
),
],
});This handler consumes hard and soft breaks with an empty transaction. The same command behavior applies to keyboard, programmatic, and test callers.
Controls how cursor positioning and text insertion behave at node boundaries, particularly for marks and inline elements.
BoldPlugin.configure({
rules: {
selection: {
// Define selection behavior at boundaries
affinity: 'default' | 'directional' | 'outward' | 'hard',
},
},
});BoldPlugin.configure({
rules: {
selection: {
// Define selection behavior at boundaries
affinity: 'default' | 'directional' | 'outward' | 'hard',
},
},
});The affinity property determines how the cursor behaves when positioned at the boundary between different marks or inline elements:
Uses Plite's default behavior. For marks, the cursor has outward affinity at the start edge (typing before the mark doesn't apply it) and inward affinity at the end edge (typing after the mark extends it).
At end of mark (inward affinity):
<p>
<text bold>Bold text|</text><text>Normal text</text>
</p><p>
<text bold>Bold text|</text><text>Normal text</text>
</p>Typing would extend the bold formatting to new text.
At start of mark (outward affinity):
<p>
<text>Normal text|</text><text bold>Bold text</text>
</p><p>
<text>Normal text|</text><text bold>Bold text</text>
</p>Typing would not apply bold formatting to new text.
Selection affinity is determined by the direction of cursor movement. When the cursor moves to a boundary, it maintains the affinity based on where it came from.
import { BoldPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
BoldPlugin.configure({
rules: {
selection: { affinity: 'directional' },
},
}),
];import { BoldPlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
BoldPlugin.configure({
rules: {
selection: { affinity: 'directional' },
},
}),
];Movement from right (inward affinity):
<p>
<text>Normal</text><text bold>B|old text</text>
</p><p>
<text>Normal</text><text bold>B|old text</text>
</p>After pressing ←:
<p>
<text>Normal</text><text bold>|Bold text</text>
</p><p>
<text>Normal</text><text bold>|Bold text</text>
</p>Typing would extend the bold formatting, which is not possible with default affinity.
import { LinkPlugin } from '@platejs/link/react';
const plugins = [
// ...otherPlugins,
LinkPlugin.configure({
rules: {
selection: { affinity: 'directional' },
},
}),
];import { LinkPlugin } from '@platejs/link/react';
const plugins = [
// ...otherPlugins,
LinkPlugin.configure({
rules: {
selection: { affinity: 'directional' },
},
}),
];Movement from right (outward affinity):
<p>
Visit <a href="https://example.com">our website</a> |for more information text.
</p><p>
Visit <a href="https://example.com">our website</a> |for more information text.
</p>After pressing ←:
<p>
Visit <a href="https://example.com">our website</a>| for more information text.
</p><p>
Visit <a href="https://example.com">our website</a>| for more information text.
</p>Cursor movement direction determines whether new text extends the link or creates new text outside it.
Forces outward affinity, automatically clearing marks when typing at their boundaries. This creates a natural "exit" behavior from formatted text.
import { CommentPlugin } from '@platejs/comment/react';
const plugins = [
// ...otherPlugins,
CommentPlugin.configure({
rules: {
selection: { affinity: 'outward' },
},
}),
];import { CommentPlugin } from '@platejs/comment/react';
const plugins = [
// ...otherPlugins,
CommentPlugin.configure({
rules: {
selection: { affinity: 'outward' },
},
}),
];At end of marked text:
<p>
<text comment>Commented text|</text><text>Normal</text>
</p><p>
<text comment>Commented text|</text><text>Normal</text>
</p>After typing:
<p>
<text comment>Commented text</text><text>x|Normal</text>
</p><p>
<text comment>Commented text</text><text>x|Normal</text>
</p>Users automatically exit comment formatting by typing at the end of commented text.
Creates a "hard" edge that requires two key presses to move across. This provides precise cursor control for elements that need exact positioning.
import { CodePlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
CodePlugin.configure({
rules: {
selection: { affinity: 'hard' },
},
}),
];import { CodePlugin } from '@platejs/basic-nodes/react';
const plugins = [
// ...otherPlugins,
CodePlugin.configure({
rules: {
selection: { affinity: 'hard' },
},
}),
];Moving across hard edges:
<p>
<text>Before</text><text code>code|</text><text>After</text>
</p><p>
<text>Before</text><text code>code|</text><text>After</text>
</p>First → press changes affinity:
<p>
<text>Before</text><text code>code</text>|<text>After</text>
</p><p>
<text>Before</text><text code>code</text>|<text>After</text>
</p>Second → press moves cursor:
<p>
<text>Before</text><text code>code</text><text>A|fter</text>
</p><p>
<text>Before</text><text code>code</text><text>A|fter</text>
</p>This allows users to position the cursor precisely at the boundary and choose whether new text should be inside or outside the code formatting.