Model-DOM Synchronization

Synchronizing between your abstract document model and the DOM is one of the most challenging aspects of building contenteditable editors. This guide covers critical considerations and common pitfalls.

Overview

The core challenge in model-based editors is maintaining bidirectional synchronization: converting user DOM edits to model operations, and rendering model changes back to DOM. Special cases like contenteditable="false", browser extensions, and framework re-renders complicate this significantly.

⚠️ Why This is Difficult

  • contenteditable="false" behavior is inconsistent across browsers
  • Selection can be lost during DOM updates
  • External DOM manipulation (translations, extensions) breaks assumptions
  • Framework re-renders can reset caret position
  • Model operations must account for read-only regions

contenteditable=false Impact on Model

When contenteditable="false" elements exist in your document, they create read-only regions that must be represented in your model and handled carefully during operations.

Model Representation

Represent read-only nodes in your model:

interface Node {
  type: string;
  attrs?: Record<string, any>;
  content?: Node[];
  // Mark nodes as non-editable
  marks?: Mark[];
}

interface Mark {
  type: 'readonly' | 'locked' | 'protected';
  attrs?: Record<string, any>;
}

// Example: Image node that should not be editable
const imageNode = {
  type: 'image',
  attrs: {
    src: 'image.jpg',
    alt: 'Description',
    contenteditable: false // Store in model
  },
  marks: [{ type: 'readonly' }] // Mark as read-only
};

// Or use a separate flag
interface DocumentNode {
  type: string;
  editable: boolean; // Explicit flag
  attrs?: Record<string, any>;
  content?: DocumentNode[];
}

Key considerations:

  • Store contenteditable="false" state in model attributes
  • Use marks or flags to indicate read-only regions
  • Validate operations don't modify read-only nodes
  • Preserve read-only state during transformations

Rendering Considerations

When rendering model to DOM, preserve contenteditable="false":

class ModelRenderer {
  renderNode(node) {
    const element = document.createElement(this.getTagName(node.type));
    
    // Preserve contenteditable=false from model
    if (node.attrs?.contenteditable === false) {
      element.setAttribute('contenteditable', 'false');
    }
    
    // Add data attributes for model tracking
    element.setAttribute('data-node-id', node.id);
    element.setAttribute('data-node-type', node.type);
    
    // Render children
    if (node.content) {
      node.content.forEach(child => {
        element.appendChild(this.renderNode(child));
      });
    }
    
    return element;
  }
  
  // Prevent editing in read-only nodes
  setupReadonlyHandlers(element) {
    if (element.getAttribute('contenteditable') === 'false') {
      element.addEventListener('beforeinput', (e) => {
        e.preventDefault();
        e.stopPropagation();
      });
      
      element.addEventListener('input', (e) => {
        e.preventDefault();
        e.stopPropagation();
      });
    }
  }
}

⚠️ Browser Inconsistency

Some browsers (especially Chrome) may allow editing within contenteditable="false" elements. Always add programmatic protection using beforeinput event handlers.

Selection Boundaries

Selection that spans across editable and non-editable boundaries requires special handling:

class SelectionManager {
  normalizeSelection(range) {
    // Check if selection spans contenteditable=false
    const startEditable = this.isEditable(range.startContainer);
    const endEditable = this.isEditable(range.endContainer);
    
    if (!startEditable || !endEditable) {
      // Selection crosses boundary
      // Option 1: Expand to nearest editable boundary
      return this.expandToEditableBoundary(range);
      
      // Option 2: Collapse to nearest editable position
      // return this.collapseToEditablePosition(range);
    }
    
    return range;
  }
  
  isEditable(node) {
    let current = node;
    while (current && current !== this.editor) {
      if (current.getAttribute?.('contenteditable') === 'false') {
        return false;
      }
      current = current.parentElement;
    }
    return true;
  }
  
  expandToEditableBoundary(range) {
    // Find nearest editable ancestor
    let start = range.startContainer;
    while (start && !this.isEditable(start)) {
      start = start.parentElement;
    }
    
    let end = range.endContainer;
    while (end && !this.isEditable(end)) {
      end = end.parentElement;
    }
    
    if (start && end) {
      range.setStart(start, 0);
      range.setEnd(end, end.textContent?.length || 0);
    }
    
    return range;
  }
}

DOM to Model Conversion

Converting DOM changes back to model operations requires detecting contenteditable="false" and handling nested structures correctly.

Detecting contenteditable=false

When parsing DOM to model, detect and preserve read-only state:

