Skip to content

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.

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.

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 approval

A 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 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.

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.

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.

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.

  • Esc turns the caret into a row selection — the row it sits in, and one level wider on each press after that.
  • Shift+ArrowUp / Shift+ArrowDown grow it by a whole row, so growing past a first child reaches its parent.
  • Ctrl/Cmd+A climbs: 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/Down grow 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.

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.

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.