Rows and Nesting
By default a Markput document has rows: separator is '\n', so every line is a row, with its
own drag grip, its own place in the tree, and its own entry in the row menu.
<MarkedInput defaultValue={'first row\nsecond row'} />A row with no row kind is a paragraph. A row that matched a kind’s opener renders through that kind’s component instead.
The separator
Section titled “The separator”separator is editor-level: it belongs to no markup, and it is the whole of what makes a document
rows.
| Value | Effect |
|---|---|
'\n' (default) |
One line is one row. |
null |
The value never splits: one document, no rows, no row controls — a plain annotated text field. |
'' |
Separates nothing. The editor reports it to the console and renders as if it were null. |
| any other string | Rows are delimited by it. |
It is never stored on a row. The value is a projection that joins rows with it, so only the document-final row lacks one — and the piece after the final separator is a row even when it is empty, which is why Enter at the end of a document always gives you a visible row.
Inside a row kind’s RAW body — a __value__ gap, like a fenced code block — the separator is that
markup’s own text and no boundary at all. One row can read as several visual lines and still carry
one grip.
With separator={null} there are no rows to select, drag, indent or convert; draggable has nothing
to act on, and Enter is an ordinary character in the value.
Nesting
Section titled “Nesting”Rows nest by indentation, and by nothing else. indent is the string one nesting level is
written with, default '\t':
- Risks - EU region capacity unconfirmed - Awaiting quota approvalA row whose lead is deeper than the row before it is that row’s child, at most one level deeper. The scan reads a maximal run of whole indent units at a row’s own start; those bytes are structural and no caret may enter them.
indent={''} turns nesting off — and with it row TYPING on any line that starts with the old indent:
a line whose first character is not an opener is a paragraph. Pass it when your document stores
leading indentation as content.
Depth is the tree, lead is the bytes
Section titled “Depth is the tree, lead is the bytes”depth counts from 0: a root row is at depth 0, its child at depth 1. That is the number a row
kind’s component receives as depth and the number setDepth takes.
There is no function from one to the other, and the difference is observable. An over-indented paste
keeps its surplus run in the row’s lead and renders one level shallower than the bytes say. The
surplus survives round-trip until the row or an ancestor is re-indented, at which point setDepth
normalizes it away.
An empty row takes no children: a blank line cannot be a parent, so outdenting a blank row to a root promotes whatever was under it.
Painting the rows
Section titled “Painting the rows”| Where | What it reaches |
|---|---|
slots.paragraph |
The component a row with NO kind renders through. Default: a bare div. |
slotProps.row |
Props merged onto EVERY row’s wrapper — kind and paragraph alike. |
option.row.Component |
The component rows of that kind render through. |
<MarkedInput slots={{paragraph: Paragraph}} slotProps={{row: {className: 'doc-row'}}} options={options}/>The two are not a pair, and the names say which is which: slots.paragraph is consulted only for a
row with no kind, while slotProps.row reaches every row.
Row verbs
Section titled “Row verbs”Every row is a live RowNode. A row kind’s component receives its own as
the node prop, and each verb is one edit — one undo entry, one onChange.
| Verb | What it does |
|---|---|
turnInto(option, patch?) |
Retype the row, keeping its id, element and children. undefined makes it a paragraph. |
setDepth(depth) |
Re-indent the row AND its subtree; the descendants travel with it. |
splitAt(anchor) |
Split at a caret. The tail keeps the kind where the kind continues. |
writeRows(span, rows) |
Write several rows into the body at a span — what a multi-line paste lands through. |
addSibling() |
Open a blank row after this row’s whole subtree, at this row’s depth. |
duplicate(), remove() |
The row menu’s own verbs. |
moveTo({parent, index}) |
Move the row and its subtree, keeping every identity on both ends. |
Each answers false rather than throwing when the model cannot express the result: a move into the
row’s own subtree, a re-indent the scan would read back as a different tree, any row verb in an
editor with no separator, addSibling() on a carved cell.
Reads: node.slot() is the row’s own interior text, node.rows() its child rows, node.meta() its
kind’s metadata gap, node.option() the index of the option that typed it.
Row selection
Section titled “Row selection”The rows the text selection covers WHOLE, in document order. It is DERIVED and never stored: a row is selected exactly while the selection spans it, so the browser paints it and a collapsed caret selects no rows at all.
Escturns the caret into a row selection — the row it sits in, and one level wider on each press after that.Shift+ArrowUp/Shift+ArrowDowngrow it by a whole row, so growing past a first child reaches its parent.Ctrl/Cmd+Aclimbs: the caret’s own row, then the row that one is nested in, then the whole document.
The selection is readable from your own components. useMarkput(selector) is the one published door
to the editor’s store, in both adapters, and it re-renders on exactly the signals the selector names
— so hand it the signal itself rather than calling it:
import {useMarkput} from '@markput/react'
const SelectionCount = () => { const selected = useMarkput(s => s.rows.selected) // readonly number[] — row ids, document order return <span>{selected.length} rows</span>}The hook works anywhere under the editor: a row kind’s component, a mark’s component, a slot. The
store’s other row verbs hang off the same s.rows — see
Keyboard Handling → Selecting Rows.
Three kinds of thing can be selected, and the difference is what the hook does with each:
| Selector answers | You get |
|---|---|
a signal — s.rows.selected |
its value, re-read whenever it changes |
an object of them — {a: s.x} |
an object of their values, same rule |
a controller — s.rows, s.edit |
the controller itself, identity and all |
A controller holds no value of its own and lives as long as the editor does, so there is nothing there to re-render on — the hook hands it back as it is:
import {useMarkput, type Store} from '@markput/react'
const RowBadge = ({id}: {id: number}) => { const rows: Store['rows'] = useMarkput(s => s.rows) const box = rows.boxOf(id) // the row's coordinates, read on demand return box === undefined ? null : <span style={{top: box.top, left: box.left}}>•</span>}Store is useMarkput’s selector parameter and both adapters publish it, so Store['rows'] names
what the hook hands back. The annotation is optional inside a component and NOT optional on an
export: a library built on markput that re-exports a hook of its own gets TS2883 — the inferred type cannot be named, because the controller class itself is core’s and is not published.
- A CLICK on a row that holds no editable position — an atomic kind, which paints none of its own
text — selects that row. The selection is written across the row’s own element, so the browser
paints the block and the next keystroke acts on it.
Shift+ArrowUp/Downgrow such a selection visibly, by the same reading: an end that no surface paints falls back on its row’s element edge.
A row selection is the ROWS — openers and leads included. Paste, cut, copy and Backspace/Delete all read it that way; typing replaces the rows’ TEXT and keeps the first row’s kind, except over a row with no editable position, where the character is refused and the row is left standing. See Keyboard Handling for the full contract.
<MarkedInput draggable options={options} /><MarkedInput draggable={{alwaysShowHandle: true}} options={options} />draggable is false by default, and ineffective when separator is null. There is ONE grip, on
the row nearest the pointer, painted from a single layer beside the rows rather than inside them;
alwaysShowHandle keeps it visible instead of fading it in on hover.
Beside it, in the same band, is a + that opens a fresh row under the row the pointer is on — the
row menu’s own Add below, one gesture instead of three. It is a ROW feature rather than a drag one,
so it is painted whenever the band is, and it is hidden with the band under readOnly. The band is
two controls wide, and that is the left gutter the editor reserves on the container while the rows
drag: 48px of padding-left, which your own layout has to leave room for.
Dragging carries the whole row selection when the gripped row is part of it, and that row alone otherwise; which rows those are is fixed when the grip is pressed, since the browser owns the text selection for the length of a native drag. The drop’s vertical position names the gap between two lines and its horizontal position names one of the depths that gap legally admits — every candidate is planned before it is offered, so the indicator promises rather than predicts. A depth whose parent would paint nothing is not among them (see Row Kinds → What your component receives). The editor takes its focus back when the drag ends, so the next keystroke lands in the document.
The row menu
Section titled “The row menu”Add, duplicate and delete a row, and convert it to another kind. One menu per editor, opened from the
grip or by the / trigger, and addressed by the id of the row it opened on — so a row that has left
the tree refuses instead of being written to.
Both lists take the same keyboard: the first entry is highlighted from the moment the menu opens,
ArrowUp/ArrowDown move the highlight, Enter runs it and Esc closes. After a verb the editor
takes its focus back from the grip, so the next character lands in the document.
Which kinds it offers is not a list you write: an option that declares a menu IS in the menu. See
Overlay Customization → The Row Menu.
History
Section titled “History”The editor keeps its own undo stack, on by default:
<MarkedInput history={false} options={options} />Ctrl/Cmd+Z undoes and Shift+Ctrl/Cmd+Z redoes, in both value modes. An undo restores the value
AND the caret the edit was made from, and it replays the edit’s own splice — so a row keeps its
identity across an undone move.
history={false} turns both keys into no-ops. It does NOT hand undo back to the browser: every input
path cancels its default, so the browser’s own stack is empty by construction.
In a controlled editor an entry is recorded only once your onChange has echoed the value back. An
emission you decline leaves nothing behind. Details, and the store verbs for your own toolbar, are in
Keyboard Handling → Undo and Redo.
See also
Section titled “See also”- Row Kinds — declaring a kind, its spec, and carving a row into cells
- Keyboard Handling — the keymap, paste, and selection
MarkedInputProps—separator,indent,draggable,history