Overview
Modern rich text editors follow a consistent architectural pattern that separates the document model from its visual representation. This separation is the foundation of maintainable, extensible editors.
Model-View Separation
The core principle of editor architecture is separating the document model (your internal representation) from the view (the DOM).
Document Model
- Abstract representation
- Schema-validated structure
- Immutable or versioned
- Framework-agnostic
- Testable in isolation
- Position-based (paths, not DOM)
View (DOM)
- HTML representation
- User-visible interface
- Mutable and interactive
- Browser-specific quirks
- Handles user input
- DOM-based selection
Why Separate?
1. DOM is unreliable:
- Browser-specific behavior and quirks
- Inconsistent HTML structures
- Selection can become invalid after DOM changes
- Hard to test and reason about
2. Model provides predictability:
- Schema-validated structure
- Consistent representation
- Framework-agnostic
- Easier to test
3. Enables advanced features:
- Undo/redo with history
- Collaborative editing
- Serialization to different formats
- Multiple views of the same document
4. Makes operations composable:
- Transforms can be combined
- Operations are reversible
- Can validate before applying
Model Characteristics
The document model should be:
- Abstract: Independent of how it's rendered
- Validated: Always conforms to schema
- Immutable or versioned: Enables history and undo/redo
- Position-based: Uses paths/offsets, not DOM references
- Serializable: Can be converted to JSON, HTML, Markdown, etc.
// Example document model
{
type: 'document',
children: [
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'world', marks: [{ type: 'bold' }] }
]
},
{
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Title' }]
}
]
}View Characteristics
The view layer is responsible for:
- Rendering: Converting model to DOM
- Input handling: Intercepting user input and converting to model operations
- Selection sync: Keeping DOM selection in sync with model selection
- DOM updates: Efficiently updating only changed parts
The view is a projection of the model, not the source of truth. When the model changes, the view updates. When the user interacts with the view, it triggers model operations.
// View layer responsibilities
class View {
// Render model to DOM
render(model) {
// Convert model nodes to DOM elements
}
// Handle user input
handleInput(event) {
// Convert DOM input to model operation
const operation = this.inputToOperation(event);
this.editor.apply(operation);
}
// Sync selection
syncSelection(modelSelection) {
// Convert model selection to DOM selection
const domSelection = this.modelToDOMSelection(modelSelection);
this.setDOMSelection(domSelection);
}
}Document Model
The document model is your source of truth. It represents the document structure independently of how it's rendered.
Model Structure
Documents are hierarchical trees:
- Block nodes: Paragraphs, headings, lists, code blocks
- Inline nodes: Text, links, images (within blocks)
- Text nodes: Actual text content with marks
// Complete document model example
{
type: 'document',
children: [
{
type: 'heading',
level: 1,
children: [
{ type: 'text', text: 'Introduction' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'This is a ' },
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' and ' },
{ type: 'text', text: 'italic', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' text.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Visit ' },
{
type: 'link',
attrs: { href: 'https://example.com' },
children: [
{ type: 'text', text: 'example.com' }
]
},
{ type: 'text', text: ' for more.' }
]
}
]
}Deep Dive: Data Structures
Choosing between Tree and Flat Map structures, and how to address nodes (IDs vs Paths) are critical architectural decisions.
Immutability
Many editors use immutable models for:
- Easy undo/redo (just store previous versions)
- Time-travel debugging
- Predictable updates
- Framework integration (React, etc.)
// Immutable model update
function insertText(model, position, text) {
// Create new model instead of mutating
return {
...model,
children: model.children.map((child, index) => {
if (index === position.block) {
return insertTextInBlock(child, position, text);
}
return child;
})
};
}Versioning
Some editors use mutable models with versioning:
- More efficient for large documents
- Version numbers track changes
- Can still implement undo/redo
- Easier to implement collaborative editing
// Versioned model
class Document {
constructor() {
this.nodes = [];
this.version = 0;
}
insertText(position, text) {
// Mutate model
this.doInsertText(position, text);
this.version++;
return this.version;
}
getVersion() {
return this.version;
}
}View Layer
The view layer renders the model to DOM and handles user interactions.
Rendering
Rendering converts model nodes to DOM elements:
function renderNode(node) {
switch (node.type) {
case 'paragraph':
return createElement('p', renderChildren(node.children));
case 'heading':
return createElement(`h${node.level}`, renderChildren(node.children));
case 'text':
let element = document.createTextNode(node.text);
// Apply marks
if (node.marks) {
node.marks.forEach(mark => {
element = wrapWithMark(element, mark);
});
}
return element;
case 'link':
const link = createElement('a', { href: node.attrs.href });
link.appendChild(renderChildren(node.children));
return link;
}
}Input Handling
The view intercepts user input and converts it to model operations:
editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertText') {
e.preventDefault();
// Get current selection in model
const selection = getModelSelection();
// Create operation
const operation = {
type: 'insertText',
position: selection.anchor,
text: e.data
};
// Apply to model
editor.applyOperation(operation);
// Model change triggers view update
}
});Selection Synchronization
Selection must be kept in sync between DOM and model:
// When user changes selection in DOM
editor.addEventListener('selectionchange', () => {
const domSelection = window.getSelection();
const modelSelection = domToModelSelection(domSelection);
editor.setSelection(modelSelection);
});
// When model changes
editor.on('modelChange', () => {
const modelSelection = editor.getSelection();
const domSelection = modelToDOMSelection(modelSelection);
setDOMSelection(domSelection);
});State Management
Editor state includes:
- Document: The current document model
- Selection: Current selection in model coordinates
- History: Undo/redo stack
- Schema: Document schema definition
- Plugins: Plugin state
class EditorState {
constructor(schema) {
this.schema = schema;
this.doc = createEmptyDocument(schema);
this.selection = null;
this.history = new History();
this.plugins = new Map();
}
apply(operation) {
// Validate operation
if (!this.validate(operation)) {
return false;
}
// Save to history
this.history.push(this.doc, this.selection);
// Apply operation
this.doc = this.transform(this.doc, operation);
// Update selection
this.selection = this.updateSelection(this.selection, operation);
// Notify plugins
this.notifyPlugins('operation', operation);
return true;
}
}Architecture Patterns
Common patterns in editor architecture:
Plugin System
Plugins extend editor functionality:
class Plugin {
constructor(editor) {
this.editor = editor;
}
install() {
// Register hooks
this.editor.on('operation', this.handleOperation);
this.editor.on('render', this.handleRender);
}
uninstall() {
// Cleanup
this.editor.off('operation', this.handleOperation);
}
handleOperation(operation) {
// Intercept or modify operations
}
}
// Usage
editor.use(new HistoryPlugin());
editor.use(new LinkPlugin());
editor.use(new ImagePlugin());Command System
Commands are high-level operations:
class Command {
constructor(editor) {
this.editor = editor;
}
canExecute() {
// Check if command can run
return true;
}
execute() {
// Compose multiple operations
const operations = this.getOperations();
operations.forEach(op => this.editor.apply(op));
}
}
class BoldCommand extends Command {
execute() {
const selection = this.editor.getSelection();
if (selection.isCollapsed) {
// Toggle bold for next character
this.editor.setPendingMark('bold');
} else {
// Apply bold to selection
this.editor.apply({
type: 'applyMark',
range: selection,
mark: { type: 'bold' }
});
}
}
}Transform System
Transforms are low-level operations that modify the model:
class Transform {
insertText(doc, position, text) {
// Insert text at position
// Update all positions after insertion
return newDocument;
}
deleteRange(doc, range) {
// Delete content in range
// Update all positions after deletion
return newDocument;
}
applyMark(doc, range, mark) {
// Apply mark to text in range
return newDocument;
}
splitNode(doc, position) {
// Split node at position
return newDocument;
}
}Asynchronous Initialization
Modern editors often require asynchronous initialization for loading resources, parsing schemas, or setting up plugins. This is similar to how WebGPU requires async device initialization.
Hook System Implementation
A hook system allows plugins to extend editor functionality at specific points in the lifecycle. This pattern is used by webpack, Vue, and other extensible systems.
Event System Architecture
A well-designed event system enables plugins to react to editor state changes and user interactions.
Performance Optimization
Large documents require careful optimization to maintain smooth editing performance.
Rendering Pipeline
A well-structured rendering pipeline ensures consistent updates and good performance.
Model-DOM Synchronization
Synchronizing between your abstract document model and the DOM
is one of the most challenging aspects of building
contenteditable editors. This includes handling contenteditable="false" elements, preserving selection during updates, and dealing with
external DOM manipulation.
History Management
Managing undo/redo history in model-based editors requires
careful coordination between your model state and browser's DOM
history. This includes handling preventDefault() conflicts, IME composition state mismatches, and programmatic changes
that don't appear in browser history.
Input Handling & IME
Input handling is one of the most complex aspects of building contenteditable editors. It involves coordinating between browser events, IME composition states, keyboard shortcuts, and your document model. This includes handling iOS Safari's lack of composition events for Korean IME, mobile virtual keyboards, and text prediction.
How Different Editors Approach This
Different editors implement these concepts differently:
ProseMirror
- Strict schema-based validation
- Immutable document model
- Transform-based operations
- Separate state and view packages
- Plugin system with hooks
- Incremental DOM updates via DOMChange
Slate
- React-first architecture
- Immutable model with React state
- Operation-based transforms
- Normalization system
- Uses React's reconciliation
- Plugin system via middleware
Lexical
- Framework-agnostic core
- Mutable model with reconciliation
- Event-driven updates
- Decorator system for UI
- Incremental reconciliation
- Command system with listeners
Related Pages
Model & Schema
Schema design and validation
Data Structure Strategies
Tree vs Flat Map
Node ID System
ID vs Path addressing
Model-DOM Synchronization
Synchronizing model and DOM
History Management
Undo/redo history management
Transaction System
Using transactions for atomic history
Operations
Operation types and usage
HTML Mapping
HTML serialization and parsing
Input Handling & IME
Input handling and IME composition
Plugin Development
Plugin development guide