class DOMParser {
  parseElement(element) {
    const node = {
      type: this.getElementType(element),
      attrs: this.parseAttributes(element),
      content: []
    };
    
    // Detect contenteditable=false
    const contenteditable = element.getAttribute('contenteditable');
    if (contenteditable === 'false') {
      node.attrs.contenteditable = false;
      node.marks = [{ type: 'readonly' }];
    }
    
    // Parse children
    for (const child of element.childNodes) {
      if (child.nodeType === Node.ELEMENT_NODE) {
        node.content.push(this.parseElement(child));
      } else if (child.nodeType === Node.TEXT_NODE) {
        node.content.push({
          type: 'text',
          text: child.textContent
        });
      }
    }
    
    return node;
  }
  
  parseAttributes(element) {
    const attrs = {};
    
    // Copy relevant attributes
    for (const attr of element.attributes) {
      if (this.isRelevantAttribute(attr.name)) {
        attrs[attr.name] = attr.value;
      }
    }
    
    return attrs;
  }
}

Nested Structures

Handle nested contenteditable attributes correctly:

class DOMParser {
  isEditable(element) {
    // Walk up the tree to determine editability
    let current = element;
    
    while (current && current !== this.editor) {
      const ce = current.getAttribute('contenteditable');
      
      if (ce === 'false') {
        return false;
      }
      
      if (ce === 'true') {
        return true;
      }
      
      // If inherit or not set, check parent
      current = current.parentElement;
    }
    
    // Default: editor is editable
    return true;
  }
  
  parseWithInheritance(element) {
    const node = this.parseElement(element);
    
    // Determine actual editability based on inheritance
    const actuallyEditable = this.isEditable(element);
    if (!actuallyEditable) {
      node.attrs.contenteditable = false;
      node.marks = [{ type: 'readonly' }];
    }
    
    return node;
  }
}

Browser Inconsistencies

Different browsers handle contenteditable="false" differently:

  • Chrome/Edge: May allow editing within contenteditable="false" elements
  • Firefox: Generally respects contenteditable="false" but cursor may disappear
  • Safari: Generally respects contenteditable="false"

Always validate DOM changes against your model's read-only constraints, regardless of browser behavior:

class OperationValidator {
  validateOperation(operation, model) {
    const targetNode = this.getNodeAtPath(model, operation.path);
    
    // Check if target is read-only
    if (targetNode?.attrs?.contenteditable === false) {
      throw new Error('Cannot modify read-only node');
    }
    
    // Check if operation would affect read-only nodes
    const affectedNodes = this.getAffectedNodes(operation, model);
    for (const node of affectedNodes) {
      if (node.attrs?.contenteditable === false) {
        throw new Error('Operation would affect read-only node');
      }
    }
    
    return true;
  }
}

Model to DOM Conversion

When rendering model to DOM, ensure contenteditable="false" attributes are correctly applied and preserved.

Preserving Read-only State

Always restore contenteditable="false" when updating DOM:

class ModelRenderer {
  updateDOM(model, previousModel) {
    const diff = this.diffModels(previousModel, model);
    
    diff.forEach(change => {
      switch (change.type) {
        case 'update':
          const element = this.findDOMElement(change.path);
          
          // Always restore contenteditable attribute
          if (change.node.attrs?.contenteditable === false) {
            element.setAttribute('contenteditable', 'false');
            this.setupReadonlyHandlers(element);
          } else {
            element.removeAttribute('contenteditable');
          }
          break;
          
        case 'insert':
          const newElement = this.renderNode(change.node);
          // contenteditable=false is set in renderNode
          this.insertElement(newElement, change.path);
          break;
      }
    });
  }
  
  renderNode(node) {
    const element = document.createElement(this.getTagName(node.type));
    
    // Copy all attributes including contenteditable
    if (node.attrs) {
      Object.entries(node.attrs).forEach(([key, value]) => {
        if (value !== undefined && value !== null) {
          element.setAttribute(key, String(value));
        }
      });
    }
    
    // Setup readonly handlers if needed
    if (node.attrs?.contenteditable === false) {
      this.setupReadonlyHandlers(element);
    }
    
    return element;
  }
}

Attribute Synchronization

Keep model attributes and DOM attributes in sync:

