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.
The Big Picture
Section titled “The Big Picture”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 ComponentsThis process happens automatically when you type, and the result is a fully interactive editor.
Core Editor Engine
Section titled “Core Editor Engine”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 viastore.tokens - raw DOM selection to serialized value ranges
- value edits through
store.edit.replace(from, to, text)(orstore.tokens.replaceBetween()/setValue()), read back withstore.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.
Marks vs Tokens
Section titled “Marks vs Tokens”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 boundariesMark 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
Tokens
Section titled “Tokens”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
Section titled “Markup Patterns”Markup patterns define how marks are identified in your text. They use placeholder syntax:
Placeholders
Section titled “Placeholders”| 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 |
Common Patterns
Section titled “Common Patterns”// 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>Pattern Matching Rules
Section titled “Pattern Matching Rules”- Greedy Matching: Patterns are matched from left to right, longest match first
- Non-Overlapping: A character can only belong to one mark
- Escape Sequences: (Not currently supported - use custom parsers for complex escaping)
The Parsing Process
Section titled “The Parsing Process”How Markput Parses Text (Deep Dive)
Let’s walk through how Markput processes your text step-by-step:
Step 1: Preparsing
Section titled “Step 1: Preparsing”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-42Step 2: Pattern Matching
Section titled “Step 2: Pattern Matching”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'Step 3: Tokenization
Section titled “Step 3: Tokenization”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: '!' }]Step 4: Rendering
Section titled “Step 4: Rendering”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
Section titled “Nested Marks”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 markToken Tree for Nested Marks
Section titled “Token Tree for Nested Marks”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.
Rendering Nested Marks
Section titled “Rendering Nested 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
Section titled “The Overlay System”The overlay system handles autocomplete and suggestion menus.
Trigger Flow
Section titled “Trigger Flow”User types '@' ↓Trigger detected ↓Overlay rendered ↓User selects 'Alice' ↓Text updated: '@[Alice]' ↓Overlay closedOverlay Lifecycle
Section titled “Overlay Lifecycle”- Detection: Text change matches a trigger character
- Rendering: Overlay component is rendered with suggestions
- Positioning: Overlay is positioned at caret location
- Selection: User selects an item or closes overlay
- Insertion: Selected value is inserted as a mark
- Cleanup: Overlay is unmounted
Overlay Props
Section titled “Overlay Props”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}Component Architecture
Section titled “Component Architecture”Internal Architecture (For Curious Minds)
High-Level Structure
Section titled “High-Level Structure”<MarkedInput> └── <Container> (the one contenteditable host) ├── <span> (plain text — bare, inherits editability) ├── <Mark> (your component — contenteditable=false) ├── <span> (plain text) └── <Overlay> (if triggered)Props Flow
Section titled “Props Flow”MarkedInput Props ↓[Configuration Layer] ↓[Parser + Store] ↓[Token Renderer] ↓React Components ↓User Interaction ↓Events → onChange ↓Update StateThe key insight: Everything flows through the store, which triggers re-renders only when tokens change.
State Management
Section titled “State Management”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 vs Uncontrolled
Section titled “Controlled vs Uncontrolled”// ✅ Controlled (recommended)const [value, setValue] = useState('')<MarkedInput value={value} onChange={setValue} />
// ⚠️ Uncontrolled (less common)<MarkedInput defaultValue="initial" />Event System
Section titled “Event System”Built-in Events
Section titled “Built-in Events”| 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 |
Custom Event Listeners
Section titled “Custom Event Listeners”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 System
Section titled “Options System”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, }, }, }, ]}/>Option Resolution Priority
Section titled “Option Resolution Priority”1. option.slots.mark (highest priority)2. MarkedInput.Mark prop3. undefined (error if no Mark provided)Performance Considerations
Section titled “Performance Considerations”Performance Tips & Optimization (Optional Reading)
Re-render Optimization
Section titled “Re-render Optimization”Markput minimizes re-renders:
- Token tree is memoized
- Components re-render only when their token changes
- Use
React.memofor expensive Mark components
const ExpensiveMark = React.memo(({value}) => { // Complex rendering logic return <span>{value}</span>})Large Documents
Section titled “Large Documents”For large documents (1000+ marks):
- Consider debouncing
onChange - Use
defaultValueif possible - Implement virtualization for mark lists
For more details, see the Performance Optimization guide.
Debugging Tips
Section titled “Debugging Tips”Troubleshooting & Debug Tools
Visualize Tokens
Section titled “Visualize Tokens”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.
Check Markup Matching
Section titled “Check Markup Matching”// Enable debug mode (if available)<MarkedInput debug value={value} onChange={setValue} />// Check console for parsing logsCommon Issues & Solutions
Section titled “Common Issues & Solutions”| 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.