So far, the formatting examples have lived directly inside event handlers. That works for a tiny editor, but it gets repetitive once the same behavior is used from keyboard shortcuts, toolbar buttons, menu items, or tests.
A command is just reusable editor logic. Keep command reads in
editor.read(...) and command writes in editor.update(...).
Extracting Commands
Start by moving the bold and code-block logic into plain functions:
import { ElementApi, type Editor } from '@platejs/slate'
const isBoldActive = (editor: Editor) => {
return editor.read(state => state.marks.get()?.bold === true)
}
const isCodeBlockActive = (editor: Editor) => {
return editor.read(state => {
const match = state.nodes.find({
match: node => ElementApi.isElement(node) && node.type === 'code',
})
return Boolean(match)
})
}
const toggleBold = (editor: Editor) => {
editor.update(tx => {
tx.marks.toggle('bold')
})
}
const toggleCodeBlock = (editor: Editor) => {
const isActive = isCodeBlockActive(editor)
editor.update(tx => {
tx.nodes.set(
{ type: isActive ? 'paragraph' : 'code' },
{
match: node => ElementApi.isElement(node) && !tx.schema.isInline(node),
}
)
})
}import { ElementApi, type Editor } from '@platejs/slate'
const isBoldActive = (editor: Editor) => {
return editor.read(state => state.marks.get()?.bold === true)
}
const isCodeBlockActive = (editor: Editor) => {
return editor.read(state => {
const match = state.nodes.find({
match: node => ElementApi.isElement(node) && node.type === 'code',
})
return Boolean(match)
})
}
const toggleBold = (editor: Editor) => {
editor.update(tx => {
tx.marks.toggle('bold')
})
}
const toggleCodeBlock = (editor: Editor) => {
const isActive = isCodeBlockActive(editor)
editor.update(tx => {
tx.nodes.set(
{ type: isActive ? 'paragraph' : 'code' },
{
match: node => ElementApi.isElement(node) && !tx.schema.isInline(node),
}
)
})
}These functions are not added to the editor object. They are normal JavaScript functions that receive an editor.
Using Commands From Editor Events
Use Editable onKeyDown for keyboard shortcuts that belong to one editor UI:
import { Editable, Slate, useSlateEditor } from '@platejs/slate-react'
const App = () => {
const editor = useSlateEditor({ initialValue })
return (
<Slate editor={editor}>
<Editable
onKeyDown={(event, { editor }) => {
if (event.key === '`' && event.ctrlKey) {
event.preventDefault()
toggleCodeBlock(editor)
return true
}
if (event.key === 'b' && event.ctrlKey) {
event.preventDefault()
toggleBold(editor)
return true
}
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
/>
</Slate>
)
}import { Editable, Slate, useSlateEditor } from '@platejs/slate-react'
const App = () => {
const editor = useSlateEditor({ initialValue })
return (
<Slate editor={editor}>
<Editable
onKeyDown={(event, { editor }) => {
if (event.key === '`' && event.ctrlKey) {
event.preventDefault()
toggleCodeBlock(editor)
return true
}
if (event.key === 'b' && event.ctrlKey) {
event.preventDefault()
toggleBold(editor)
return true
}
}}
renderElement={renderElement}
renderLeaf={renderLeaf}
/>
</Slate>
)
}Use extension transforms for behavior that maps to Slate transform names.
That keeps model behavior available to keyboard input, native input, toolbar
logic, programmatic calls, and tests.
import { defineEditorExtension, ElementApi, PointApi, RangeApi } from '@platejs/slate'
const markdownBlocks = defineEditorExtension({
name: 'markdown-blocks',
transforms: {
deleteBackward({ editor, next, unit }) {
const selection = editor.read(state => state.selection.get())
if (
unit === 'character' &&
selection &&
RangeApi.isCollapsed(selection)
) {
const blockEntry = editor.read(state =>
state.nodes.above({
at: selection,
match: node =>
ElementApi.isElement(node) && state.nodes.isBlock(node),
})
)
if (blockEntry) {
const [block, blockPath] = blockEntry
const start = editor.read(state => state.points.start(blockPath))
if (
ElementApi.isElement(block) &&
block.type !== 'paragraph' &&
PointApi.equals(selection.anchor, start)
) {
editor.update(tx => {
tx.nodes.set(
{ type: 'paragraph' },
{
at: blockPath,
match: node =>
ElementApi.isElement(node) && tx.nodes.isBlock(node),
}
)
})
return true
}
}
}
return next({ unit })
},
insertBreak({ editor, next }) {
const selection = editor.read(state => state.selection.get())
if (selection && RangeApi.isCollapsed(selection)) {
const blockEntry = editor.read(state =>
state.nodes.above({
at: selection,
match: node =>
ElementApi.isElement(node) && state.nodes.isBlock(node),
})
)
if (blockEntry) {
const [block, blockPath] = blockEntry
if (ElementApi.isElement(block) && block.type === 'heading-one') {
const start = editor.read(state => state.points.start(blockPath))
if (PointApi.equals(selection.anchor, start)) {
const result = next()
editor.update(tx => {
tx.nodes.set(
{ type: 'paragraph' },
{
at: blockPath,
match: node =>
ElementApi.isElement(node) && tx.nodes.isBlock(node),
}
)
})
return result
}
}
}
}
return next()
},
},
})
const App = () => {
const editor = useSlateEditor({
extensions: [markdownBlocks],
initialValue,
})
return (
<Slate editor={editor}>
<Editable renderElement={renderElement} renderLeaf={renderLeaf} />
</Slate>
)
}import { defineEditorExtension, ElementApi, PointApi, RangeApi } from '@platejs/slate'
const markdownBlocks = defineEditorExtension({
name: 'markdown-blocks',
transforms: {
deleteBackward({ editor, next, unit }) {
const selection = editor.read(state => state.selection.get())
if (
unit === 'character' &&
selection &&
RangeApi.isCollapsed(selection)
) {
const blockEntry = editor.read(state =>
state.nodes.above({
at: selection,
match: node =>
ElementApi.isElement(node) && state.nodes.isBlock(node),
})
)
if (blockEntry) {
const [block, blockPath] = blockEntry
const start = editor.read(state => state.points.start(blockPath))
if (
ElementApi.isElement(block) &&
block.type !== 'paragraph' &&
PointApi.equals(selection.anchor, start)
) {
editor.update(tx => {
tx.nodes.set(
{ type: 'paragraph' },
{
at: blockPath,
match: node =>
ElementApi.isElement(node) && tx.nodes.isBlock(node),
}
)
})
return true
}
}
}
return next({ unit })
},
insertBreak({ editor, next }) {
const selection = editor.read(state => state.selection.get())
if (selection && RangeApi.isCollapsed(selection)) {
const blockEntry = editor.read(state =>
state.nodes.above({
at: selection,
match: node =>
ElementApi.isElement(node) && state.nodes.isBlock(node),
})
)
if (blockEntry) {
const [block, blockPath] = blockEntry
if (ElementApi.isElement(block) && block.type === 'heading-one') {
const start = editor.read(state => state.points.start(blockPath))
if (PointApi.equals(selection.anchor, start)) {
const result = next()
editor.update(tx => {
tx.nodes.set(
{ type: 'paragraph' },
{
at: blockPath,
match: node =>
ElementApi.isElement(node) && tx.nodes.isBlock(node),
}
)
})
return result
}
}
}
}
return next()
},
},
})
const App = () => {
const editor = useSlateEditor({
extensions: [markdownBlocks],
initialValue,
})
return (
<Slate editor={editor}>
<Editable renderElement={renderElement} renderLeaf={renderLeaf} />
</Slate>
)
}Using Commands From UI
The same functions can be called from toolbar buttons:
const Toolbar = ({ editor }) => {
return (
<div>
<button
onMouseDown={event => {
event.preventDefault()
toggleBold(editor)
}}
>
Bold
</button>
<button
onMouseDown={event => {
event.preventDefault()
toggleCodeBlock(editor)
}}
>
Code Block
</button>
</div>
)
}const Toolbar = ({ editor }) => {
return (
<div>
<button
onMouseDown={event => {
event.preventDefault()
toggleBold(editor)
}}
>
Bold
</button>
<button
onMouseDown={event => {
event.preventDefault()
toggleCodeBlock(editor)
}}
>
Code Block
</button>
</div>
)
}Extension Commands
Plain functions are enough for app code. Extensions can expose typed state
and tx namespaces when a behavior needs to be shared across editors.
Raw Slate does not ship product commands like lists, headings, or links. Those belong in extensions or higher-level frameworks.