class AttributeSync {
  syncAttributes(modelNode, domElement) {
    // Model -> DOM
    if (modelNode.attrs) {
      Object.entries(modelNode.attrs).forEach(([key, value]) => {
        if (key === 'contenteditable' && value === false) {
          domElement.setAttribute('contenteditable', 'false');
        } else if (value !== undefined && value !== null) {
          domElement.setAttribute(key, String(value));
        }
      });
    }
    
    // DOM -> Model (for external changes)
    const domAttrs = {};
    for (const attr of domElement.attributes) {
      domAttrs[attr.name] = attr.value;
    }
    
    // Detect if contenteditable was changed externally
    if (domAttrs.contenteditable === 'false' && 
        modelNode.attrs?.contenteditable !== false) {
      // External change detected, update model
      this.updateModelAttribute(modelNode, 'contenteditable', false);
    }
  }
}

Selection Synchronization

Selection must be preserved when converting between DOM and model, especially when contenteditable="false" elements are involved.

Selection Loss Prevention

Save and restore selection during DOM updates:

class SelectionManager {
  saveSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    
    const range = selection.getRangeAt(0);
    
    // Convert DOM selection to model selection
    return {
      anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
      focus: this.domPositionToModelPath(range.endContainer, range.endOffset),
      collapsed: range.collapsed
    };
  }
  
  restoreSelection(modelSelection) {
    if (!modelSelection) return;
    
    // Convert model selection to DOM selection
    const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
    const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
    
    if (!anchorPos || !focusPos) return;
    
    const range = document.createRange();
    range.setStart(anchorPos.node, anchorPos.offset);
    range.setEnd(focusPos.node, focusPos.offset);
    
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
  }
  
  // Update selection after model operations
  updateSelectionAfterOperation(operation, previousSelection) {
    // Adjust selection based on operation
    const newSelection = this.adjustSelectionForOperation(
      previousSelection,
      operation
    );
    
    // Restore in next frame to ensure DOM is updated
    requestAnimationFrame(() => {
      this.restoreSelection(newSelection);
    });
  }
}

Boundary Handling

Handle selection boundaries at editable/non-editable junctions:

class BoundaryHandler {
  normalizeSelectionAtBoundary(range) {
    const startEditable = this.isEditable(range.startContainer);
    const endEditable = this.isEditable(range.endContainer);
    
    // If selection starts in non-editable, move to next editable
    if (!startEditable) {
      const nextEditable = this.findNextEditable(range.startContainer);
      if (nextEditable) {
        range.setStart(nextEditable.node, nextEditable.offset);
      } else {
        // No editable found, collapse selection
        range.collapse(true);
      }
    }
    
    // If selection ends in non-editable, move to previous editable
    if (!endEditable) {
      const prevEditable = this.findPreviousEditable(range.endContainer);
      if (prevEditable) {
        range.setEnd(prevEditable.node, prevEditable.offset);
      } else {
        range.collapse(false);
      }
    }
    
    return range;
  }
  
  findNextEditable(node) {
    // Walk forward in DOM tree to find next editable position
    let current = node;
    while (current) {
      if (this.isEditable(current)) {
        return { node: current, offset: 0 };
      }
      current = this.getNextSibling(current) || this.getNextSibling(current?.parentElement);
    }
    return null;
  }
}

External DOM Manipulation

External factors like browser translation, extensions, and framework re-renders can manipulate the DOM, breaking your model synchronization.

Browser Translation

Browser translation features manipulate DOM, potentially breaking your model:

class TranslationHandler {
  preventTranslation(element) {
    // Disable translation for editor
    element.setAttribute('translate', 'no');
    
    // Monitor for translation changes
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        // Detect if translation injected elements
        if (this.isTranslationElement(mutation.target)) {
          // Revert translation changes
          this.revertTranslation(mutation.target);
        }
      });
    });
    
    observer.observe(element, {
      childList: true,
      subtree: true,
      characterData: true
    });
  }
  
  isTranslationElement(element) {
    // Translation often adds specific classes or attributes
    return element.classList?.contains('translated') ||
           element.hasAttribute('data-translation-id');
  }
  
  revertTranslation(element) {
    // Restore original content from model
    const nodeId = element.getAttribute('data-node-id');
    if (nodeId) {
      const modelNode = this.getNodeById(nodeId);
      this.renderNode(modelNode, element);
    }
  }
}

⚠️ Translation Impact

Browser translation can inject <span> elements, modify text content, and break contenteditable="false" attributes. Always use translate="no" and monitor for changes.

Browser Extensions

Browser extensions can inject scripts and modify DOM:

