Editor Architecture

Understanding the fundamental architecture of rich text editors: model-view separation, document model design, and view layer responsibilities.

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.

Read detailed guide on Asynchronous 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.

Read detailed guide on Hook System Implementation →

Event System Architecture

A well-designed event system enables plugins to react to editor state changes and user interactions.

Read detailed guide on Event System Architecture →

Performance Optimization

Large documents require careful optimization to maintain smooth editing performance.

Read detailed guide on Performance Optimization →

Rendering Pipeline

A well-structured rendering pipeline ensures consistent updates and good performance.

Read detailed guide on Rendering Pipeline →

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.

Read detailed guide on Model-DOM Synchronization →

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.

Read detailed guide on History Management →

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.

Read detailed guide on Input Handling & IME →

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