Skip to content

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.

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)

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.

Click to try it out
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
],
});

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.

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>

These are by design, not bugs to “fix”:

RestrictionWhy
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 throughAvoids 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 deferredRe-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

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.

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.

  • Tab on a list-item label or in-list content -> ListKeymap.sinkListItem runs (ListIndent’s Tab returns false)
  • Tab on a top-level paragraph after a list -> ListIndent.indentBlockAsListChild runs
  • Shift-Tab on a list-item label -> ListKeymap.liftListItem runs
  • Shift-Tab on a nested last-child block of a list item -> ListIndent.outdentBlockFromListItem runs (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.

import {
ListIndent,
indentBlockAsListChild,
outdentBlockFromListItem,
} from '@domternal/core';

@domternal/core - ListIndent.ts

  • 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