Skip to content

How It Works

This guide explains how Markput works under the hood. Understanding these concepts will help you build more sophisticated editors and troubleshoot issues effectively.

TL;DR: Markput turns text patterns into React components. You define patterns like @[__value__], Markput parses them into tokens, and renders them as your custom components.

Visual Overview (Optional)

Markput transforms plain text with special patterns into interactive React components. Here’s the flow:

Plain Text with Patterns
[Parser]
Token Tree
[Renderer]
React Components

This process happens automatically when you type, and the result is a fully interactive editor.

Markput’s core owns the editor-engine primitives:

  • token addresses and token index validation
  • adapter DOM registration through the ref registries (store.tokens.control() / store.tokens.children(ownerId)), with token-DOM lookup via store.tokens
  • raw DOM selection to serialized value ranges
  • value edits through store.edit.replace(from, to, text) (or store.tokens.replaceBetween() / setValue()), read back with store.tokens.value()
  • caret range application to the DOM after framework renders
  • mark commands through the live MarkNode (mark.update() / mark.remove())

React and Vue render structural token shells, text surfaces, slot roots, rows, and controls, then register them with core through private refs. Features do not rely on DOM child order or on public data attributes to locate tokens.

A Mark’s own element belongs to you, so core never asks for it and never writes to it. Each Mark is rendered inside one element markput owns — a span with display: contents, which generates no box and so is invisible to layout. That wrapper is what core registers, and it is where the contenteditable="false" that makes a value-only Mark atomic is written. Your Mark component needs to forward nothing, which is what lets a third-party component be passed straight through as Mark.

A mark is a special pattern in your text that gets rendered as a React component. It highlights or transforms specific text segments into interactive elements.

'Hello @[World](meta)!'
// ↑ ↑
// Mark boundaries

Mark Properties:

  • Content: The entire matched pattern @[World](meta)
  • Value: The text to display "World"
  • Meta: Optional metadata "meta"
  • Position: Start and end indices in the original string

A token is the internal representation used by Markput’s parser. Your text is broken down into tokens:

'Hello @[World](meta)!'[
// Becomes this token tree:
({type: 'text', content: 'Hello '},
{type: 'mark', value: 'World', meta: 'meta', content: '@[World](meta)'},
{type: 'text', content: '!'})
]

Token Types:

  • TextToken: Plain text segments
  • MarkToken: Marked segments (rendered as your Mark component)

Markup patterns define how marks are identified in your text. They use placeholder syntax:

Placeholder Description Supports Nesting
__value__ Main content (plain text only) ❌ No
__meta__ Metadata (plain text only) ❌ No
__slot__ Content that can contain other marks ✅ Yes
// Basic mention
'@[__value__]'
// Matches: @[Alice], @[Bob]
// Mention with metadata
'@[__value__](__meta__)'
// Matches: @[Alice](user:1), @[Bob](user:2)
// Hashtag
'#[__value__]'
// Matches: #[react], #[javascript]
// Bold (supports nesting)
'**__slot__**'
// Matches: **bold text**, **bold with *italic* inside**
// HTML-like (two values pattern)
'<__value__>__slot__</__value__>'
// Matches: <div>content</div>, <span>text</span>
  1. Greedy Matching: Patterns are matched from left to right, longest match first
  2. Non-Overlapping: A character can only belong to one mark
  3. Escape Sequences: (Not currently supported - use custom parsers for complex escaping)
How Markput Parses Text (Deep Dive)

Let’s walk through how Markput processes your text step-by-step:

The text is scanned for potential mark boundaries.

Input: 'Hello @[World](meta) and @[Alice](user:1)!'
Identifies: Two potential marks at positions 6-22 and 27-42

Each potential mark is tested against your markup patterns.

Markup: '@[__value__](__meta__)'
Test: '@[World](meta)' → ✅ Match!
value: 'World', meta: 'meta'
Test: '@[Alice](user:1)' → ✅ Match!
value: 'Alice', meta: 'user:1'

The text is broken into tokens.

[
{ type: 'text', content: 'Hello ' },
{ type: 'mark', value: 'World', meta: 'meta', ... },
{ type: 'text', content: ' and ' },
{ type: 'mark', value: 'Alice', meta: 'user:1', ... },
{ type: 'text', content: '!' }
]

Each token is rendered as a React element.

TextToken → <span>Hello </span>
MarkToken → <Mark value="World" meta="meta" />
TextToken → <span> and </span>
MarkToken → <Mark value="Alice" meta="user:1" />
TextToken → <span>!</span>

Key insight: This happens for every keystroke, keeping tokens in sync with your text.

Nested marks allow hierarchical structures. Use __slot__ to enable nesting:

// Flat (no nesting)
markup: '*__value__*'
value: '*bold with *italic* inside*'
// Result: One mark with value = "bold with *italic* inside"
// Nested (supports hierarchy)
markup: '*__slot__*'
value: '*bold with *italic* inside*'
// Result: Parent mark contains child mark
Token Structure Example (Advanced)
'**bold with *italic* text**'
// Token tree:
{
type: 'mark',
value: undefined,
nested: 'bold with *italic* text',
children: [
{ type: 'text', content: 'bold with ' },
{
type: 'mark',
value: undefined,
nested: 'italic',
children: [
{ type: 'text', content: 'italic' }
]
},
{ type: 'text', content: ' text' }
]
}