class ExtensionHandler {
  detectExternalChanges() {
    // Use MutationObserver to detect unexpected changes
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        // Check if change matches our model
        if (!this.isExpectedChange(mutation)) {
          // External change detected
          this.revertToModel(mutation.target);
        }
      });
    });
    
    observer.observe(this.editor, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['contenteditable', 'data-node-id']
    });
  }
  
  isExpectedChange(mutation) {
    // Check if this change was initiated by our code
    return mutation.target.hasAttribute('data-expected-change') ||
           this.isOurOperation(mutation);
  }
  
  revertToModel(element) {
    // Restore from model
    const nodeId = element.getAttribute('data-node-id');
    if (nodeId) {
      const modelNode = this.getNodeById(nodeId);
      this.renderNode(modelNode, element.parentElement);
    }
  }
}

Framework Re-renders

Framework re-renders can reset DOM, losing selection and breaking synchronization:

class FrameworkSync {
  handleReRender() {
    // Save state before framework re-render
    const selection = this.saveSelection();
    const model = this.getModel();
    
    // Mark that we're about to update
    this.isUpdating = true;
    
    // Framework re-renders DOM
    // ... framework code ...
    
    // After re-render, restore
    requestAnimationFrame(() => {
      this.restoreModel(model);
      this.restoreSelection(selection);
      this.isUpdating = false;
    });
  }
  
  preventCaretJump() {
    // Save caret position before any DOM update
    const selection = this.saveSelection();
    
    // Perform update
    this.updateDOM();
    
    // Restore in next frame
    requestAnimationFrame(() => {
      this.restoreSelection(selection);
    });
  }
  
  // Use MutationObserver to detect framework changes
  watchForFrameworkChanges() {
    const observer = new MutationObserver(() => {
      if (!this.isUpdating) {
        // Unexpected change, sync from DOM to model
        this.syncFromDOM();
      }
    });
    
    observer.observe(this.editor, {
      childList: true,
      subtree: true,
      attributes: true
    });
  }
}

Undo/Redo Synchronization

Browser's native undo/redo stack doesn't include programmatic DOM changes. You need to implement custom undo/redo that synchronizes with your model.

Programmatic Changes in History

Track all model operations for undo/redo, including programmatic changes:

class HistoryManager {
  constructor(model) {
    this.model = model;
    this.undoStack = [];
    this.redoStack = [];
    this.maxSize = 50;
  }
  
  recordOperation(operation, beforeState, afterState) {
    const historyEntry = {
      operation,
      beforeModel: beforeState,
      afterModel: afterState,
      beforeSelection: this.saveSelection(),
      timestamp: Date.now()
    };
    
    this.undoStack.push(historyEntry);
    if (this.undoStack.length > this.maxSize) {
      this.undoStack.shift();
    }
    this.redoStack = [];
  }
  
  undo() {
    if (this.undoStack.length === 0) return false;
    const entry = this.undoStack.pop();
    this.model = entry.beforeModel;
    this.renderModelToDOM(this.model);
    requestAnimationFrame(() => {
      this.restoreSelection(entry.beforeSelection);
    });
    this.redoStack.push(entry);
    return true;
  }
}

⚠️ Browser History vs Custom History

Browser's native undo/redo (Ctrl+Z/Cmd+Z) may conflict with your custom implementation. Consider preventing default and handling keyboard shortcuts yourself.

Selection in History

Always save and restore selection with each history entry:

class HistoryManager {
  saveSelection() {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    const range = selection.getRangeAt(0);
    return {
      anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
      focus: this.domPositionToModelPath(range.endContainer, range.endOffset)
    };
  }
  
  restoreSelection(modelSelection) {
    if (!modelSelection) return;
    const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
    const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
    if (!anchorPos || !focusPos) return;
    const range = document.createRange();
    range.setStart(anchorPos.node, anchorPos.offset);
    range.setEnd(focusPos.node, focusPos.offset);
    const selection = window.getSelection();
    selection.removeAllRanges();
    selection.addRange(range);
  }
}

Browser History Integration

Prevent browser's native undo/redo from interfering:

class HistoryManager {
  preventBrowserUndoRedo() {
    this.editor.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
        e.preventDefault();
        if (e.inputType === 'historyUndo') {
          this.undo();
        } else {
          this.redo();
        }
      }
    });
  }
}

IME Composition Synchronization

IME composition creates temporary DOM changes that must be handled carefully to avoid corrupting your model.

Composition State Tracking

Track composition state to prevent model updates during composition:

