Row Kinds
A row kind is a Markup matched ONLY at a row’s own start. Matching it
TYPES the row: the row renders through the component the option names instead of through
slots.paragraph, and the bytes that matched — the opener, and a closing literal where the kind has
one — become structural. They stay in the value, they never reach your component, and no caret may
enter them.
It is the same markup language a mark uses, compiled by the same compiler. What makes it a row is
the row key:
import type {RowProps} from '@markput/react'import {MarkedInput} from '@markput/react'
const Heading = ({children, ref, className, style}: RowProps) => ( <h1 ref={ref} className={className} style={style}> {children} </h1>)
;<MarkedInput defaultValue={'# Title\nplain text'} options={[{markup: '# __slot__', row: {Component: Heading}}]}/>'# Title' paints as <h1>Title</h1> — the '# ' is gone from the screen and still in the value.
'plain text' has no kind, so it is a paragraph and renders through slots.paragraph.
Markup rules for a row
Section titled “Markup rules for a row”A row markup obeys every rule a mark’s markup obeys, plus three of its own. A markup that breaks one is reported to the console and contributes no row kind — the option is skipped and every other option keeps its index.
| Rule | Rejected |
|---|---|
| Must not BEGIN with a placeholder (the mark rule) | '__slot__\n' |
Exactly one body placeholder — __slot__ or __value__ |
'# __value__ __slot__' |
No second __value__: an opener is a literal scan |
'<__value__>__slot__</__value__>' |
| No two placeholders touching | '# __meta____slot__' |
| Its opener must not already be claimed by an earlier option | two options both opening '# ' |
__meta__ gaps are allowed beside the body — that is a to-do’s checked flag and a fence’s language.
The body placeholder decides how the interior is read
Section titled “The body placeholder decides how the interior is read”__slot__— the body is inline-parsed. Marks inside it are marks, and the row’s inline content is what your component gets as children.__value__— the body is raw and never re-parsed. Enter inside it is a literal newline rather than a row split, and a separator inside it is that markup’s own text, not a boundary — which is how a fenced code block or a frontmatter frame reads as ONE row across several visual lines. The keystroke that CLOSES such a body leaves the caret in the row AFTER it — one is opened when the body ends the document — rather than at the end of the body: the position after the closing literal is not one the row can hold, and a person who has just closed a block continues below it.
{markup: '```__meta__\n__value__\n```', row: {Component: CodeFence}}A kind whose markup has no closing literal after the body — '# __slot__', '- __slot__' — is
open: its body ends at the row’s own separator.
Longest opener wins
Section titled “Longest opener wins”Openers are scanned longest-first, so a longer opener beats a prefix of itself and the two never compete:
{markup: '- [__meta__] __slot__', row: {…}} // '- [x] ' — a to-do{markup: '- __slot__', row: {…}} // '- ' — a bulletTwo options that compile to the SAME opener are not a precedence question: the later one is reported and dropped.
The row spec
Section titled “The row spec”Everything a kind declares beyond its markup lives in RowSpec.
| Field | Default | What it does |
|---|---|---|
Component |
required | The component every row of this kind renders through. There is no per-kind fallback — slots.paragraph answers only the row with NO kind. |
continues |
false |
What the row a split produces is. true is this kind again — Enter at the end of a continues row opens another row of it, and mid-row the tail keeps it. false is a plain row. An option is a third answer: the tail takes THAT kind, which is how a table header continues into a table line. The KIND continues and the row’s own meta does not: the tail is seeded with menu.meta, the same thing a new row of that kind gets from the menu, so a checked to-do splits into a checked head and an UNCHECKED tail. A list item continues, a heading does not. |
indents |
false |
Does Tab belong to this EDITOR. One option declaring it answers for the whole editor: Tab then re-indents a row of ANY kind, wherever a drag onto the same gap would, and is consumed even where the step is refused. In an editor no option declares it on, Tab leaves the field (ADR-0002). |
split |
— | This kind carves its own body at a literal into cells. See Carving a row into cells. |
What your component receives
Section titled “What your component receives”RowProps, in React:
| Prop | What it is |
|---|---|
children |
The row’s own inline content, already rendered. |
rows |
The row’s CHILD ROWS, already rendered. Always present — empty when the row has none. |
meta |
The kind’s __meta__ gap — a to-do’s flag, a fence’s language. |
depth |
Nesting depth, counted from 0 — a root row is at depth 0. |
node |
The live RowNode: its id, its own text, and its verbs. |
ref, className, style |
Slot plumbing. Spread all three onto the element you render. |
There is no index. It was published and was removed: a sibling position changes for every row
after an insert, so a row that is handed one repaints whenever a distant row moves — half the cost
of one Enter in a 4000-row document, measured. Number a run with a CSS counter instead, which the
browser keeps exact for free:
.numbered { counter-increment: item;}/* The reset sits on the FIRST item of a run, so each run counts from one. */.numbered:not(.numbered + .numbered) { counter-reset: item;}.numbered::before { content: counter(item) '.';}A counter is also the RIGHT number, which the prop never was: a position among siblings of every
kind counts the paragraphs before a list, so the first item of a list under two paragraphs reported
2 and index + 1 read “3.”.
The ref is load-bearing. It is how the editor finds the row’s element; a component that drops it
leaves the row unbound and the caret cannot resolve into it. Nothing on screen says so, which is why
the editor does: a row whose component paints no element the editor can bind is reported to the
console, once per such row, naming the kind’s markup. The check is re-asked whenever the component
that paints a row changes, so a turn-into into a broken kind is reported too, and the verdict waits
a frame — a kind that paints null first and its element on a flip set from its own mount is a
correct kind and is never accused.
A kind that never renders rows cannot be nested under. Rendering it is how the editor learns
that this kind has somewhere to put a child: the wrapper the adapter hands over registers itself,
and a kind that drops it registers nothing. Tab and a drag both read that and refuse to nest a row
where nothing would paint it — the drop indicator never offers the depth, and Tab is consumed and
does nothing. Nothing is lost and nothing is hidden, which is what the alternative cost: a row moved
under a heading that renders no rows stayed in the value with no box, no caret position and
nothing on screen.
So render rows even where the design has no children in mind — an empty wrapper costs nothing,
and it is what keeps the kind nestable. Hiding them is a different thing and is fine (below); it is
read as “not now”, and a nest into a hidden subtree is refused for the same reason.
A row that BECOMES such a kind has its children lifted. Tab and a drag are refused before they
write, because the destination is on screen to be asked; a retype is not — the row only becomes a
heading a frame later — so node.turnInto(heading) on a parent used to leave its children in the
value with nothing painting them. The editor now moves them out one level, to the depth that does
paint them, in the same undo step as the retype: what the user asked for stands, and nothing they
were looking at disappears. It repairs a document being EDITED, never one merely authored: a value
that arrives with a child under such a kind is the consumer’s own bytes and is left alone.
Collapse by HIDING, never by unmounting. An unpainted row leaves the DOM binding and takes its
anchors with it, so End, select-all and every arrow that resolves through the last row would walk
into a row with no element:
<span hidden={!open}>{rows}</span>Closing one under the caret is safe: a hidden row generates no box, and the editor moves the caret
to the nearest row that does — the row after the collapsed subtree, which is where the same
ArrowDown would have landed. It used to be left inside, where every keystroke edited text nobody
could see.
The same kind in Vue
Section titled “The same kind in Vue”Vue delivers the child rows as a slot named rows rather than as a prop — the one place the two
adapters’ row contract differs, because a rendered node is a slot in Vue and a node in React. The
editor’s ref resolves through the component instance, so there is nothing to spread; class and
style fall through onto the root element unless the component declares inheritAttrs: false.
That root must be a single HTML element. A component with several roots, or one that renders
nothing, has a fragment or a comment where the instance’s element would be, and the editor binds
neither — such a row is unbound and reported. An <svg> root is refused for the same reason: the
editor writes contentEditable on the row’s element, which SVG does not carry. Put the <svg>
inside the element the component renders.
import {defineComponent} from 'vue'
const Bullet = defineComponent({ props: {meta: String, node: {type: null}, depth: Number}, template: '<li><slot /><slot name="rows" /></li>',})Declare the props you read. Vue puts every prop a component does not declare onto its root element,
so node and depth would otherwise land there as attributes.
The node you are handed is reactive, in both adapters and for the same reason a prop is: read
node.slot() or node.meta() in a template or a computed and it re-reads itself after every edit.
import {computed, defineComponent} from 'vue'
const Fence = defineComponent({ props: {meta: String, node: {type: null}, depth: Number}, setup(props) { return {body: computed(() => props.node.slot())} }, template: '<pre>{{ body }}</pre>',})That is worth stating because it was not always true and cannot be taken for granted: what the node
answers are the editor’s own signals, which Vue’s reactivity does not see, so the node a kind
receives is wrapped for it. Two consequences follow. Reading it OUTSIDE a reactive scope — in
setup’s body rather than in a computed or a template — captures a value and never hears again,
exactly as any other one-time read would. And the wrapper is not the same object the editor holds,
so compare rows by node.id rather than by ===; every method, every read and every verb behaves
as it always did.
Controls inside a row
Section titled “Controls inside a row”Everything a row’s component paints sits inside the one contenteditable container. An element the
editor knows nothing about is document content: the caret enters it, the browser edits it, and what
the user types into a checkbox’s label lands in the value.
A control announces itself with useControlRef():
import type {RowProps} from '@markput/react'import {useControlRef} from '@markput/react'
const Bullet = ({children, rows, ref, className, style}: RowProps) => { const controlRef = useControlRef() return ( <div ref={ref} className={className} style={style}> <span className="bullet" ref={controlRef} /> {children} {rows} </div> )}Use it for anything that is chrome rather than text: a bullet glyph, a toggle arrow, a checkbox, a
<select>, a tab bar.
When the WHOLE interior is editor UI — a properties grid, a board, a card, a table of contents —
wrap it once in Atomic instead of registering each leaf:
<Atomic className="board"> <Board columns={columns} /></Atomic>Atomic is useControlRef() on one <div>, and it is shipped because forgetting it on one kind of
several is the measured failure: when this site’s own showcase first shipped its atomic kinds, four
of the seven had no control root, and a click parked a blinking caret in a properties grid where
every keystroke was swallowed.
@markput/vue publishes both under the same names. useControlRef() returns a callback a template
binds with :ref, and it takes Vue’s ref argument rather than an element, so a control painted by a
component of your own registers the element that component rendered:
import {defineComponent} from 'vue'import {Atomic, useControlRef} from '@markput/vue'
// ONE control, in a row whose text is still the document's.const Bullet = defineComponent({ setup: () => ({setControlRef: useControlRef()}), template: '<div><span class="bullet" :ref="setControlRef" /><slot /><slot name="rows" /></div>',})
// A WHOLE interior: this row paints no document surface at all, so it takes one `Atomic` rather// than a registration per leaf. A `class` on it falls through onto the one element it renders.const Board = defineComponent({ components: {Atomic}, props: {columns: {type: Array, default: () => []}}, template: '<div><Atomic class="board"><div v-for="c in columns" :key="c">{{ c }}</div></Atomic></div>',})A control that is FOCUSABLE — a checkbox, a <select>, a <button> — takes DOM focus when it is
clicked, which is the browser’s own default, and it leaves the selection where it was. The editor
takes its focus back, so the user can go on typing where the caret already is, and it does so at
whichever of these comes first:
- the CLICK, for a control that answers the pointer and nothing else — a
<button>, a tab, a[tabindex]element. A decoration that writes nothing to the document is the common case here, and holding its focus only makes the editor deaf: acontenteditableemits nobeforeinputwhile a descendant control has focus, so every keystroke after such a click was lost with nothing on screen to say why; - the COMMIT, for a control that OWNS a keyboard of its own — a
<select>, an<input>, a<textarea>or your owncontenteditableisland. Those keep the focus a click gives them, because taking it back would close the very popup the click opened, and they answer their own arrow keys and typing until they write. The cost of that, stated: such a control driven by the KEYBOARD and committing on every keystroke — a<select>arrowed with its popup closed — loses focus after the first commit.
THE RULE ONLY REACHES CONTROLS YOU REGISTER. useControlRef() is what tells the editor an element is
not document content; a focusable element it knows nothing about keeps the focus it took, and the
keyboard stays dead until the user clicks back into the text.
A control that is only PRESENTATION — a bullet glyph, a card, a properties grid — takes no focus,
and a click on one names no position the editor can use: the browser either leaves its caret inside
the frozen element or, for a draggable one, leaves the selection exactly where it was and moves
nothing at all. Either way the row the pointer was IN is the row the caret gets, at that row’s own
entry. A click never reaches a neighbouring row.
THE POINTER OUTRANKS A CARET ITS OWN GESTURE COULD NOT HAVE MOVED, which is what makes that true on a page you have already typed in: a caret three rows up is a reading the editor CAN make and it is not one this press produced, so the landing wins. What still outranks a landing is an EXTENT with an end in the row you pressed in — a sweep that began there and was dragged away is yours, and no claim can re-derive it. A control that takes FOCUS is not a landing at all: it answered the pointer itself, so the caret you were holding stays where it was and your next character goes there.
A row that paints none of its own text — an atomic kind, the whole card — holds no entry to claim,
so a click on it SELECTS the row instead: the selection is written across the row’s own element, the
browser paints the block, and Backspace, a paste and a drag act on it. A typed character is
refused there, since the row holds no prose for it to replace. If you want such a block to answer a
click some other way, paint something the caret can enter or handle the click yourself inside the
control.
Retyping a row
Section titled “Retyping a row”node.turnInto(option, patch?) replaces the row’s kind, keeping the row’s identity — its id, its
element, its drag grip, its child rows. undefined makes it a paragraph.
node.turnInto(todo, {meta: 'x'}) // tick the boxnode.turnInto(undefined) // back to a paragraphThe patch carries meta (absent leaves it, null clears it, a string sets it) and text, which
REPLACES the body — that is what lets a caller strip a span and retype in ONE splice.
The reparse decides what comes back: a body carrying the separator becomes two rows, and a body whose
own start matches a longer opener types as THAT kind. turnInto answers false for an option this
editor compiles no row kind from, and for a no-op.
Carving a row into cells
Section titled “Carving a row into cells”A kind that declares split carves its OWN body at a literal, and each piece becomes an ordinary Row
of the option as names — a table line into cells.
const cell: Option = { row: { Component: ({children, ref, className, style}: RowProps) => ( <div ref={ref} className={`${className} cell`} style={style}> {children} </div> ), },}
const tableLine: Option = { markup: '| __slot__', row: {continues: true, split: {at: ' | ', as: cell}, Component: TableLine}, menu: {label: 'Table row'},}| Auth migration | Blocked | Kara paints as three cells. TableLine renders the pieces through
rows — they are the row’s children — and nothing else.
asmay be an option with no markup at all — an anonymous kind, which nothing scans and which exists only as a split’s target. It must be an option of this editor carryingrow; anything else is reported and this kind carves nothing.- A carved row takes no indent-nested children: its children ARE its body, and no separator is written between them.
- Tab inside a cell walks to the next piece rather than changing depth; at the first or last piece there is no neighbour and the key is not consumed.
- Every other key names the LINE, because a piece has no line of its own to splice: Enter splits the
line, Backspace at the first piece demotes the line,
Home/Endgo to the LINE’s edges rather than the piece’s, and the row menu converts the line. Shift+Enter is refused there. - A piece cannot contain the delimiter — an escape scoped to a cell’s body is a named follow-up, not a feature.
- Copying one cell emits the whole line with the other cells empty, so a pasted cell keeps its column.
Appearing in the row menu
Section titled “Appearing in the row menu”An option that declares menu IS in the row menu — there is no list of kinds anywhere else:
{markup: '# __slot__', row: {Component: Heading}, menu: {label: 'Heading 1', keywords: ['h1', 'title']}}See Overlay Customization → The Row Menu for the trigger wiring and the seed fields.
Worked example: a Notion to-do
Section titled “Worked example: a Notion to-do”Everything above, in one kind. The markup carries a __meta__ flag and an inline-parsed body; the
kind continues, so Enter opens another to-do; it indents, so Tab nests one under another; the
checkbox is a control, and ticking it is a retype.
import type {Option, RowProps} from '@markput/react'import {MarkedInput, useControlRef} from '@markput/react'
const todo: Option = { markup: '- [__meta__] __slot__', row: { continues: true, indents: true, Component: ({meta, children, rows, node, ref, className, style}: RowProps) => { const controlRef = useControlRef() const done = meta === 'x' return ( <div ref={ref} className={className} style={style}> <input type="checkbox" ref={controlRef} checked={done} onChange={event => node.turnInto(todo, {meta: event.target.checked ? 'x' : ' '})} /> <span className={done ? 'done' : undefined}>{children}</span> {rows} </div> ) }, }, menu: {label: 'To-do list', keywords: ['todo', 'task', 'check'], meta: ' '},}
const Bullet = ({children, rows, ref, className, style}: RowProps) => ( <li ref={ref} className={className} style={style}> {children} {rows} </li>)
const bullet: Option = { markup: '- __slot__', row: {continues: true, indents: true, Component: Bullet}, menu: {label: 'Bulleted list'},}
export const Editor = () => ( <MarkedInput defaultValue={'- [ ] Confirm the EU quota\n- [x] Signed off by Platform'} options={[{overlay: {trigger: '/'}}, todo, bullet, {markup: '@[__value__](__meta__)'}]} />)Notes on the pieces:
'- [x] 'is a longer opener than'- ', so the to-do wins over the bullet whichever order the options are listed in.meta: ' 'on the menu entry seeds a NEW row’s flag. Seeds apply only where there is nothing to keep — a row that already has text keeps its own body, since a turn-into must not discard what the user typed.node.turnInto(todo, {meta})is the whole of the toggle. The row keeps its id, so its element, its child rows and its drag grip survive the tick, and the tick is one undo entry.- The
@[__value__](__meta__)option needs norow: a mark inside a to-do’s body is a mark like any other, because__slot__bodies are inline-parsed.
The full vocabulary this kind was taken from — headings, callouts, fences, toggles, tables, a board
and a metrics strip — is the showcase page in
packages/storybook/src/pages/Notion/.
It imports the published adapter and React and nothing else.
See also
Section titled “See also”- Rows and Nesting — the separator, the indent, row selection, drag, history
- Keyboard Handling — what each key does in a row
RowSpec,RowProps,RowNode