Architecture
This guide explains Markputβs internal architecture, data flow, and design decisions.
System Overview
Section titled βSystem Overviewβββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Markput ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β Framework Layer (React / Vue) β ββ β β’ Components (MarkedInput, Container, Token, Block) β ββ β β’ Hooks (useMark, useOverlay, useStore) β ββ β β’ Context Providers (StoreContext) β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β Core Layer β ββ β β’ Parser (markup β tokens) β ββ β β’ Store (state + events + features) β ββ β β’ Signals (framework-agnostic reactivity) β ββ β β’ Caret (cursor positioning) β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β DOM Layer β ββ β β’ contenteditable container β ββ β β’ Mark elements (custom components) β ββ β β’ Overlay element (suggestions) β ββ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββComponent Hierarchy
Section titled βComponent HierarchyβComponent Tree (React & Vue)
Section titled βComponent Tree (React & Vue)βBoth framework adapters share the same component structure:
<MarkedInput> # Root: creates Store, provides context <Container> # contenteditable element β ββ (drag=false) β β ββ <Token node={n} /> # Unified renderer for text & mark nodes β β ββ <TokenChildren> # Internal host for __slot__ child sequence β β ββ <Token node={child}> β β β ββ (block layout) β ββ <Block node={n}> # Row wrapper: the RowNode's own element AND β β ββ <Token node={child}> # its child-sequence host β ββ <BlockControls /> # ONE per editor, beside the rows, not inside β ββ grip # them: grip, drop indicator and row menu, β ββ drop indicator # painted at row boxes it measures β ββ row menu β <OverlayRenderer> # Portal for overlay ββ <Overlay /> # User's custom Overlay componentComponent Responsibilities
Section titled βComponent Responsibilitiesβ| Component | Responsibility |
|---|---|
| MarkedInput | Entry point, store initialization, mount/unmount signaling |
| Container | contenteditable management, renders tokens or blocks |
| Token | Unified renderer for both text and mark tokens (recursive) |
| TokenChildren | Internal nested token sequence host for slot children |
| Block | Block layoutβs row wrapper β the RowNodeβs own element and its child-sequence host; renders the rowβs children and nothing else |
| BlockControls | ONE per editor: the grip, the drop indicator and the row menu, painted at measured row boxes |
| OverlayRenderer | Portal renderer for overlay component |
| Span | Default text span renderer |
Data Flow
Section titled βData FlowβInput Flow (User Types)
Section titled βInput Flow (User Types)β1. User types in contenteditable β2. KeyboardController detects input β3. store.tokens.domAnchors() resolves the DOM selection (or the input target range) to a pair of node anchors in the live tree β4. KeyboardController calls store.edit.replace(from, to, text) for every user edit; a whole-value rewrite calls store.edit.setValue(text) instead. Block row edits name the row the caret enters β store.tokens.setValue(text, enterRoot) β rather than a character offset β5. The string boundary decides commit policy β uncontrolled commits straight through; controlled emits onChange and waits for the echo it spliced β6. Adoption folds the fresh parse back into the persistent nodes, and apply() binds the tree to the painted DOM, then announces the commit β7. The pipeline does not route: what re-renders is decided by what changed. A text edit writes one node's `text` signal, which its own bound surface effect writes to the DOM β no component re-renders. A structural change publishes new roots, so React/Vue re-render through `useMarkput()` β8. SelectionDriver applies the stored anchors to the DOM after the adapter registers the new DOMAll user mutations go through store.edit.replace(from, to, text): features name the two NODE ANCHORS that bound the span, and the edit coordinator applies the post-edit caret the token layer answers with, inside a single batch. store.edit.setValue(text) is the whole-value form; it is not part of the public export. No absolute offset survives above the token tree β a whole-value rewriter that must place the caret names the ROW it enters (store.tokens.setValue(text, enterRoot)), an index into the result the caller genuinely knows. Programmatic writes go through store.tokens.replaceBetween() / setValue(), and store.tokens.value() reads the current projection. DOMβmodel boundary mapping lives in store.tokens (anchorFor); its private SelectionDriver re-applies the stored anchors to the DOM on tokens.bound β the DOM clock, one pulse per bind β and on anchor writes. TokenModel owns the token parse, the live node map, and all DOMβmodel operations.
Trigger Flow (Overlay Opens)
Section titled βTrigger Flow (Overlay Opens)β1. User types trigger character (e.g., '@') β2. OverlayController runs a trigger probe after value edits, or on `selectionchange` when `showOverlayOn` includes `selectionChange` β3. If found: - store.overlay.match set β4. Overlay component receives match via useOverlay() β5. Overlay renders at cursor position β6. User selects item: - Overlay calls select({ value, meta }) β7. store.overlay.choose(value, meta) annotates and replaces the trigger range β8. Markup inserted, onChange called with new text β9. store.overlay.close() closes overlayParsing Pipeline
Section titled βParsing PipelineβStage 1: Text Input
Section titled βStage 1: Text InputβInput: "Hello @[Alice](123) and #[react]"Stage 2: Parser Initialization
Section titled βStage 2: Parser Initializationβconst parser = new Parser([ '@[__value__](__meta__)', // Mention pattern '#[__value__]', // Hashtag pattern])new Parser and createMarkupDescriptor THROW on a markup that breaks the placeholder rules β
which is right for denote and any other caller that constructs a parser in its own stack.
TokenModel.#parser does not construct one that way: it asks markupError first and maps an
invalid option.markup to undefined, the hole MarkupRegistry already skips while preserving
the original indices, then reports through reportBadProp. The props boundary must not throw
because both adapters push props from a per-render lifecycle hook (see the re-parse paragraph
below). The check sits in #parser rather than #markups deliberately: props.options compares
array elements by reference, so an inline options={[β¦]} prop differs on every render, while
#markups compares the markup STRINGS β reporting downstream of that gate is what makes it once
per distinct markup set instead of once per render.
Stage 3: Tokenization (3-stage pipeline)
Section titled βStage 3: Tokenization (3-stage pipeline)β- SegmentMatcher β finds all opening/closing bracket positions
- PatternMatcher β groups segments into complete markup matches, resolves nesting
- TreeBuilder β single-pass algorithm builds nested token tree using a parent stack for
__slot__content
Tokens carry descriptor.index pointing back to which option/markup created them.
[ { type: 'text', content: 'Hello ' }, { type: 'mark', content: '@[Alice](123)', value: 'Alice', meta: '123', descriptor: { index: 0, markup: '@[__value__](__meta__)' }, children: [], }, { type: 'text', content: ' and ' }, { type: 'mark', content: '#[react]', value: 'react', descriptor: { index: 1, markup: '#[__value__]' }, children: [], },]Stage 4: Rendering
Section titled βStage 4: RenderingβEach root node renders via the unified Token component, taking the live TreeNode off store.tokens.nodes():
<Container> <Token node={textNode} /> {/* renders as <span> */} <Token node={markNode} /> {/* renders user's Mark component */} <Token node={textNode} /> <Token node={markNode} /></Container>Nested Parsing
Section titled βNested ParsingβFor nested marks like **bold @[mention]**:
1. Parse outer mark: **__slot__** β2. Extract nested content: "bold @[mention]" β3. Recursively parse nested content β4. Build token tree with children: { type: 'mark', children: [ { type: 'text', content: 'bold ' }, { type: 'mark', value: 'mention', ... } ] }Event System
Section titled βEvent SystemβEmitter Architecture
Section titled βEmitter ArchitectureβEvents use event<T>() to create typed emitters backed by reactive signals:
Event<T>β callevent(payload)to fire; useevent.read()to read/subscribe; subscribable viawatch(event, fn)
Store Events
Section titled βStore Eventsβ| Event | Feature | When Fired | Payload |
|---|---|---|---|
close |
overlay | Close overlay | void |
Block row operations are NOT an event. store.block.action({...}) and its four-verb
DragAction payload are gone: BlockController resolves the menuβs row id to its node and calls
that nodeβs own verbs, so there is no action to lower onto them.
Re-parsing is not a store event: it is the string boundaryβs reparse(), driven by a single watch over the (value, parser, rowSeparator) tuple in the TokenModel constructor. rowSeparator is TokenModelβs own computed β block layoutβs separator, undefined everywhere else β and it is the one place the layout enum is read; everything else asks it, or asks the tree it produced. Because a computed tracks its dependencies per evaluation, a document with no rows is not subscribed to separator at all, so changing that prop in inline layout re-derives nothing. An EMPTY separator also answers undefined: an empty separator separates nothing, and undefined is already the seamβs word for βno rowsβ, so the row parse, the block feature gates, the grip gutter and BlockController turn off together. It is reported through reportBadProp rather than thrown, because both adapters push props from a per-render lifecycle hook β React unmounts the whole render root on a throw there, Vue keeps rendering the stale tree β while Parser.parseRows keeps refusing '' for callers that reach it directly. Mount/unmount is not an event either: the adapter writes the host.container signal, and host.onMounted(setup) runs setup (with auto-disposal) whenever a container attaches, swaps, or detaches. The selection driverβs props.readOnly watch (which writes the containerβs contenteditable) is a reactive effect hook, not a store event; binding is not reactive at all β apply() calls it directly on every commit.
Event Usage
Section titled βEvent Usageβ// Commit a value edit between two node anchorsstore.tokens.replaceBetween(store.tokens.anchorAt(0), store.tokens.anchorAt(5), 'hello')
// Read the live root nodes (readonly TreeNode[]) β reactivestore.tokens.nodes()
// Run a row operation through the editor's block controller β it addresses the row the// open menu belongs to, so the menu is opened on that row firststore.block.openMenu(store.tokens.nodes()[0].id, gripElement.getBoundingClientRect())store.block.deleteRow()
// Subscribe to an eventimport {watch, effectScope} from '@markput/core'
const dispose = effectScope(() => { watch( store.tokens.value, () => { console.log('Text changed') } )})
// Clean up all subscriptions in the scopedispose()State Management
Section titled βState ManagementβReactive Signals
Section titled βReactive SignalsβState is managed through direct signal declarations. Each property is a Signal<T>:
export interface Signal<T> { (): T // Read value (also tracks as reactive dependency) (value: T | undefined): void // Write value (undefined reverts to default)}Framework adapters subscribe to signals through their own useMarkput() hook
(see packages/react/markput/src/lib/hooks/useMarkput.ts and the Vue
equivalent). The hook accepts a selector that reads from the store; the
adapter wraps it in an effect() that tracks signal reads and notifies the
framework when any tracked signal changes:
- React:
useMarkputis built onuseSyncExternalStore; the subscribe function creates aneffect()and the snapshot reads the selector untracked. - Vue:
useMarkputreturns ashallowRef, drives it witheffect(), and disposes ononUnmounted.
This is the only framework coupling point.
Store Structure
Section titled βStore Structureβclass Store { readonly key: KeyGenerator
readonly props: { // Identity props are declared with `signal<T>({readonly: true})` β no // initial, so the type widens to `Signal<T | undefined>`. Default-bearing // props use `signal<T>({default: X, readonly: true})` so an incoming // `undefined` from the adapter spread reverts to the declared default. value: Signal<string | undefined> defaultValue: Signal<string | undefined> onChange: Signal<((value: string) => void) | undefined> options: Signal<CoreOption[]> readOnly: Signal<boolean> layout: Signal<'inline' | 'block'> separator: Signal<string> // block layout's structural row separator (ADR-0009) draggable: Signal<boolean | DraggableConfig> showOverlayOn: Signal<OverlayTrigger> Span: Signal<Slot | undefined> Mark: Signal<Slot | undefined> Overlay: Signal<Slot | undefined> className: Signal<string | undefined> style: Signal<CSSProperties | undefined> slots: Signal<CoreSlots | undefined> slotProps: Signal<CoreSlotProps | undefined> }
// Features live directly on store, not nested under .feature readonly host: Host // rendered event + container signal + onMounted lifecycle readonly props: PropsModel // framework-provided configuration readonly tokens: TokenModel // the token tree (the value's source of truth), the SELECTION, live node map, DOMβmodel facade, ref registries, caret/selection DOM ops, and `rowSeparator` β the one reader of the `layout` enum readonly slots: SlotsFeature // slot component/props, mark resolver, and the grip gutter (rowSeparator + draggable) readonly edit: EditController // replace(from, to, text) / setValue(text) β single batched write path readonly overlay: OverlayController // match, element, slot, select, close readonly keyboard: KeyboardController // input handling and block editing readonly block: BlockController // Block layout for the whole editor: hover, drag, drop edge, menu readonly clipboard: ClipboardController // copy/cut handling readonly api: MarkputHandle // the ref handle: container, focus()}State and props access
Section titled βState and props accessβInternal feature state, computeds, and events live directly on store.<name>.*. Values and options passed from React/Vue live on store.props and are updated via store.props.set().
// Read the live root nodes (readonly TreeNode[]) β reactive, and THE render readstore.tokens.nodes()
// The token tree owns the value; store.tokens.value() is its string projection.// Route edits through node anchors; setValue() is the whole-value form.store.tokens.replaceBetween(store.tokens.anchorAt(0), store.tokens.anchorAt(5), 'Hello')store.tokens.setValue('Hello @[World]')
// Framework-provided props (MarkedInput calls store.props.set on each render)store.props.set({readOnly: true})
// Use in component (framework-specific reactive binding). `nodes` is the data, and// the only renderer subscription there is: an adapter re-renders when the tree's// root list changes by reference, and nothing else tells it to.const {nodes} = useMarkput(s => ({nodes: s.tokens.nodes}))Features
Section titled βFeaturesβ11 features, each declaring its dependencies as positional constructor parameters with concrete feature types. The dependency graph is acyclic β features can only depend on features constructed above them in Store. They never import each other directly; all cross-feature access goes through the injected constructor parameters. MarkputHandle β the public host object the component ref exposes β follows the same rule: it owns nothing and delegates every member to the feature that owns the state.
Signal subscription order is significant: inside its constructor onMounted hook, TokenModel registers a single watch over the (value, parser, rowSeparator) tuple before any other consumer registers a watcher in onMounted. When any of the three changes, the watch callback runs the private #reparse, so by the time downstream listeners observe a value.current change, tokens.nodes() already reflects the new value.
| Feature | Responsibility |
|---|---|
| Host | Adapter-fed runtime state: the rendered event and the container HTMLElement |
| EditController | Unified user edit path: replace(from, to, text) between node anchors, plus setValue(text) for a whole-value rewrite |
| TokenModel | Parsing, the token tree, the selection (state + DOM driver), live node map (id-keyed), one commit pipeline, DOMβmodel facade, adapter ref registries β see features/tokens/README.md |
| OverlayController | Overlay trigger detection, position, open/close |
| SlotsFeature | Container ref, slot component/props resolution, mark resolver |
| KeyboardController | Text input and block editing |
| BlockController | Block layout for the whole editor: the hovered/dragged row, the drop edge, the open menu, the row verbs the menu triggers, and the row geometry the layer paints at |
| ClipboardController | Clipboard copy/cut handling |
KeyboardController registers ONE module: enableInput owns the whole keyboard tier β the beforeinput guard, paste, the delete keys and Ctrl/Cmd+A β and calls blockEdit.tsβs two block arms after its own shared checks. Those arms are all block layout still answers differently: Enter splits a row by inserting the separator, and an insertParagraph that reaches the guard anyway is dropped rather than mapped to a newline. A row MERGE is not among them β Backspace/Delete at a row boundary expands onto the separator through anchorsForDelete, the same arm that swallows an adjacent mark. Caret navigation is the browserβs: the container is the one editing host, so arrows and Home/End move natively and no core keyboard handler intercepts them. (Coreβs SuggestionsModel does claim ArrowUp/ArrowDown/Enter while the built-in Suggestions component is mounted β the adapter component only activates it.) The selection is not a feature of its own: store.tokens.selection is the stored anchor pair (see below).
Lifecycle Timing
Section titled βLifecycle TimingβReact/Vue render asynchronously, so initialization order matters:
// 1. Framework writes the container element via store.host.container(el).// β Each feature's onMounted callback fires with the live container element.// It also re-fires (with auto-disposal of the previous scope) if the// framework swaps to a different container.
// 2. After mount, the string boundary accepts props.value/defaultValue.// TokenModel's constructor watch over (value, parser, rowSeparator) subscribed// first inside its onMounted hook, so tokens.nodes() reflects the new value// before any other onMounted watcher observes it.
// 3. Sync the one-host topology (layout effect)// β TokenModel's commit pipeline runs its first bind: walks the DOM, creates// TokenHandle instances, applies the editable state (bare text surfaces,// ce=false value marks and mark controls, no tabindex anywhere), and arms one// text effect per bound text surface (which writes its textContent)
// 4. Each token's ref fires as it paints β store.tokens.consign(id)(element)// β rebind(id): that token's share of the walk, no whole-tree pass per ref
// 5. Framework writes store.host.container(null) on unmount// β Each onMounted scope is disposed (DOM listeners removed, watchers torn down)Block System (Block Layout)
Section titled βBlock System (Block Layout)βInline layout: tokens render in one flow as alternating [text, mark, text, ...].
Block layout (layout="block", with draggable adding the reorder affordance): each root node
is a ROW, wrapped in a <Block> component that renders the rowβs children and nothing else. The
row controls β grip, drop indicator, row menu β are not in the row. One <BlockControls> per editor
paints all three, as the containerβs last child, position: absolute; inset: 0 over the rows.
BlockController (store.block) owns them for the whole editor, as four signals
addressed by row id:
class BlockController { readonly state = { hovered: signal<number | null>(...), // row id under the pointer dragging: signal<number | null>(...), // row id being dragged drop: signal<{id: number; edge: 'before' | 'after'} | null>(...), menu: signal<{id: number; top: number; left: number} | null>(...), geometry: signal(...), // re-measure clock } // ...five container listeners, and three geometry clocks: a ResizeObserver on each of the // container's two boxes (the layer's origin is the PADDING box, which neither one alone reports), // a watch on the commit clock, and a rAF loop over the PAINTED rows while the controls are visible}There is no per-row store and no per-row control DOM. At 200 rows the shape this replaced mounted
201 grip buttons, 201 control() roots and 1608 listeners; measured mount was 44 ms and 1005 DOM
nodes, against 18 ms and 403 for one layer.
The price is geometry: .Block { position: relative } made a per-row grip free, while a layer
measures. boxOf(id) answers a rowβs box in CONTAINER-LOCAL coordinates (which carry
scrollTop, so they are scroll-proof), and rowAt(clientY) hit-tests a pointer with a binary
search over the vertically tiled rows. Hover is therefore geometric rather than DOM containment:
the drag gutter hovers its row, and a point in the gap between two rows snaps to the nearest.
A measured box goes stale on any reflow, so the model tells the layer to re-measure on three
clocks: the containerβs own size, every commit (a row that reflows moves every row BELOW it while
the containerβs box does not change), and β while the controls are painted β a rAF loop over the painted
rows, for the reflow that is neither. An image or a webfont landing inside a row ABOVE the painted
one moves it without changing its size, so both observers stay silent; measured, the grip sat 66px
off its row and stayed there. The loop reads two rects per painted row per frame (0.9 Β΅s with a
clean layout, 20 Β΅s when every read forces a reflow), bumps the clock only when a box actually
moved, and does not exist while the pointer is away. That last property is also its one gap:
alwaysShowHandle paints a grip with no pointer present, so a reflow that moves row 0 while both
container boxes and row 0βs own box keep their size leaves that grip behind, and the pointer does
not repair it β hover re-measures only when the hovered ROW changes, and the resting row is
already that row. The container padding change that used to demonstrate this (60px in both
adapters) needed no frames after all and is closed by the second container observation; what
survives is measured at 60px for consumer content growing ABOVE the rows inside a fixed-height
container, and 30px under display: flex; justify-content: center when a lower row grows.
Pre-existing, and left open rather than paid for with frames that would run for the editorβs whole
lifetime.
Row operations are calls on the rowβs own node: addRow/deleteRow/duplicateRow resolve the
open menuβs id through tokens.find and call insertAfter(separator)/remove()/duplicate(),
so a row that has left the tree refuses. The drop is addressed by id too: its source is
state.dragging, and both it and the drop edgeβs row resolve through rootIndexOf on the live
tree.
That signal is also the PROVENANCE test. Only beginDrag β the gripβs own dragstart β sets it,
and it is per-editor, so dragover paints no drop edge for a drag this editor did not start and
drop never claims one; two editors on a page discriminate each other for free, the way
captureMarkupPaste already scopes the clipboard per container. A foreign drop falls through to
the browserβs own editable drop, where insertFromDrop inserts the dragged text. Until 2026-08-23
there was no provenance test at all: the handler parsed text/plain as a row index and refused
only NaN, so the bare text 0 dragged in from another application reordered the document, and
so did a second markput editorβs row. Because the drop no longer reads the payload, text/plain
now carries the rowβs own text β what a drag out of the editor should deliver.
Row controls addressed by position rather than by row identity are the narrow exception to ADR-0007, amended for it; a rowβs own state still travels with the row.
Core-Owned DOM And Cursor Management
Section titled βCore-Owned DOM And Cursor ManagementβCore owns token identity (stable ids and live handles), DOM registration, DOMβanchor selection mapping, value mutation, and caret placement. React and Vue render adapter-owned structural DOM and register it with core through private refs. Features communicate through store.<name>.* and store.props; production code must not infer token identity from DOM child order. All DOMβmodel operations go through store.tokens β see features/tokens/README.md for the full surface.
The selection: store.tokens.selection plus a private driver
Section titled βThe selection: store.tokens.selection plus a private driverβThere is no selection feature and no store.selection. TokenModel owns both halves (split by owner, not by convenience):
- State β
store.tokens.selection(tree/selection.ts, DOM-free). The STORED form is a pair of node anchors, never offsets;anchors()reads them,select/selectNode/selectAll/clearwrite them, andrepair(result)applies the post-adoption anchor adoption resolved.isAllSelectedis the one derived number left, computed inside the tree layer where that arithmetic is legal. - DOM I/O β the private
SelectionDriver(dom/SelectionDriver.ts). It owns theselectionchangesync, thefocusoutclear, the caret application and the editing hostβscontenteditable. Its two externally-needed reads are delegated on the model:tokens.domAnchors()(the live browser selection as anchors) andtokens.focusFirst(). - The driverβs ONE direct DOM write is the editing host itself β
container.contentEditable, gated byprops.readOnly. Everything else goes through the modelβs ownDomModelβplaceCaret(anchor)/selectRange(anchor, head)β which the driver holds as a dep. DOMβanchor boundary mapping (dom/domBoundary.ts) and caret placement (dom/caret.ts) live entirely inside the token layer and are not exported from@markput/core. - The selection is re-applied after every bind: the driverβs
onMountedhook watchestokens.bound(one pulse per bind, so every handle matches an element in the document) and the stored anchors, re-running the placement against the live surfaces. It is the DOM clock and not the commit clock because a caret landing in a node BORN by the commit has no handle until bind makes one. - Editable policy is one host deep and nothing sweeps:
props.readOnlywrites the containerβs owncontenteditablethrough the driver, and the topology below it (bare text surfaces,ce=falsevalue marks, bare slot marks with frozen controls) is applied once per bind.
Token layer: store.tokens
Section titled βToken layer: store.tokensβTokenModel is the thin public shell over a live-node core β dom/TokenHandle.ts (the per-token live binding), dom/commit.ts (the one commit pipeline), and dom/bind.ts (the DOM walk that binds freshly rendered DOM). It consolidates the DOM responsibilities that were previously split across separate ref/index/surface modules:
- Adapter ref registries β
tokens.control()andtokens.children(ownerId)register non-editable control elements and__slot__child-sequence hosts, andtokens.consign(id)registers a tokenβs own element β a block rowβs wrapper included, since a row IS a token (ADR-0009). All are keyed by the owning tokenβs stable id. A Markβs registered element is the box-less wrapper markput renders around it, not the consumerβs component β so no consumer needs to forward a ref, and core writes attributes only to elements it owns. - Live node map and commit pipeline β one id-keyed
Map<number, TokenHandle>, mutated only through the pipeline. Elements are CONSIGNED by the adapters through refs, keyed by token id, rather than derived by walking the painted DOM;bindprojects those registries onto the node layer. Text never reaches the pipeline: binding arms one conditional-write effect per bound text surface, subscribed to that nodeβstextsignal, so a text edit repaints no component.nodes()is the live tree (consistent withtokens.value()) and what both adapters render. There are TWO payload-free clocks, because one event was answering two questions:committedfires once per commit β including the commits that move no element, such as a row reorder or a mark value change β andboundfires once per bind, which is what the caret needs. - DOMβmodel facade β
handleAt(node)resolves a DOM node to its handle (or'control'),handle(id)resolves a stable id to its live handle,anchorFor(node, offset)maps a DOM boundary to a node anchor in the live tree, andcaretRect()/selectedContent()read the live selection. The placement commands (placeCaret,selectRange) and the raw snapshot read live on their owner,dom/DomModel, which nothing outside the token layer holds. No member of this facade takes or returns an absolute document offset βanchorAt/offsetOfare the tree layerβs own boundary, kept because that is the one place a coordinate may be formed. - Editable-state application β
bindapplies the one-host topology to newly mounted surfaces, and that is the whole of it. The containerβscontenteditablebelongs toprops.readOnly, through the selection driverβs{immediate: true}watch; there is no second writer and no manual override.
See packages/core/src/features/tokens/README.md for the full architecture of the token layer.
Framework Hooks
Section titled βFramework HooksβuseMark
Section titled βuseMarkβAvailable in both React and Vue. Returns the live MarkNode for the current mark token:
const mark = useMark()mark.update({value: 'updated'})mark.remove()Use useMarkInfo() for structural metadata: depth and hasNestedMarks.
useOverlay
Section titled βuseOverlayβAvailable in both React and Vue. Provides overlay state and actions:
const { style, close, select, match, ref } = useOverlay()| Property | Type | Description |
|---|---|---|
style |
{ left, top } |
Positioning coordinates |
close |
() => void |
Close the overlay |
select |
(value: { value, meta? }) => void |
Select an overlay item |
match |
OverlayMatch |
Current trigger match |
ref |
RefObject<HTMLElement> |
Ref to attach to overlay DOM |
useStore
Section titled βuseStoreβReturns the Store instance from context:
const store = useStore()Extensibility Points
Section titled βExtensibility Pointsβ1. Custom Mark Components
Section titled β1. Custom Mark Componentsβ<MarkedInput Mark={CustomMark} />2. Custom Overlay
Section titled β2. Custom Overlayβ<MarkedInput Overlay={CustomOverlay} />3. Custom Slots
Section titled β3. Custom SlotsβReplace internal rendering components:
<MarkedInput slots={{ container: MyCustomContainer, span: MyCustomSpan, block: MyCustomBlock, // drag mode only }}/>Common Architectural Patterns
Section titled βCommon Architectural PatternsβPattern: Controlled Component
Section titled βPattern: Controlled Componentβfunction App() { const [value, setValue] = useState('')
return ( <MarkedInput value={value} onChange={setValue} Mark={MyMark} /> )}Pattern: Uncontrolled Component
Section titled βPattern: Uncontrolled Componentβfunction App() { return ( <MarkedInput defaultValue="Initial" Mark={MyMark} /> )}defaultValue is read once, to start a tree that holds nothing yet. It is not a
value the editor reverts to: an editor that stops receiving value (a parent
passing undefined after a string) keeps what is on screen, because the tree β
not a remembered string β is what an arrival without a value falls back to. To go
back to earlier text, pass it.
Pattern: Block Layout With Drag
Section titled βPattern: Block Layout With Dragβfunction App() { return ( <MarkedInput layout="block" draggable Mark={MyMark} /> )}Performance Characteristics
Section titled βPerformance CharacteristicsβParsing Performance
Section titled βParsing Performanceβ| Text Length | Parse Time | Notes |
|---|---|---|
| 100 chars | ~0.1ms | Very fast |
| 1,000 chars | ~1ms | Fast |
| 10,000 chars | ~10ms | Acceptable |
| 100,000 chars | ~100ms | Consider optimization |
Re-render Optimization
Section titled βRe-render Optimizationβ- Signal-based: only components subscribing through
useMarkput()to a changed signal re-render - Token changes: only affected tokens re-render (not the entire tree)
- Overlay opens: only the overlay component re-renders
See also:
- How It Works - Understanding how Markput processes text
- Performance - Detailed performance analysis