Notice the children array - this is what makes nesting possible. Each mark can contain text and other marks.

When a mark has children, they’re rendered as React children:

const Mark = ({children, nested}) => {
// For nested marks, use children (ReactNode)
if (children) {
return <strong>{children}</strong>
}
// For flat marks, use nested string
return <strong>{nested}</strong>
}

The overlay system handles autocomplete and suggestion menus.

User types '@'
Trigger detected
Overlay rendered
User selects 'Alice'
Text updated: '@[Alice]'
Overlay closed
  1. Detection: Text change matches a trigger character
  2. Rendering: Overlay component is rendered with suggestions
  3. Positioning: Overlay is positioned at caret location
  4. Selection: User selects an item or closes overlay
  5. Insertion: Selected value is inserted as a mark
  6. Cleanup: Overlay is unmounted

The useOverlay() hook provides:

{
style: { left: 120, top: 45 }, // Caret position
close: () => {...}, // Close the overlay
select: (item) => {...}, // Insert a mark
match: { // Match details
value: 'ali', // Current typed text
source: '@ali', // Full matched string
trigger: '@' // The trigger character
},
ref: overlayRef // For outside click detection
}
Internal Architecture (For Curious Minds)
<MarkedInput>
└── <Container> (the one contenteditable host)
├── <span> (plain text — bare, inherits editability)
├── <Mark> (your component — contenteditable=false)
├── <span> (plain text)
└── <Overlay> (if triggered)
MarkedInput Props
[Configuration Layer]
[Parser + Store]
[Token Renderer]
React Components
User Interaction
Events → onChange
Update State

The key insight: Everything flows through the store, which triggers re-renders only when tokens change.

Markput uses an internal store for managing editor state:

Store State:
{
value: string, // Current text
tokens: TreeNode[], // The live token tree (the source of truth)
selection: Range, // Cursor/selection position
overlay: OverlayState, // Overlay visibility & data
focused: boolean // Focus state
}
// ✅ Controlled (recommended)
const [value, setValue] = useState('')
<MarkedInput value={value} onChange={setValue} />
// ⚠️ Uncontrolled (less common)
<MarkedInput defaultValue="initial" />
Event When Triggered Use Case
onChange Text changes Update parent state
onFocus Editor focused Show toolbar
onBlur Editor blurred Hide toolbar
onKeyDown Key pressed Custom shortcuts
onSelectionChange Selection changes Update toolbar state

Use useListener hook for custom events:

import {useListener} from '@markput/react'
const Mark = () => {
useListener(
'customEvent',
data => {
console.log('Custom event:', data)
},
[]
)
return <span>Mark</span>
}

Options allow per-pattern configuration. Each pattern can have its own Mark component and overlay:

<MarkedInput
options={[
{
markup: '@[__value__](__meta__)', // Pattern 1: mentions
slots: {mark: MentionComponent},
slotProps: {overlay: {trigger: '@', data: users}},
},
{
markup: '#[__value__]', // Pattern 2: hashtags
slots: {mark: HashtagComponent},
slotProps: {overlay: {trigger: '#', data: hashtags}},
},
]}
/>
Advanced: Full Example with Props Transform
<MarkedInput
options={[
{
markup: '@[__value__](__meta__)',
slots: {
mark: MentionComponent,
overlay: MentionOverlay,
},
slotProps: {
mark: ({value, meta}) => ({
// Transform extracted props
label: value,
userId: meta,
}),
overlay: {
// Static overlay config
trigger: '@',
data: users,
},
},
},
]}
/>
1. option.slots.mark (highest priority)
2. MarkedInput.Mark prop
3. undefined (error if no Mark provided)
Performance Tips & Optimization (Optional Reading)

Markput minimizes re-renders:

  • Token tree is memoized
  • Components re-render only when their token changes
  • Use React.memo for expensive Mark components
const ExpensiveMark = React.memo(({value}) => {
// Complex rendering logic
return <span>{value}</span>
})

For large documents (1000+ marks):

  • Consider debouncing onChange
  • Use defaultValue if possible
  • Implement virtualization for mark lists

For more details, see the Performance Optimization guide.

Troubleshooting & Debug Tools
import {parse} from '@markput/core'
const tokens = parse(value, [{markup: '@[__value__]'}])
console.log(JSON.stringify(tokens, null, 2))

This is your best friend for understanding what Markput “sees” in your text.

// Enable debug mode (if available)
<MarkedInput debug value={value} onChange={setValue} />
// Check console for parsing logs
Issue Cause Solution
Marks not rendering Markup pattern mismatch Check pattern syntax with console.log
Infinite re-renders onChange creates new reference Use useCallback
TypeScript errors Generic type mismatch Specify types explicitly in <MarkedInput<YourType>>
Overlay not showing Trigger mismatch Check that trigger character matches your pattern

Still stuck? Ask in GitHub Discussions.