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 nested block at the end of the last item (outdent OUT).

Not included in StarterKit. Add it together with ListKeymap for the full Notion-style list UX.

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.

The reverse: when the cursor is inside a nested block that is:

  • the LAST child of its list item, AND
  • the item is the LAST in its wrapper, AND
  • the parent is not a paragraph (the label paragraph stays), AND
  • the cursor is empty

Then Shift-Tab lifts the block OUT to top-level position right after the list.

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>

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 both the last child of the last item. Mid-position outdent is deferredSplitting the list item mid-way would require complex schema transformations
Shift-Tab requires parent != paragraph so the label paragraph itself never lifts outLifting the label would dissolve the item; that’s what liftListItem does, and ListKeymap handles it for 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. When liftTarget returns null (rare schema cases), outdentBlockFromListItem falls back to a manual delete+insert.

  • 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 at end of last item -> ListIndent.outdentBlockFromListItem runs
  • 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