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