Hotkeys
The default keymap, the chord syntax for plugin and app hotkeys, precedence, and how to override or disable a default binding.
Every key the editor handles goes through one keymap: your app’s bindings, then each plugin’s in list order, then the built-in ones. This page lists the defaults and shows how to add, override and disable bindings.
In the tables, Mod is Cmd on macOS and iOS and Ctrl elsewhere.
Default keymap
Editing and history
The Command column names the command onBeforeOperation sees first (see commands and steps).
| Keys | Action | Command | Where |
|---|---|---|---|
| Enter | Split the block, or add a block before or after it; what it does depends on the block’s role, see Enter and Backspace by role | splitBlock, insertBlockAfter, insertBlockBefore, addChildBlock, unNestBlock or setBlock |
Text |
| Shift + Enter | Insert a soft line break | insertText |
Text |
| Mod + Enter | Start a new block below, splitting at the end of the caret’s text. In a toggle’s header, open or close the toggle instead (Notion); the document does not change | splitBlock |
Text |
| Tab | Nest the block into its previous sibling; selected sibling blocks, or the blocks a text selection spans, nest together and stay selected. A selection across nesting levels nests each group of sibling blocks one level, as one undo step; a selected block’s selected descendants move with it. A closed toggle a block nests into opens, and a moved toggle keeps its open state | nestBlock; moveBlocks for several |
Text, or selected blocks |
| Shift + Tab | Move the block out one level, after its parent; the blocks nested after it become its children. Selected sibling blocks, or the blocks a text selection spans, move out together. Across nesting levels each group of sibling blocks moves out one level, a selected block’s selected descendants with it; top-level blocks stay. A closed toggle that takes the blocks after it as children opens | unNestBlock; moveBlocks for several |
Text, or selected blocks |
| Backspace, Delete | Delete the selected blocks, the selected atom, the selected range or one character; at a block edge, see by role | deleteBlocks, removeInlineBlock, deleteContentWithinSelection or deleteText |
Anywhere |
| Mod + Z | Undo | none (the history) | Anywhere |
| Mod + Shift + Z | Redo | none | Anywhere |
| Mod + Y | Redo | none | Windows and Linux |
Enter and Backspace by role
This table is the reference for what Enter and Backspace do at a block’s edges. A block’s role comes from its kind’s record (custom blocks); the bundled kinds are listed in Blocks. A block can have more than one role: a toggle continues and is a container.
| Role | Kinds | Enter | Backspace at the start |
|---|---|---|---|
| Plain | paragraph, heading | At the end, a block of the parent’s default child kind after it (insertBlockAfter); at the start, one before it (insertBlockBefore); in the middle, a split whose second half takes the default child kind (splitBlock). At the end of a block with text and children, a split that keeps the kind and takes the children. |
A kind the menus offer (one with presets) other than the parent’s default child, such as a heading, turns into that default, keeping its text and children (setBlock). Otherwise, a nested block that is its parent’s last child moves out one level (unNestBlock), and any other merges into the block before it (mergeBlockBackward). |
Continuing (continues) |
bulleted and numbered items, to-do, toggle | A block of the same kind with its first preset’s data (a new to-do is unchecked), after it at the end and before it at the start; a split in the middle keeps the kind. At the end of one with text and children, a split whose second half keeps the kind, with its first preset’s data (an unchecked to-do), and takes the children. In an empty one without children, it ends the run: it moves out one level when its parent is a continuing kind too (unNestBlock), else it turns into the parent’s default child in place (setBlock). |
As plain: turns into the parent’s default child first. |
Container (container) |
toggle, callout, quote | At the end of one with children, or of an open toggle, a first child of its default child kind (addChildBlock). At the end of a closed toggle, a new toggle after it; its children stay. In the middle of the header of one with children, or of an open toggle, the text after the caret becomes its first child, of its default child kind, and the children stay with the header (splitBlock, one step); in a closed toggle’s, it goes to a new toggle after it and the children stay. Otherwise as its other role. |
As plain. A closed toggle is one unit, as in Notion: Backspace at the start of the block after it merges that block into the toggle’s header (its children take its place), and Delete at the end of the header merges the block after the toggle, never the hidden children. |
Void (void) |
divider, image | A void block’s own text (an image caption) never splits. At the caption’s end or start, a block after or before it. | Backspace at the start of the block after a void block selects it; a second Backspace deletes it. Delete at the end of the block before it does the same. A void block never merges. |
Island (island) |
code | Inside, a new line (a codeLine). |
Lines merge inside the island only: the first line never joins the block before it, and with a single line, Backspace selects the whole island. With the code plugin, Delete at the end of the block before a code block removes that block when it is empty (the caret goes to the end of the text before it, or to the start of the code when nothing comes before) and does nothing otherwise, and Backspace in an empty block right after a code block removes that block and puts the caret at the end of the code. |
Selection
| Keys | Action | Where |
|---|---|---|
| Mod + A | Select the block’s text; press again to select the block, then every block | Anywhere |
| ↑, ↓ | Select the previous or next block | One selected block |
| Shift + ↑, Shift + ↓ | Add the next block to the selection, or remove the last one when moving back | Block selection |
| Shift + ↑, Shift + ↓ | Extend the text selection one line; at the end of a block, Shift + ↓ selects a following void block | Text |
| Escape | Leave the block selection, caret at the end of the first selected block | Block selection |
Arrow keys that walk a block selection do not enter an island (such as a code block) from outside, and they skip the hidden body of a closed toggle.
Navigation
Every navigation key also has a Shift variant that extends the selection instead of moving the caret.
| Keys | Moves to | Where |
|---|---|---|
| ←, → | The previous or next character, across blocks and atoms | Text |
| Alt + ←, Alt + → | The previous or next word | macOS |
| Mod + ←, Mod + → | The previous or next word | Windows and Linux |
| Home, End | The start or end of the block | Text |
| PageUp, PageDown | The start or end of the document | Text |
| Mod + ↑, Mod + ↓ | The start or end of the document | Text |
| ↑, ↓ | The previous or next line (native) | Text |
Left, right and word keys follow the visual direction of right-to-left text, and they skip the body of a closed toggle.
macOS text bindings
On macOS the Emacs-style Ctrl keys work as in native text fields:
| Keys | Action |
|---|---|
| Ctrl + A, Ctrl + E | Start or end of the block |
| Ctrl + B, Ctrl + F | Previous or next character |
| Ctrl + P, Ctrl + N | Like ↑ and ↓ |
| Ctrl + H, Ctrl + D | Delete backward or forward |
| Ctrl + K | Delete to the end of the block; at the end, join the next block |
| Ctrl + O | Insert a line break, keeping the caret before it |
Plugin keys
Rich text, arrow move and block handles are on by default in <Edytor>; the others apply when you list their plugin.
| Keys | Action | Plugin, where |
|---|---|---|
| Mod + B, I, U, E | Bold, italic, underline, inline code | Rich text |
| Mod + Shift + S, Mod + Shift + X | Strikethrough | Rich text |
| Mod + Shift + H | Red text color | Rich text |
| Mod + Enter | Check or uncheck the to-do | Rich text, in a to-do |
| Mod + Alt + 0 to 8 | Turn into text, heading 1 to 3, to-do, bulleted list, numbered list, toggle, code (a block with text or children keeps them, and the code block is inserted after it) | Rich text, when that kind’s command exists |
| Tab | Insert a tab at the caret, indent every line a selection touches, or accept a suggestion | Code, in a code line |
| Shift + Tab | Remove one leading tab (or up to two spaces) from every line the selection touches | Code, in a code line |
| Shift + Enter | New code line | Code, in a code line |
| Mod + A | Select the whole code block’s text | Code, in a non-empty code line |
| Escape | Dismiss a suggestion | Code, in a code line |
| ↑, ↓, Enter, Escape | Navigate, run, close | Slash menu, while open |
| Mod + ↑, Mod + ↓ | Move the selected blocks up or down | Arrow move, block selection |
| Mod + Shift + ↑, Mod + Shift + ↓ | Move the caret’s block (or the selected blocks) up or down | Arrow move, text or block selection |
| Mod + D | Duplicate the selected block (the first one), or else the caret’s block | Block menu |
| Alt + ↑ ↓ → ← | Move the block up, down, in, out | Block handles, handle focused |
The Mod + Alt digits follow Notion’s numbering and run the kind’s block.* command, so Mod + Alt + 8 needs the code plugin. The block menu’s own keys (arrows, Enter, Escape) belong to its search field, see Block menu.
A plugin key only claims the key in the situation listed. Otherwise the key falls through to the next binding, for example Mod + ↓ to the document end when no block is selected, or Mod + Enter to the built-in new block outside a to-do. With the arrow move plugin (a default), Mod + Shift + ↑/↓ moves a movable block instead of extending the selection to the document edge.
Adding hotkeys
A plugin declares bindings in hotkeys. The app can pass its own with the hotKeys prop of <Edytor>. Both take the same functions:
import type { Plugin } from 'edytor';
export const savePlugin: Plugin = () => ({
hotkeys: {
'mod+s': ({ edytor, prevent }) => {
prevent(() => {
localStorage.setItem('doc', JSON.stringify(edytor.value));
});
}
}
});
A binding receives { edytor, prevent, event? }:
prevent()claims the key: later bindings do not run, and the browser’s default action and propagation are stopped.prevent(cb)claims it and runscb. Like everyprevent, it throws, so code after it does not run.- A binding that does not call
preventleaves the key to the next binding, and finally to the browser. eventis theKeyboardEvent. It is absent when the key arrived only as an input event, as with some virtual keyboards on Android.
The hotKeys prop is read once, when the editor mounts.
Chord syntax
A chord is lowercase modifiers and one key, joined with +:
- Modifiers:
mod,alt,ctrl,shift, up to three. Their order does not matter:shift+mod+kandmod+shift+kare the same chord. Case does not matter either at runtime, but the types expect lowercase. - Keys: the letters
atoz, the digits0to9, the punctuation keys (/,.,[,+, …, asKeyboardEvent.keynames them),arrowup,arrowdown,arrowleft,arrowright,tab,enter,backspace,delete,space,escape,home,end,pageup,pagedown,insert,f1tof12.spaceis the space bar. - Other names (
cmd,meta,option,esc,return,del,up…) never match a key press. They fail type-checking, and in development the editor logs a warning naming the chord. modis Cmd on Apple platforms and Ctrl elsewhere.ctrlmatches the Ctrl key only on Apple platforms; elsewhere Ctrl is alwaysmod.- On non-Latin keyboard layouts, a Mod chord also matches by physical key: Mod + Б on a Russian layout runs
mod+b. - AltGr combinations and dead keys produce text and never run a binding.
- A punctuation key is the character the key press types. A character typed with Shift needs
shiftin the chord: Mod + Shift + / ismod+shift+?, somod+?andshift+/never match. On macOS, Option with a character key types a character (Option + B is∫), soalt+bnever matches there; bindmod+alt+binstead. In development the editor logs a warning for these chords too.
A plugin’s hotkeys and the hotKeys prop are type-checked against this syntax (HotKeyCombination).
Enter, Shift + Enter, Backspace and Delete reach bindings too, so a plugin can claim them, as the slash menu does with Enter. Each key press runs its bindings once.
Precedence
For each chord, the bindings run in this order until one calls prevent:
- the
hotKeysprop of<Edytor>; - each plugin’s
hotkeys, in the order of thepluginsarray; - the built-in bindings.
A binding that is not a function, such as an optional callback left undefined ('mod+e': onInlineCode), is skipped: the chord’s next binding runs, and in development the editor logs a warning naming the chord. TypeScript accepts undefined for these optional keys, so add an optional callback only when it is set: ...(onInlineCode && { 'mod+e': onInlineCode }).
Overriding and disabling defaults
To override a default, bind the same chord in the hotKeys prop or in a plugin, and call prevent:
<script lang="ts">
import { Edytor } from 'edytor';
let saved = $state(0);
</script>
<Edytor
hotKeys={{
// Mod+Enter saves instead of starting a new block.
'mod+enter': ({ prevent }) => prevent(() => saved++),
// Tab never nests: claim it and do nothing.
tab: ({ prevent }) => prevent(),
// Mod+B does nothing in headings; elsewhere the rich text binding runs.
'mod+b': ({ edytor, prevent }) => {
if (edytor.selection.state.startBlock?.type === 'heading') prevent();
}
}}
/>
prevent() without a callback disables the key in the editor. It also prevents the browser’s default, so a disabled Tab does not move focus out of the editor either. There is no way to remove a built-in binding and hand the key back to the browser.