class CompositionHandler {
  constructor() {
    this.isComposing = false;
    this.compositionText = '';
  }
  
  init(editor) {
    editor.addEventListener('compositionstart', () => {
      this.isComposing = true;
      this.compositionText = '';
    });
    
    editor.addEventListener('compositionend', (e) => {
      this.finalizeComposition(e.data || this.compositionText);
      this.isComposing = false;
    });
  }
  
  finalizeComposition(text) {
    const operation = {
      type: 'insertText',
      path: this.saveSelection().anchor,
      text: text
    };
    this.applyToModel(operation);
    this.renderModelToDOM();
  }
}

Handling Duplicate Events

Some browsers (especially iOS Safari with Korean IME) fire duplicate events:

class CompositionHandler {
  handleBeforeInput(e) {
    if (!e.isComposing) return;
    
    if (e.inputType === 'deleteContentBackward' && this.isComposing) {
      this.pendingDelete = e;
      e.preventDefault();
      return;
    }
    
    if (e.inputType === 'insertText' && this.isComposing && this.pendingDelete) {
      this.pendingDelete = null;
      this.handleCompositionUpdate(e.data);
      e.preventDefault();
      return;
    }
  }
}

Composition to Model Conversion

Convert composition result to model operation only after composition ends:

class CompositionHandler {
  compositionend(e) {
    const finalText = e.data || this.compositionText;
    const selection = this.saveSelection();
    const operation = {
      type: 'insertText',
      path: selection.anchor,
      text: finalText
    };
    const newModel = this.applyOperation(this.model, operation);
    this.renderModelToDOM(newModel);
    this.isComposing = false;
  }
}

Paste Operation Synchronization

Paste operations insert DOM content that must be converted to model operations while preserving formatting.

Paste DOM to Model

Convert pasted DOM content to model operations:

class PasteHandler {
  handlePaste(e) {
    e.preventDefault();
    const clipboardData = e.clipboardData;
    const html = clipboardData.getData('text/html');
    const text = clipboardData.getData('text/plain');
    const selection = this.saveSelection();
    
    if (!selection.collapsed) {
      this.applyOperation({ type: 'delete', path: selection.anchor });
    }
    
    const operations = html ? this.htmlToModelOperations(html) : this.textToModelOperations(text);
    operations.forEach(op => this.applyOperation(op));
    this.renderModelToDOM();
    requestAnimationFrame(() => {
      this.restoreSelection(this.calculateSelectionAfterPaste(selection, operations));
    });
  }
}

Formatting Preservation

Preserve or filter formatting based on your schema:

class PasteHandler {
  sanitizePastedHTML(html) {
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    const allowedTags = ['p', 'br', 'strong', 'em', 'u', 'a'];
    this.filterElements(doc.body, allowedTags);
    return doc.body.innerHTML;
  }
}

Paste During Composition

Handle paste operations that occur during IME composition:

class PasteHandler {
  handlePaste(e) {
    if (this.compositionHandler.isComposing) {
      this.compositionHandler.cancelComposition();
    }
    this.processPaste(e);
  }
}

Drag & Drop Synchronization

Drag & drop operations modify DOM directly. You need to detect these changes and convert them to model operations.

DOM Changes During Drag

Monitor DOM changes during drag operations:

class DragDropHandler {
  init(editor) {
    let draggedContent = null;
    editor.addEventListener('dragstart', (e) => {
      draggedContent = this.getSelectedContent();
      e.dataTransfer.effectAllowed = 'move';
    });
    
    editor.addEventListener('drop', (e) => {
      e.preventDefault();
      const dropPosition = this.getDropPosition(e);
      if (e.dataTransfer.effectAllowed === 'move') {
        this.removeDraggedContent();
      }
      this.insertAtPosition(dropPosition, draggedContent);
      const operations = this.dragDropToModelOperations(dropPosition, draggedContent);
      operations.forEach(op => this.applyOperation(op));
      this.renderModelToDOM();
    });
  }
}

Drop to Model Conversion

Convert drag & drop to model operations:

class DragDropHandler {
  dragDropToModelOperations(dropPosition, content) {
    const operations = [];
    const modelNodes = this.contentToModelNodes(content);
    modelNodes.forEach((node, index) => {
      operations.push({
        type: 'insert',
        path: { ...dropPosition, offset: dropPosition.offset + index },
        node: node
      });
    });
    return operations;
  }
}

Duplicate Prevention

Prevent duplicate elements (especially in Firefox):

