{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "editing-behavior-docs",
  "title": "Editing Behavior",
  "description": "How Plate handles Enter, Backspace, merge, normalize, and selection behavior.",
  "files": [
    {
      "path": "../../content/docs/(guides)/editing-behavior.mdx",
      "content": "---\ntitle: Editing Behavior\ndescription: How Plate handles Enter, Backspace, merge, normalize, and selection behavior.\n---\n\nEditing behavior is the path from a key press or transform to the final document shape. Use [Plugin Rules](/docs/plugin-rules) for declarative node policy, and use [Editor Methods](/docs/editor-methods) when you need an imperative transform. This guide shows how break, delete, merge, normalize, and selection behavior fit together.\n\n## On This Page\n\n- [Choose the Right Surface](#choose-the-right-surface)\n- [Runtime Pipeline](#runtime-pipeline)\n- [Break Behavior](#break-behavior)\n- [Delete Behavior](#delete-behavior)\n- [Merge Behavior](#merge-behavior)\n- [Normalize Behavior](#normalize-behavior)\n- [Selection Behavior](#selection-behavior)\n- [Recipes](#recipes)\n- [API Reference](#api-reference)\n\n## Choose the Right Surface\n\nMost editing behavior belongs in `plugin.rules`. Reach for custom transforms only when the rule table cannot express the behavior.\n\n| Need | Use |\n| --- | --- |\n| Change how `Enter` works in a node. | `rules.break` |\n| Change how `Backspace` works at the start of a block. | `rules.delete` |\n| Decide whether an empty sibling disappears during a merge. | `rules.merge` |\n| Remove empty nodes during normalization. | `rules.normalize` |\n| Control mark or inline boundaries while typing and moving. | `rules.selection` |\n| Apply one plugin's rules to another node type. | `rules.match` |\n| Run product-specific behavior that rules cannot express. | `.overrideEditor()` or an explicit `editor.tf.*` command |\n\nRules keep common editor policy close to the plugin that owns the node. Transforms are still the right tool for commands, toolbar actions, and behavior that depends on app state.\n\n## Runtime Pipeline\n\nPlate resolves plugins first, then core plugins wrap Slate APIs and transforms.\n\n| Layer | Owner | Handles |\n| --- | --- | --- |\n| `OverridePlugin` | Core runtime | Node flags, break rules, delete rules, merge rules, normalize rules. |\n| `AffinityPlugin` | Core runtime | `rules.selection` for mark and inline boundaries. |\n| Feature plugins | Feature packages | Default rules for headings, callouts, lists, links, tables, marks, and other nodes. |\n| App plugins | Your app | Local overrides, custom node policy, and custom transforms. |\n\nThe normal flow is:\n\n```txt\nkey press or command\n  -> optional input rule for typed patterns\n  -> plugin rule lookup for the current node\n  -> editor.tf transform\n  -> merge guard when nodes are joined\n  -> normalization\n  -> selection affinity cleanup\n```\n\nInput 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.\"\n\n## Break Behavior\n\n`rules.break` controls `editor.tf.insertBreak()`, which is what `Enter` calls.\n\nPlate checks the current block and handles these cases in order:\n\n| Case | Rule | What happens |\n| --- | --- | --- |\n| Empty collapsed block | `break.empty` | Runs `reset`, `exit`, `lift`, `deleteExit`, or falls through. |\n| Cursor after a trailing newline | `break.emptyLineEnd` | Runs `exit`, `deleteExit`, or falls through. |\n| Normal Enter | `break.default` | Runs `lineBreak`, `exit`, `deleteExit`, or falls through. |\n| Split created a new block | `break.splitReset` | Resets the new block to the default type. |\n\nUse `splitReset` for blocks that should not keep their type after a normal split.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { H1Plugin } from '@platejs/basic-nodes/react';\n\nexport const AppH1Plugin = H1Plugin.configure({\n  rules: {\n    break: {\n      splitReset: true,\n    },\n  },\n});\n```\n\nUse `lineBreak` and `deleteExit` for container-like blocks that need soft lines before they leave the block.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { CalloutPlugin } from '@platejs/callout/react';\n\nexport const AppCalloutPlugin = CalloutPlugin.configure({\n  rules: {\n    break: {\n      default: 'lineBreak',\n      empty: 'reset',\n      emptyLineEnd: 'deleteExit',\n    },\n  },\n});\n```\n\nThat callout keeps normal Enter inside the callout, resets empty callouts to paragraphs, and exits after a trailing empty line.\n\n## Delete Behavior\n\n`rules.delete` controls collapsed `Backspace` behavior. Expanded selections still delete through the normal fragment path unless the whole editor is selected.\n\nPlate checks collapsed `deleteBackward` in this order:\n\n| Case | Rule | What happens |\n| --- | --- | --- |\n| Cursor at the start of the current block | `delete.start` | Runs `reset`, `lift`, or falls through. |\n| Current block is empty | `delete.empty` | Runs `reset` or falls through. |\n| Cursor is at the start of the document | Core default | Resets the first block. |\n| Nothing handled the case | Slate transform | Runs the original delete transform. |\n\nUse `start: 'reset'` for formatted text blocks that should become paragraphs before they merge into the previous block.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { H1Plugin } from '@platejs/basic-nodes/react';\n\nexport const AppH1Plugin = H1Plugin.configure({\n  rules: {\n    delete: {\n      start: 'reset',\n    },\n  },\n});\n```\n\nUse `start: 'lift'` for nested blocks that should move out one ancestor level.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { createPlatePlugin } from 'platejs/react';\n\nexport const QuoteItemPlugin = createPlatePlugin({\n  key: 'quote_item',\n  node: {\n    isElement: true,\n  },\n  rules: {\n    delete: {\n      start: 'lift',\n    },\n  },\n});\n```\n\nWhen a selection spans multiple blocks, Plate deletes the selected content and then calls `editor.tf.mergeNodes()` at the end boundary. That means cross-block deletion can still enter the merge pipeline below.\n\n## Merge Behavior\n\nMerge behavior decides whether two nodes can join and whether empty nodes at the boundary disappear.\n\n`editor.tf.mergeNodes()` calls `editor.api.shouldMergeNodes(prev, next, options)` before it applies the merge. Plate's merge rules add three important guards:\n\n| Case | Behavior |\n| --- | --- |\n| Empty text node before the merge point | Remove it when it is not the first child. |\n| Empty previous sibling | Remove it only when the owning plugin has `rules.merge.removeEmpty: true`. |\n| Target node is void | Do not delete the void target by default; remove the current empty node instead when possible. |\n\nUse `removeEmpty: true` for text-like blocks such as paragraphs and headings.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { H1Plugin } from '@platejs/basic-nodes/react';\n\nexport const AppH1Plugin = H1Plugin.configure({\n  rules: {\n    merge: {\n      removeEmpty: true,\n    },\n  },\n});\n```\n\nKeep `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.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { CalloutPlugin } from '@platejs/callout/react';\n\nexport const StableCalloutPlugin = CalloutPlugin.configure({\n  rules: {\n    merge: {\n      removeEmpty: false,\n    },\n  },\n});\n```\n\n<Callout>\n  Merge rules are not table cell merge commands. `rules.merge` protects\n  document structure during node joins. Table cell merge and split commands live\n  on `editor.tf.table.merge()` and `editor.tf.table.split()`.\n</Callout>\n\n## Normalize Behavior\n\n`rules.normalize` runs during Slate normalization.\n\nUse `normalize.removeEmpty` for elements that should not exist without text content. Links use this shape because an empty link has no useful editing surface.\n\n```tsx title=\"plugins.tsx\" showLineNumbers\nimport { LinkPlugin } from '@platejs/link/react';\n\nexport const AppLinkPlugin = LinkPlugin.configure({\n  rules: {\n    normalize: {\n      removeEmpty: true,\n    },\n  },\n});\n```\n\nKeep normalization rules boring. If a node needs a rich repair strategy, write a dedicated normalizer with `.overrideEditor()` so the behavior is explicit and testable.\n\n## Selection Behavior\n\n`rules.selection` controls how marks and inline-like boundaries behave while typing, deleting, and moving the cursor.\n\n| Affinity | Use for |\n| --- | --- |\n| `default` | Normal Slate boundary behavior. |\n| `directional` | Links and highlights where cursor direction decides whether typed text stays inside. |\n| `outward` | Comment and suggestion marks where edge typing should leave the mark. |\n| `hard` | Boundaries that should take an extra arrow-key step to cross. |\n\nNode flags also affect editing behavior. `node.isInline`, `node.isVoid`, `node.isSelectable`, and `node.isMarkableVoid` are resolved by the core override layer before selection and transform logic runs.\n\n## Recipes\n\n| Behavior | Configure |\n| --- | --- |\n| Heading resets to paragraph on Backspace. | `rules.delete.start: 'reset'` |\n| Heading splits into paragraph on Enter. | `rules.break.splitReset: true` |\n| Callout keeps Enter inside the block. | `rules.break.default: 'lineBreak'` |\n| Empty callout becomes a paragraph. | `rules.break.empty: 'reset'` or `rules.delete.start: 'reset'` |\n| Nested item outdents on Backspace. | `rules.delete.start: 'lift'` |\n| Empty text block disappears during merge. | `rules.merge.removeEmpty: true` |\n| Structural wrapper survives merge. | `rules.merge.removeEmpty: false` |\n| Empty inline element disappears. | `rules.normalize.removeEmpty: true` |\n| Link boundary follows cursor direction. | `rules.selection.affinity: 'directional'` |\n\nFor 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.\n\n## API Reference\n\n| Surface | Owner | Reference |\n| --- | --- | --- |\n| `rules.break` | Core rule engine plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesbreak) |\n| `rules.delete` | Core rule engine plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesdelete) |\n| `rules.merge` | Core rule engine plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesmerge) |\n| `rules.normalize` | Core rule engine plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesnormalize) |\n| `rules.selection` | Affinity core plugin plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesselection) |\n| `rules.match` | Core rule lookup plus feature plugin config | [Plugin Rules](/docs/plugin-rules#rulesmatch) |\n| `editor.tf.mergeNodes()` | Slate transform patched by Plate | [Editor Transforms](/docs/api/slate/editor-transforms#mergenodes) |\n| `editor.api.shouldMergeNodes()` | Slate editor API patched by Plate | [Editor API](/docs/api/slate/editor-api#shouldmergenodes) |\n| `editor.tf.table.merge()` | Table feature package | [Table](/docs/table#editing-behavior) |\n\nDone. Rules describe the policy; transforms do the work.\n",
      "type": "registry:file",
      "target": "content/docs/plate/(guides)/editing-behavior.mdx"
    }
  ],
  "type": "registry:file"
}