Keyboard Handling
Markput handles text input, deletion, paste, overlay insertion, row editing, and mark commands through core-owned NODE ANCHORS — a node plus a local offset, never an absolute position in the value string.
The Keymap
Section titled “The Keymap”Every binding below except Home/End exists only where the value splits into
rows; with separator={null} the editor keeps the plain-text behaviour in the
right-hand column’s parentheses.
| Key | What it does |
|---|---|
Enter |
Splits the caret’s row. On an EMPTY row of a kind that continues it demotes instead — depth first, then kind — and inserts only when the row has neither left to give; a kind that continues into nothing has no run to leave, so an empty one of those SPLITS and keeps its kind. Over a text range it splices at the range’s LOW end and keeps what was selected; over a row selection it replaces those rows with one fresh row. Inside a raw closed body (a fence) it is a literal newline. It defers to an open suggestion list. (Otherwise: a '\n' in the value.) |
Shift+Enter |
Opens a CONTINUATION line — a row inside the subtree of the row whose kind owns the line, so N soft breaks are N lines at one level. Refused inside a carved cell. |
Tab / Shift+Tab |
Re-indents the row selection, or the caret’s row, in an editor where some option declares indents. Inside a carved row it walks to the next/previous cell, and is consumed at the ends without moving the caret. In an editor where no option declares it, the key leaves the field (ADR-0002). |
Home / End |
The caret to its LINE’s edge, Shift to extend — and a carved row’s pieces are one line, not one each, so Home in a table’s second column goes to the start of the LINE. Owned by the editor on every platform, and present with separator={null} too: macOS binds both keys to SCROLLING the document, so in a page with room left to scroll the caret did not move at all until the key was pressed a second time. A modifier is left to the platform — Cmd+Left/Right and Ctrl+Home are still the browser’s. |
Backspace at a row’s entry |
Climbs the same demote ladder — depth first, then kind — for a row of ANY kind, which is where it parts from Enter: this key asks about the POSITION, Enter about the run. Otherwise the boundary expansion merges the row with the one above. |
Backspace / Delete |
Next to a mark, deletes the WHOLE mark. Over a row selection, takes those rows away rather than emptying the first. |
Esc |
Turns the caret into a ROW SELECTION, one level wider on each press. Defers to an open overlay or row menu, which closes on that press instead. |
Shift+ArrowUp / Down |
Grows the selection by a whole row. |
Ctrl/Cmd+A |
Selects the caret’s own row, then the row that one is nested in, then the whole document. |
Ctrl/Cmd+Z |
Undo. Shift+Ctrl/Cmd+Z redoes. history={false} turns both into no-ops. |
| Arrow keys | The browser’s, except while a row selection stands. An EMPTY row is stopped on like any other — it is given a line box for it, which the platform does not. |
| Click on a frozen block | SELECTS that row. An atomic kind paints none of its own text, so there is no caret position in it; the selection is written across the row’s own element, so the browser paints the block and Backspace, a paste and a drag act on it. A typed CHARACTER is refused there — see below. The landing outranks a caret elsewhere, since no press in this row could have put one there; it defers to a SWEEP with an end in the row pressed, and a control the browser FOCUSES keeps its own click. |
| Triple-click | Selects the ROW’s content — the cell’s, inside a carved row. The editor owns the gesture: the platform answers the visual LINE under the pointer, so on a wrapped row the same gesture took a different amount of text depending on where the window edge fell. |
| A trigger character | Opens the overlay the option owning that character declares — @ for a picker, / for the row menu. |
Edit Flow
Section titled “Edit Flow”- React/Vue render adapter-owned token shells and text surfaces.
- The adapter registers the root with
store.host.containerand child structure throughstore.tokens.control()(for non-editable controls inside a token) andstore.tokens.children(ownerId)(for nested__slot__child sequence hosts). - Keyboard handlers read the browser selection as a pair of node anchors through
store.tokens.domAnchors(). - Edits call
store.edit.replace(from, to, text), which places the post-edit caret itself; a caller that needs a different caret writesstore.tokens.selection.select(anchor). store.tokens.selectionstores the selection as node anchors and its DOM driver applies them after the next render, placing each anchor through its OWN node.
Production code should not infer token identity from DOM child order or public data attributes.
Text Input
Section titled “Text Input”Inline text input uses the selection the DOM reports:
const anchors = store.tokens.domAnchors()if (anchors) store.edit.replace(anchors.anchor, anchors.head, text)store.edit.replace(from, to, replacement) moves the caret for you, to the end of what it inserted; the pair is normalized, so from after to is legal. To move the caret without editing, write store.tokens.selection.select(anchor).
It also reads the live selection into store.tokens.selection before it commits, because selectionchange is delivered on a task of its own: a caret that has moved since the last one leaves the stored anchors naming where it was, and an edit is addressed from the DOM. So “where the caret was” — the position an undo goes back to, and the one a controlled echo maps into a post-edit caret — is always the browser’s reading at the moment of the edit. A selection the editor cannot resolve, one inside a registered control root or a consumer’s own contenteditable island, leaves the stored anchors standing.
Controlled editors emit onChange first and update the accepted value after the matching prop echo.
Undo and Redo
Section titled “Undo and Redo”The editor keeps its own stack, in both value modes. Ctrl/Cmd+Z undoes and Shift+Ctrl/Cmd+Z redoes; so do the historyUndo and historyRedo input types, which is how the Edit menu and trackpad gestures arrive. Native browser undo stays swallowed — every input path cancels its default, so the browser’s own stack is empty by construction.
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. Consecutive characters typed forward within 500ms are one entry, and so is a run of Backspace or Delete presses inside the same window — but typing and deleting never join, so undoing a correction gives back the letters you removed and the ones you typed in two presses. Every row verb — a move, a duplicate, a turn-into — is its own entry, and so is a paste and any delete of a selection wider than one character. A selection of exactly one character is indistinguishable from a Backspace, so it joins the run beside it.
Wire your own controls to the same stack:
store.history.canUndo() // reactive: safe to read inside a computedstore.history.undo() // answers whether the document movedstore.history.redo()In a controlled editor an entry is recorded only once your onChange has echoed the value back. An emission you decline leaves nothing behind, and a value you write yourself — a reset, a change from elsewhere — leaves canUndo() false until the document is back at an entry the editor recorded.
Pass history={false} to turn both keys back into no-ops.
Selecting Rows
Section titled “Selecting Rows”Where the value splits into rows, Esc turns the caret into a ROW SELECTION: the whole row it sits in, and one level wider on each press after that. Shift+ArrowUp/Shift+ArrowDown grow the selection by a row — absorbing that row whole, so growing past a first child reaches its parent — and Ctrl/Cmd+A walks the same ladder from the caret’s own row, reaching the whole document one rung after Esc runs out of parents.
There is no separate row-selection state: a row is selected exactly while the text selection spans it whole, so the browser paints it and every read is one call.
store.rows.selected() // reactive: the ids of the selected rows, in document orderstore.rows.move({parent: null, index: 2}) // move them, as one spliceAn arrow key is only ever intercepted once a row selection stands — which follows from the selection being derived and is not a synonym for “after Esc”: a plain text selection that spans one row WHOLE grows by a row rather than by a line, and what Shift+ArrowDown writes back is that row’s exact span, so the sweep becomes a row selection at that press. Until then store.rows.selected() is empty and no verb acts on the row the sweep merely covers. At the document’s edges the key is still the gesture’s and does nothing, rather than falling back to the browser and collapsing the selection it was extending. Esc defers to anything already open — the suggestions overlay and the row menu — each of which closes on that press instead. An EMPTY row cannot be row-selected on its own — its content is zero-width, so a caret resting in one sits at both of its edges — but it is selected as part of a range that spans its neighbours. It follows that the ladder has no rung there: Ctrl/Cmd+A in an empty row reaches the whole document on the FIRST press, and Esc leaves the key alone.
Widening never narrows: where a selection spans two parents, Esc and Ctrl/Cmd+A climb to the parent AND keep the rows outside it, and once every selected row is a root Esc leaves the selection alone.
Enter at a row’s own START opens an empty row ABOVE it, and the row you were in keeps its kind, its meta and its children. The row that OPENS is the one continues describes, so Enter at the head of a bullet gives you a second bullet above it and Enter at the head of a heading gives you a plain row. It follows that a kind seeded from the / menu — a table header, whose caret lands at the head of the seed so the first thing typed replaces a column name rather than appending to the last one — answers the very next Enter with an empty row of what that kind CONTINUES INTO, above the seeded row. To open the first data row instead, put the caret in the LAST cell first (Tab, or a click) and press Enter there.
Enter over a TEXT range is deliberately not the replace-the-range rule: it splices a row boundary at the range’s LOW end and KEEPS what was selected, so nothing a selection covers is lost to the key that opened a row above it. Over a ROW SELECTION it does replace, opening one fresh row where the selected rows were — the same answer it gives for an all-selected document, at row granularity.
A row selection is PAINTED BY THE EDITOR, not by the platform: the browser’s own highlight sits under whatever a row kind draws, so a block whose kind paints its own backgrounds — a board, a card grid — could be selected whole with almost nothing on screen saying so, one Backspace from gone. Every selected row carries an overlay of the editor’s instead, over its own paint, in every kind. Its colour is --markput-row-selected.
A row selection is the ROWS, openers and leads included, and paste, cut and Backspace/Delete all read it that way: a paste replaces the selected rows with the clip, a cut or a delete takes them away rather than emptying the first, and what a copy put on the clipboard is exactly what those two write over. This applies wherever the selection covers a whole number of rows EXACTLY — including the one a triple-click makes, which is a row selection by every reading this editor has, and excluding a sweep that runs from mid-row into another row, which is a text selection however much of a row it covers.
“Exactly” is a BOUNDARY and not an offset. Between one line’s last character and the next line’s first typable position lie the separator, the next line’s indent, its opener and any meta inside that opener — and between two CARVED pieces of one row lies the delimiter the kind split at. All of it is structural, none of it is a caret position, so every offset in such a stretch names the same boundary and any of them closes the run. That is what the browser hands you: Shift+ArrowDown from a row’s start, a mouse sweep down one line and a triple-click all end at the NEXT line’s first position, and getSelection().toString() on a selected BBB reads "BBB\n" where the highlight paints only BBB.
EVERY RANGED EDIT READS IT THAT WAY, not only the ones over a row selection. A selection edge that lands on structural bytes resolves to the content boundary it names — the low edge forward, the high edge back — so the span an edit writes is the CONTENT the highlight covers and never the structure between two pieces of it. The resolution only ever shrinks a selection, so an edit can never touch a byte outside what you selected. It matters most where no row selection is possible at all: a triple-click on a row that HAS CHILDREN covers the parent’s own line while the parent’s span covers its whole subtree, and a triple-click on a table CELL covers a piece that no gesture can name as a row. Both used to write over the raw offsets, and both swallowed their neighbour — - A with children became - ZB, and a cell typed over cost the row a column.
An edge that lands INSIDE a line’s content is left exactly where it is: a sweep from mid-row into another row is a text edit and still merges the two rows, which is what it looks like it does. An edge in a structural RUN resolves whatever the other edge is doing, and that is the difference between the two: the first names a position you can put a caret on, the second names bytes no caret may occupy. A selection running from mid-sentence to the ELEMENT of a frozen block below — which is where the browser ends a sweep, or a triple-click, that leaves the row it started in — is that second case, and typing over it used to take the block’s opener with the sentence.
A SELECTION THAT COVERS NO CONTENT AT ALL IS A POSITION, not a licence to write the structure it spans. Where both edges resolve into the same structural run the edit becomes an INSERT at the content boundary the low edge names — the end of the row the gesture began in. The plainest way to one is a double-click in a row’s blank RIGHT MARGIN: the browser’s word expansion runs off the end of the line and answers a cross-row range whose own text is empty, and writing that range verbatim merged the two rows and deleted the next one’s opener. Backspace over such a selection is Backspace at that row’s end and takes one character.
AND A SPAN MAY NOT CUT A BLOCK OPEN. A row whose body is RAW — a fence, a @block … @end panel — is several LINES of the value held between an opening and a closing literal, so an edge inside its body whose partner is outside the row names the BLOCK rather than a position in it and resolves to the row’s own boundary. Without that, a sweep from a heading into the fence below it deleted the fence’s opener and left its closing literal standing as prose. A selection wholly inside such a body is an ordinary text selection and edits as one.
AND A WRITE STOPS AT THE FIRST ROW THE PAGE DOES NOT PAINT. A collapsed toggle still RENDERS its child rows and hides them, so the browser’s own selection walks straight through their text; the span an edit writes is clipped to the last line you can actually see, so the toggle’s own line is replaced and its hidden body is left alone. A row a frame has not painted YET is a race rather than a verdict, and is not clipped against.
TYPING stays TEXT: a character replaces the rows’ own text and the first row keeps its kind. The ONE exception is a row that holds no editable position, an atomic kind that paints none of its own text: there is no prose there to replace, so the key is CONSUMED and the row is left standing. That is a refusal rather than a hole, and it is deliberate — such a selection is what a plain click on a frozen block produces, so “replace the block with this letter” put a whole card one accidental keystroke away from gone. Backspace, Delete and a paste still take the row, because those are the gestures that say so.
A clip pasted over a row selection takes the same rules it takes at a caret in the same row: a FOREIGN clip’s lines each open a row at the covered rows’ depth, carrying their kind wherever the kind declares continues, so a one-line clip keeps the kind exactly as typing does; this editor’s own clip is the value’s own projection and is spliced verbatim.
Tab and Shift+Tab move every row the selection holds, in one splice; where no row selection stands they move the caret’s own row. A step no selected row can take moves none of them. indents is the EDITOR’s declaration, not the row’s: it answers whether Tab belongs to the field at all, and which rows may actually nest is the same question a DROP asks — the scan’s depth ceiling, plus whether the would-be parent’s component paints child rows. So the keyboard and the drag agree, and a row of a kind that declares nothing still nests under a bullet by either gesture.
When a Gesture Is Refused
Section titled “When a Gesture Is Refused”Several of the rules above END in the editor consuming a key and doing nothing, and every one of them is deliberate: Shift+Enter inside a table cell, Tab past the last cell of a carved row, Tab on a row the depth verb will not move — a root row under Shift+Tab, or one whose would-be parent paints no child rows — and a character typed over a row that holds no editable position. A cancelled key with nothing behind it is indistinguishable from an editor that has stopped responding, so the editor SAYS it refused: a tint fades over the row the gesture was refused at, once per press. Its colour is --markput-row-refused.
It names the row and not the reason. Nothing about the rules changes, and nothing is required of you to get it — it is painted by the same layer that paints the grip and the drop indicator. Where the document parses no rows there is nothing to tint and a refusal is silent, which is what a plain text field does.
A DELETE the model cannot express is the one refusal that stays quiet: Backspace at a boundary there is no merge to offer across — the closing line of a raw body — is consumed and writes nothing, but so is Backspace at the very start of the document and Delete at its very end, and the editor cannot tell those apart. Announcing on the pair tinted the first row on the most ordinary keystroke there is, so it announces on neither.
store.rows.state.refused() // reactive: {id, at} — the row, and which pressDragging a row’s grip carries the whole row selection when the gripped row is part of it, and that row alone otherwise. Where the drop lands — including how DEEP — comes from the pointer: its vertical position names the gap between two lines, its horizontal position names one of the depths that gap legally admits, and every candidate is planned before it is offered, so the drop indicator promises rather than predicts. A pointer below a nested subtree names the gap after that subtree’s LAST line, not the slot under the root it started at. The placement the rows ALREADY hold is one of the depths a gap offers, so releasing at a row’s own indent leaves it where it was instead of re-indenting it. The rows in flight are stepped over at BOTH ends of a gap, so a dragged row’s own gap offers the same depths whether the pointer sits on the upper half of its line or the lower.
Copying and Pasting
Section titled “Copying and Pasting”Copying part of a TYPED row emits a partial RE-ANNOTATION rather than the painted text: half a heading copies as '# half', and one cell of a table line copies as that line with the other cells empty, so the pasted cell keeps its column. That is what makes a clip round-trip through the editor as the same kind it came from.
A pasted clip’s LINE BREAKS open rows, through the same plan Enter writes: the first line joins the row the caret is in, each line after it opens a row at that row’s depth, carrying its kind wherever the kind declares continues, and the rest of the body follows the last one. \r\n, \r and \n are all line breaks here, whatever the editor’s own separator is — and so is the separator itself, wherever a line still holds one. A text DROP carrying line breaks takes the same rule, since it delivers the same bytes. Two clips are spliced verbatim instead: one that came from this editor — it is the value’s own projection, and every line already carries its lead and its opener — and one landing inside a raw closed kind, whose body holds separators as content.
A selection that runs from one row INTO A LATER ONE takes the same plan: the head keeps the text before it, the rows between the two ends go, and the last covered row’s tail follows the last line of the clip — in a row carrying that row’s own kind at the head’s depth. Two shapes still fall back on the raw splice, both because the tail is written at the head’s depth: one closing in a row that has rows nested under it, and one after which the next row would no longer sit where it sat.
Where the value does NOT split into rows, a pasted line break is an ordinary character, as it always was.
A delete the model cannot express — one that would reach through a raw closed kind’s closing line, or past either end of the document — is CONSUMED and changes nothing, rather than being handed back to the browser.
Deleting Around Marks
Section titled “Deleting Around Marks”Collapsed Backspace/Delete asks the tree for the mark ADJACENT to the caret anchor. If there is one, core deletes the whole mark. Otherwise it steps the anchor one character and deletes that span.
Mark Commands
Section titled “Mark Commands”Use useMark() for mark-specific actions:
import {useMark} from '@markput/react'
function RemovableMention() { const mark = useMark() return ( <button type="button" onClick={() => mark.remove()}> @{mark.value()} </button> )}To update a mark, call mark.update():
mark.update({value: 'alice'})mark.update({meta: null})The hook no longer exposes a DOM ref. Focus moves through registered token shells and text surfaces owned by the adapters.
Overlay Triggers
Section titled “Overlay Triggers”Overlay trigger probing uses the current raw caret position (caret.selection()). During input, core probes the caret range which is updated synchronously with value edits.
Custom Keyboard Handlers
Section titled “Custom Keyboard Handlers”Attach custom handlers to the container through slotProps.container, but let Markput own text mutation:
<MarkedInput slotProps={{ container: { onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { if (event.key === 'Escape') { // custom behavior } }, }, }}/>If a handler changes editor text, route it through component state (value/onChange) or a mark command. Do not mutate parsed tokens directly.