List Indent
ListIndent extends the Tab and Shift-Tab keyboard shortcuts to handle the boundary between top-level blocks and lists. ListKeymap continues to own in-list Tab / Shift-Tab (sinkListItem / liftListItem); ListIndent only fires when the cursor is in a top-level block immediately after a list (indent IN) or in a block at the end of a list item’s children zone (outdent OUT).
Off by default. StarterKit does not register ListIndent, because Tab on a paragraph that merely follows a list would otherwise capture focus (pull the paragraph into the list) instead of moving to the next field - a surprise in embedded / form usage. Opt in either by adding the extension directly (as in the Quickstart below) or with StarterKit.configure({ listIndent: true }). ListKeymap (in-list Tab/Shift-Tab) stays on regardless.
When to use
Section titled “When to use”Use ListIndent when you need:
- Tab to sink the current list item one nesting level (becomes a sub-item)
- Shift+Tab to lift the item out one level
Skip it if:
- Your lists are flat by design and nesting would create unwanted hierarchy
- You want to override Tab behavior globally (build a custom keymap)
Live Playground
Section titled “Live Playground”Click into the paragraph below the list and press Tab to indent it as a nested child of the last list item. Press Shift-Tab on a nested block to lift it out as a top-level paragraph.
Quickstart
Section titled “Quickstart”import { Editor, StarterKit, ListIndent } from '@domternal/core';import '@domternal/theme';
const editor = new Editor({ element: document.getElementById('editor')!, extensions: [ StarterKit, // includes ListKeymap ListIndent, // adds Tab/Shift-Tab at list boundaries ],});Behavior
Section titled “Behavior”Tab: indent into the previous list
Section titled “Tab: indent into the previous list”When the cursor is at a top-level block (depth=1, e.g. a paragraph below a bullet list) whose previous sibling is a list wrapper (bulletList, orderedList, taskList), pressing Tab moves the block INTO that list’s last item as a nested child.
Before: After Tab:<ul> <ul> <li>First item</li> <li>First item</li></ul> <li><p>This paragraph[cursor]</p> <p>Cursor moves here</p> <p>This paragraph[cursor]</p> </li> </ul>The block becomes the last child of the last item.
Tab also accepts a NodeSelection on a top-level atom block: select a horizontal rule or an image sitting right after the list and press Tab to move it into the last item the same way.
Shift-Tab: lift out as top-level sibling
Section titled “Shift-Tab: lift out as top-level sibling”The reverse: when the cursor is inside a nested block that is:
- the LAST child of its list item, AND
- NOT the FIRST child of its list item (index 0 is the label slot, whatever node type sits there), AND
- the cursor is empty
Then Shift-Tab lifts the block OUT to parent level. When the item is the last in its wrapper the block lands right after the whole list; otherwise the wrapper is split after that item and the block lands between the two halves, keeping the parent item and the remaining items intact (Notion parity).
The block that moves is the direct child of the list item that contains the cursor. With the cursor in a paragraph inside a blockquote that sits in the children zone, the blockquote is the block that lifts.
Before: After Shift-Tab:<ul> <ul> <li> <li> <p>First item</p> <p>First item</p> <p>nested[cursor]</p> </li> </li> </ul></ul> <p>nested[cursor]</p>When the item is not the last one, the wrapper splits after it:
Before: After Shift-Tab:<ul> <ul> <li> <li> <p>First item</p> <p>First item</p> <p>nested[cursor]</p> </li> </li> </ul> <li><p>Second</p></li> <p>nested[cursor]</p></ul> <ul> <li><p>Second</p></li> </ul>Intentional restrictions
Section titled “Intentional restrictions”These are by design, not bugs to “fix”:
| Restriction | Why |
|---|---|
| Tab indents into the immediate last item only, not recursively into a “deepest last item” | Users get deeper nesting via repeated Tab inside the now-nested context (then ListKeymap takes over) |
| Tab only fires for cursors in top-level blocks (depth=1). Cursors inside blockquote, table cell, etc. fall through | Avoids unwanted re-routing in containers that have their own Tab semantics |
| Shift-Tab requires the block to be the last child of its item. A block in the MIDDLE of the children zone is deferred | Re-homing the trailing siblings inside the item would need extra schema work; it falls through to the rest of the keymap chain |
| Shift-Tab never fires for the first child of a list item, whatever its node type (paragraph, heading, blockquote) | Index 0 is the item label; lifting it would dissolve the item, which is exactly what liftListItem does, so ListKeymap owns label-position cursors |
Underlying functions
Section titled “Underlying functions”Both handlers are exported (advanced use):
import { indentBlockAsListChild, outdentBlockFromListItem,} from '@domternal/core';
function indentBlockAsListChild( state: EditorState, dispatch?: (tr: Transaction) => void,): boolean;
function outdentBlockFromListItem( state: EditorState, dispatch?: (tr: Transaction) => void,): boolean;Both return true when the operation succeeded (and dispatched if dispatch was provided), or false when any precondition fails so the keymap chain can fall through.
Schema safety
Section titled “Schema safety”Both functions validate via canReplaceWith before dispatching. Invalid placements are a clean no-op (false return) so the keymap chain falls through to the next handler.
Interaction with ListKeymap
Section titled “Interaction with ListKeymap”- Tab on a list-item label or in-list content ->
ListKeymap.sinkListItemruns (ListIndent’s Tab returns false) - Tab on a top-level paragraph after a list ->
ListIndent.indentBlockAsListChildruns - Shift-Tab on a list-item label ->
ListKeymap.liftListItemruns - Shift-Tab on a nested last-child block of a list item ->
ListIndent.outdentBlockFromListItemruns (splitting the list when the item is not the last one) - Shift-Tab elsewhere in lists -> ListKeymap continues to own the in-list outdent
Together they cover every Tab/Shift-Tab case in and around lists.
Exports
Section titled “Exports”import { ListIndent, indentBlockAsListChild, outdentBlockFromListItem,} from '@domternal/core';Source
Section titled “Source”@domternal/core - ListIndent.ts
See also
Section titled “See also”- List Keymap - Backspace/Enter behavior for list items
- List Item - list item used by bullet/ordered/task lists
- Bullet List - unordered list with bullet markers