class DragDropHandler {
  preventDuplicates() {
    this.editor.addEventListener('drop', (e) => {
      e.preventDefault();
      if (this.draggedElement && this.draggedElement.parentNode) {
        this.draggedElement.parentNode.removeChild(this.draggedElement);
      }
      this.insertAtDropPosition(e);
    });
  }
}

Transaction and DOM Updates

Transactions group multiple operations. DOM updates must be atomic and support rollback.

Atomic DOM Updates

Update DOM atomically for transactions:

class TransactionManager {
  applyTransaction(transaction) {
    const beforeDOM = this.snapshotDOM();
    const beforeSelection = this.saveSelection();
    
    try {
      transaction.operations.forEach(op => this.applyOperation(op));
      this.renderModelToDOM();
      requestAnimationFrame(() => {
        this.restoreSelection(beforeSelection);
      });
      this.commitTransaction(transaction);
    } catch (error) {
      this.rollbackDOM(beforeDOM);
      throw error;
    }
  }
}

DOM Rollback

Rollback DOM to previous state:

class TransactionManager {
  rollbackDOM(beforeDOM) {
    const parent = this.editor.parentNode;
    const newEditor = beforeDOM.cloneNode(true);
    Array.from(this.editor.attributes).forEach(attr => {
      newEditor.setAttribute(attr.name, attr.value);
    });
    parent.replaceChild(newEditor, this.editor);
    this.editor = newEditor;
    this.setupEventHandlers();
  }
}

Consistency Validation & Recovery

Periodically validate that model and DOM are in sync, and recover from inconsistencies.

Model-DOM Validation

Validate model and DOM match:

class ConsistencyValidator {
  validate() {
    const domModel = this.parseDOMToModel(this.editor);
    const differences = this.diffModels(this.model, domModel);
    if (differences.length > 0) {
      this.handleInconsistency(differences);
    }
    return differences.length === 0;
  }
}

Inconsistency Detection

Use MutationObserver to detect unexpected DOM changes:

class ConsistencyValidator {
  watchForInconsistencies() {
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (!this.isExpectedChange(mutation)) {
          this.handleUnexpectedChange(mutation);
        }
      });
    });
    observer.observe(this.editor, {
      childList: true,
      subtree: true,
      attributes: true
    });
  }
}

Recovery Strategies

Strategies for recovering from inconsistencies:

class ConsistencyValidator {
  recoverFromInconsistency(differences) {
    if (this.shouldRevertToModel(differences)) {
      this.revertDOMToModel();
    } else if (this.shouldSyncFromDOM(differences)) {
      this.syncModelFromDOM();
    } else {
      this.mergeDifferences(differences);
    }
  }
}

Best Practices

Best practices for maintaining model-DOM synchronization:

  • Always save selection before DOM updates: Use requestAnimationFrame to restore after updates
  • Store contenteditable state in model: Don't rely solely on DOM attributes
  • Validate operations against model: Check read-only constraints before applying
  • Use MutationObserver for external changes: Detect and revert unexpected DOM modifications
  • Preserve data attributes: Use data-node-id to track model-DOM mapping
  • Handle boundaries explicitly: Normalize selections that cross editable/non-editable boundaries
  • Test across browsers: contenteditable="false" behavior varies significantly
  • Use programmatic protection: Add beforeinput handlers even if browser respects attribute
class ModelDOMSync {
  // Complete synchronization workflow
  syncModelToDOM(model) {
    // 1. Save current selection
    const selection = this.saveSelection();
    
    // 2. Update DOM from model
    this.updateDOM(model);
    
    // 3. Restore selection in next frame
    requestAnimationFrame(() => {
      this.restoreSelection(selection);
    });
  }
  
  syncDOMToModel() {
    // 1. Parse DOM to model
    const newModel = this.parseDOM();
    
    // 2. Validate against constraints
    this.validateModel(newModel);
    
    // 3. Update model
    this.setModel(newModel);
    
    // 4. Preserve selection
    const selection = this.saveSelection();
    requestAnimationFrame(() => {
      this.restoreSelection(selection);
    });
  }
  
  // Handle contenteditable=false nodes
  handleReadonlyNode(node, element) {
    // Set attribute
    element.setAttribute('contenteditable', 'false');
    
    // Add programmatic protection
    element.addEventListener('beforeinput', (e) => {
      e.preventDefault();
      e.stopPropagation();
    }, { capture: true });
    
    // Mark in model
    node.marks = [{ type: 'readonly' }];
  }
}