From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
  • Feature Kits
  • Plugin
    • Plugin Methods
    • Plugin Shortcuts
    • Plugin Context
    • Plugin Components
    • Plugin Rules
    • Editing Behavior
    • Plugin Input Rules
  • Editor
    • Editor Methods
    • Controlled Value
  • Performance
  • Static Rendering
  • HTML
  • Markdown
  • Form
  • TypeScript
  • Debugging
  • Unit Testing
  • Browser
  • Troubleshooting

Editing Behavior

PreviousNext

How Plate handles Enter, Backspace, merge, normalize, and selection behavior.

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.

On This Page

  • Choose the Right Surface
  • Runtime Pipeline
  • Break Behavior
  • Delete Behavior
  • Merge Behavior
  • Normalize Behavior
  • Selection Behavior
Plugin RulesPlugin Input Rules

On This Page

On This PageChoose the Right SurfaceRuntime PipelineBreak BehaviorDelete BehaviorMerge BehaviorNormalize BehaviorSelection BehaviorRecipesAPI Reference
Build your editor
Production-ready AI template and reusable components.
Get all-access
  • Recipes
  • API Reference
  • Choose the Right Surface

    Most editing behavior belongs in plugin.rules. Reach for an explicit transaction command or Plite extension only when the rule table cannot express the behavior.

    NeedUse
    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 Plite 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.

    Runtime Pipeline

    Plate resolves plugins first, then installs the Plite schema and extension fragments contributed by those plugins.

    LayerOwnerHandles
    OverridePluginCore runtimeNode flags, break rules, delete rules, merge rules, normalize rules.
    AffinityPluginCore runtimerules.selection for mark and inline boundaries.
    Feature pluginsFeature packagesDefault rules for headings, callouts, lists, links, tables, marks, and other nodes.
    App pluginsYour appLocal rules, explicit transaction commands, and Plite command or correction extensions.

    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 cleanup
    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 cleanup

    Input 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."

    Break Behavior

    rules.break controls the break transaction that Enter calls.

    Plate checks the current block and handles these cases in order:

    CaseRuleWhat happens
    Empty collapsed blockbreak.emptyRuns reset, exit, lift, deleteExit, or falls through.
    Cursor after a trailing newlinebreak.emptyLineEndRuns exit, deleteExit, or falls through.
    Normal Enterbreak.defaultRuns lineBreak, exit, deleteExit, or falls through.
    Split created a new blockbreak.splitResetResets the new block to the default type.

    Use splitReset for blocks that should not keep their type after a normal split.

    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/react';
     
    export const AppHeadingPlugin = HeadingPlugin.configure({
      rules: {
        break: {
          splitReset: true,
        },
      },
    });
    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/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.

    plugins.tsx
    import { CalloutPlugin } from '@platejs/callout/react';
     
    export const AppCalloutPlugin = CalloutPlugin.configure({
      rules: {
        break: {
          default: 'lineBreak',
          empty: 'reset',
          emptyLineEnd: 'deleteExit',
        },
      },
    });
    plugins.tsx
    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.

    Delete Behavior

    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:

    CaseRuleWhat happens
    Cursor at the start of the current blockdelete.startRuns reset, lift, or falls through.
    Current block is emptydelete.emptyRuns reset or falls through.
    Cursor is at the start of the documentCore defaultResets the first block.
    Nothing handled the casePlite commandDelegates to the built-in delete command.

    Use start: 'reset' for formatted text blocks that should become paragraphs before they merge into the previous block.

    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/react';
     
    export const AppHeadingPlugin = HeadingPlugin.configure({
      rules: {
        delete: {
          start: 'reset',
        },
      },
    });
    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/react';
     
    export const AppHeadingPlugin = HeadingPlugin.configure({
      rules: {
        delete: {
          start: 'reset',
        },
      },
    });

    Use start: 'lift' for nested blocks that should move out one ancestor level.

    plugins.tsx
    import { schema } from 'platejs';
    import { definePlatePlugin } from 'platejs/react';
     
    export const QuoteItemPlugin = definePlatePlugin('quoteItem', {
      rules: {
        delete: {
          start: 'lift',
        },
      },
      schema: {
        element: {
          content: schema.content.text({ default: 'text', min: 1 }),
          type: 'quote_item',
        },
      },
    });
    plugins.tsx
    import { schema } from 'platejs';
    import { definePlatePlugin } from 'platejs/react';
     
    export const QuoteItemPlugin = definePlatePlugin('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

    Merge behavior decides whether two nodes can join and whether empty nodes at the boundary disappear.

    tx.nodes.merge(...) evaluates Plite's internal editorReads.nodes.shouldMergeNodesRemovePrevNode descriptor before it applies the merge. Plate's merge-rule middleware adds three important guards:

    CaseBehavior
    Empty text node before the merge pointRemove it when it is not the first child.
    Empty previous siblingRemove it only when the owning plugin has rules.merge.removeEmpty: true.
    Target node is voidDo 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.

    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/react';
     
    export const AppHeadingPlugin = HeadingPlugin.configure({
      rules: {
        merge: {
          removeEmpty: true,
        },
      },
    });
    plugins.tsx
    import { HeadingPlugin } from '@platejs/basic-nodes/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.

    plugins.tsx
    import { CalloutPlugin } from '@platejs/callout/react';
     
    export const StableCalloutPlugin = CalloutPlugin.configure({
      rules: {
        merge: {
          removeEmpty: false,
        },
      },
    });
    plugins.tsx
    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.table.merge() and tx.table.split().

    Normalize Behavior

    rules.normalize runs during Plite 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.

    plugins.tsx
    import { LinkPlugin } from '@platejs/link/react';
     
    export const AppLinkPlugin = LinkPlugin.configure({
      rules: {
        normalize: {
          removeEmpty: true,
        },
      },
    });
    plugins.tsx
    import { LinkPlugin } from '@platejs/link/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 Plite correction and contribute it through the constructor's root corrections field so the behavior is explicit and testable.

    Selection Behavior

    rules.selection controls how marks and inline-like boundaries behave while typing, deleting, and moving the cursor.

    AffinityUse for
    defaultNormal Plite boundary behavior.
    directionalLinks and highlights where cursor direction decides whether typed text stays inside.
    outwardComment and suggestion marks where edge typing should leave the mark.
    hardBoundaries 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 Plite's schema before selection and transaction logic runs.

    Recipes

    BehaviorConfigure
    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.

    API Reference

    SurfaceOwnerReference
    rules.breakCore rule engine plus feature plugin definitionPlugin Rules
    rules.deleteCore rule engine plus feature plugin definitionPlugin Rules
    rules.mergeCore rule engine plus feature plugin definitionPlugin Rules
    rules.normalizeCore rule engine plus feature plugin definitionPlugin Rules
    rules.selectionAffinity core plugin plus feature plugin definitionPlugin Rules
    rules.matchCore rule lookup plus feature plugin definitionPlugin Rules
    tx.nodes.merge(...)Plite transaction commandTransforms
    editorReads.nodes.shouldMergeNodesRemovePrevNodeInternal Plite descriptor extended by Plate merge-rule middlewarePlugin Rules
    tx.table.merge()Table feature packageTable

    Done. Rules describe the policy; transaction commands and corrections do the work.