History Management

Managing undo/redo history in model-based editors requires careful coordination between your model state and browser's DOM history. This guide covers the challenges and solutions for professional history management.

Overview

History management in model-based editors is fundamentally different from browser's native undo/redo. Your model tracks operations, while the browser tracks DOM changes. These two systems can conflict, especially when using preventDefault() or during IME composition.

⚠️ Core Challenge

The fundamental problem:

  • Browser history tracks DOM changes, not model operations
  • Using preventDefault() prevents DOM changes but browser may still update internal state
  • IME composition state can become corrupted when events are prevented
  • Programmatic model changes don't appear in browser history
  • Selection must be preserved across history operations

Model-Based History Management

Model-based history tracks operations on your abstract document model, not DOM changes. This provides more control but requires careful synchronization.

History Architecture

Design your history system around model operations:

interface HistoryEntry {
  id: string;
  timestamp: number;
  operations: Operation[];
  inverseOperations?: Operation[]; // Pre-computed inverses for undo
  // Note: beforeModel/afterModel are optional - avoid storing full snapshots
  // Use operation-based undo instead (see history-management-optimization)
  beforeModel?: DocumentModel; // Only if needed for specific use cases
  afterModel?: DocumentModel;  // Only if needed for specific use cases
  beforeSelection: Selection | null;
  afterSelection: Selection | null;
  metadata?: {
    source: 'user' | 'programmatic' | 'undo' | 'redo';
    compositionState?: CompositionState;
  };
}

interface Operation {
  type: 'insert' | 'delete' | 'format' | 'replace';
  path: Path;
  data?: any;
  inverse?: Operation; // For efficient undo
}

class HistoryManager {
  private undoStack: HistoryEntry[] = [];
  private redoStack: HistoryEntry[] = [];
  private maxSize: number = 50;
  private currentEntry: HistoryEntry | null = null;
  
  constructor(private model: DocumentModel) {}
  
  // Record a new history entry
  // Note: Avoid cloning full models - use operation-based approach instead
  record(operations: Operation[], selection: Selection | null) {
    const beforeSelection = selection;
    
    // Apply operations
    operations.forEach(op => this.model.apply(op));
    
    const afterSelection = this.calculateSelectionAfterOperations(
      beforeSelection,
      operations
    );
    
    // Compute inverse operations for efficient undo
    const inverseOperations = this.computeInverseOperations(operations);
    
    const entry: HistoryEntry = {
      id: this.generateId(),
      timestamp: Date.now(),
      operations,
      inverseOperations, // Use inverses instead of model snapshots
      beforeSelection,
      afterSelection,
      metadata: {
        source: 'user'
      }
      // NO beforeModel/afterModel - too memory-intensive!
    };
    
    this.undoStack.push(entry);
    if (this.undoStack.length > this.maxSize) {
      this.undoStack.shift();
    }
    this.redoStack = []; // Clear redo on new action
  }
  
  // Compute inverse operations for undo
  computeInverseOperations(operations: Operation[]): Operation[] {
    // Reverse order and compute inverse for each
    return operations
      .slice()
      .reverse()
      .map(op => this.getInverseOperation(op));
  }
}

Operation Tracking

Track all operations that modify the model:

class OperationTracker {
  private pendingOperations: Operation[] = [];
  private isRecording = false;
  
  startRecording() {
    this.isRecording = true;
    this.pendingOperations = [];
  }
  
  recordOperation(operation: Operation) {
    if (this.isRecording) {
      this.pendingOperations.push(operation);
    }
  }
  
  stopRecording(): Operation[] {
    this.isRecording = false;
    const operations = [...this.pendingOperations];
    this.pendingOperations = [];
    return operations;
  }
  
  // Integrate with event handlers
  handleBeforeInput(e: InputEvent) {
    this.startRecording();
    
    // Convert DOM event to model operation
    const operation = this.domEventToOperation(e);
    this.recordOperation(operation);
    
    // Prevent default to handle in model
    e.preventDefault();
    
    // Apply to model
    this.model.apply(operation);
    
    // Stop recording and save to history
    const operations = this.stopRecording();
    this.historyManager.record(operations, this.saveSelection());
  }
}

State Snapshots

⚠️ Important: Storing full model snapshots is memory-intensive. Use operation-based history instead.

Avoid full model snapshots: Storing complete model clones for each history entry consumes excessive memory and is slow for large documents.

See History Management Optimization for the recommended operation-based approach.

// ❌ AVOID: Full model snapshots (memory-intensive)
class HistoryManager {
  // Option 1: Full model snapshots (simple but memory-intensive)
  createSnapshot(model: DocumentModel): DocumentModel {
    return model.clone(); // Deep clone - expensive!
  }
}

// ✅ PREFERRED: Operation-based (memory-efficient)
class HistoryManager {
  // Store only operations, use inverse operations for undo
  createSnapshot(operations: Operation[]): HistoryEntry {
    return {
      operations,
      inverseOperations: this.computeInverses(operations),
      // NO full model snapshots!
    };
  }
  
  // Apply inverse operations for undo
  undo() {
    const entry = this.#undoStack.pop()!;
    entry.inverseOperations.forEach(op => this.model.apply(op));
  }
}

// See history-management-optimization for complete implementation

DOM History Conflict

Browser's native history and your model history can conflict. Understanding these conflicts is crucial for reliable history management.

Browser History Limitations

Browser history has several limitations:

  • Only tracks DOM changes: Programmatic changes may not be included
  • Granularity varies: Some browsers undo per keystroke, others per operation
  • Can be cleared unexpectedly: Focus changes, programmatic DOM updates
  • No operation metadata: Can't distinguish user actions from programmatic changes
  • Selection not preserved: Undo may lose selection position
// Browser history behavior
// ❌ Problem: Programmatic changes not in history
element.innerHTML = newContent; // Not in browser undo stack

// ❌ Problem: preventDefault() operations not in history
element.addEventListener('beforeinput', (e) => {
  e.preventDefault();
  // Custom operation - not in browser history
  this.applyCustomOperation();
});

// ❌ Problem: History cleared on focus change
element.addEventListener('blur', () => {
  // Browser may clear undo stack
});

// ✅ Solution: Use custom history
class HistoryManager {
  recordOperation(operation) {
    // Always track in custom history
    this.undoStack.push({
      operation,
      beforeModel: this.model.clone(),
      afterModel: this.applyOperation(operation)
    });
  }
}

preventDefault() Impact

When you call preventDefault(), you prevent DOM changes but browser's internal state may still update:

// The problem
element.addEventListener('beforeinput', (e) => {
  e.preventDefault(); // Prevent DOM change
  
  // Apply to model instead
  const operation = this.domEventToOperation(e);
  this.model.apply(operation);
  
  // ❌ Problem: Browser's internal state (IME, undo stack) may be updated
  // even though DOM didn't change
});

// Browser's perspective:
// 1. beforeinput fires → you preventDefault()
// 2. Browser doesn't update DOM (because prevented)
// 3. BUT browser may have already:
//    - Updated internal IME state
//    - Added entry to undo stack (in some browsers)
//    - Updated selection tracking
// 4. This creates mismatch between browser state and actual DOM

⚠️ State Mismatch

Critical issue: Browser's internal state (IME composition, undo stack, selection tracking) may be updated even when preventDefault() is called. This creates a mismatch between what the browser thinks happened and what actually happened in the DOM.

Programmatic Changes

Programmatic model changes don't appear in browser history:

// User types "Hello" → Browser adds to undo stack
// You programmatically insert "World" → Browser does NOT add to undo stack

class Editor {
  insertText(text: string) {
    const operation = {
      type: 'insert',
      path: this.getSelection().anchor,
      text: text
    };
    
    // Apply to model
    this.model.apply(operation);
    
    // Update DOM
    this.renderModelToDOM();
    
    // ❌ Problem: This change is NOT in browser's undo stack
    // User presses Ctrl+Z → Only "Hello" is undone, "World" remains
    
    // ✅ Solution: Always record in custom history
    this.historyManager.record([operation], this.saveSelection());
  }
}

IME Composition and History

IME composition creates unique challenges for history management. Preventing events during composition can corrupt browser's IME state.

Composition State Mismatch

When you prevent events during composition, browser's IME state can become corrupted:

// The critical problem
element.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertParagraph') {
    e.preventDefault(); // Prevent paragraph insertion
    
    // ❌ Problem: If composition is active, browser's IME state becomes corrupted
    // Browser thinks composition ended, but it's still active internally
    // Next IME input fails because browser state is inconsistent
  }
});

// What happens:
// 1. User is composing Korean text (한글)
// 2. User presses Enter → insertParagraph fires
// 3. You call preventDefault()
// 4. Browser's internal IME state thinks composition ended
// 5. BUT composition is still active from IME's perspective
// 6. Next character input fails because state mismatch

⚠️ Safari Composition Corruption

Safari-specific issue: In Safari, preventing insertParagraph during or after IME composition corrupts the browser's IME state. Subsequent IME input fails completely. This affects Korean, Japanese, and Chinese IME.

preventDefault() During Composition

Always check composition state before preventing events:

class CompositionAwareHistory {
  private isComposing = false;
  private compositionState: CompositionState | null = null;
  
  init(editor: HTMLElement) {
    // Track composition state
    editor.addEventListener('compositionstart', () => {
      this.isComposing = true;
      this.compositionState = {
        startTime: Date.now(),
        text: ''
      };
    });
    
    editor.addEventListener('compositionend', (e) => {
      this.isComposing = false;
      // Record composition as single operation
      this.recordCompositionOperation(e.data);
      this.compositionState = null;
    });
    
    // NEVER prevent events during composition
    editor.addEventListener('beforeinput', (e) => {
      if (this.isComposing) {
        // Allow browser to handle composition
        // Don't prevent default
        return;
      }
      
      // Only prevent when NOT composing
      if (e.inputType === 'insertParagraph') {
        e.preventDefault();
        this.handleCustomParagraphInsertion();
      }
    });
  }
  
  // Alternative: Check isComposing flag
  handleBeforeInput(e: InputEvent) {
    // Check both our state and browser's flag
    if (this.isComposing || e.isComposing) {
      // Don't prevent during composition
      return;
    }
    
    // Safe to prevent
    if (e.inputType === 'insertParagraph') {
      e.preventDefault();
      this.handleCustomOperation();
    }
  }
}
// ✅ Safe pattern: Always check composition
editor.addEventListener('beforeinput', (e) => {
  // NEVER prevent during composition
  if (e.isComposing || this.compositionHandler.isComposing) {
    return; // Let browser handle
  }
  
  // Safe to prevent
  if (e.inputType === 'insertParagraph') {
    e.preventDefault();
    this.handleCustomParagraph();
  }
});

// ✅ Alternative: Check in keydown
editor.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    // Check composition state
    if (e.isComposing || this.compositionHandler.isComposing) {
      return; // Let browser handle Enter during composition
    }
    
    e.preventDefault();
    this.handleCustomEnter();
  }
});

Composition History Tracking

Track composition as a single history entry:

class CompositionHistory {
  private compositionStartModel: DocumentModel | null = null;
  private compositionStartSelection: Selection | null = null;
  
  handleCompositionStart() {
    // Save state at composition start
    this.compositionStartModel = this.model.clone();
    this.compositionStartSelection = this.saveSelection();
    
    // Don't record intermediate updates
    this.isRecordingComposition = true;
  }
  
  handleCompositionUpdate(text: string) {
    // Update model for visual feedback
    // But don't record in history yet
    this.updateCompositionDisplay(text);
  }
  
  handleCompositionEnd(finalText: string) {
    // Now record as single operation
    const operation = {
      type: 'insertText',
      path: this.compositionStartSelection.anchor,
      text: finalText,
      metadata: {
        source: 'ime',
        composition: true
      }
    };
    
    // Record in history
    this.historyManager.record(
      [operation],
      this.compositionStartSelection,
      {
        compositionState: {
          startText: '',
          endText: finalText
        }
      }
    );
    
    this.isRecordingComposition = false;
  }
  
  // Prevent recording intermediate composition updates
  shouldRecordOperation(operation: Operation): boolean {
    if (this.isRecordingComposition) {
      // Don't record composition updates, only final result
      return false;
    }
    return true;
  }
}

History Synchronization Strategies

You need to synchronize your model history with browser's DOM history, or disable browser history entirely.

Disable Browser History

Completely disable browser history and use only custom history:

class HistoryManager {
  disableBrowserHistory() {
    // Prevent browser's undo/redo
    this.editor.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
        e.preventDefault();
        
        if (e.inputType === 'historyUndo') {
          this.undo();
        } else {
          this.redo();
        }
      }
    });
    
    // Also handle keyboard shortcuts
    this.editor.addEventListener('keydown', (e) => {
      const isUndo = (e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey;
      const isRedo = (e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey));
      
      if (isUndo || isRedo) {
        e.preventDefault();
        e.stopPropagation();
        
        if (isUndo) {
          this.undo();
        } else {
          this.redo();
        }
      }
    });
    
    // Clear browser's undo stack by manipulating DOM
    // (Browser clears stack when DOM is replaced)
    this.clearBrowserStack();
  }
  
  clearBrowserStack() {
    // Force clear by replacing content
    const content = this.editor.innerHTML;
    this.editor.innerHTML = content; // Browser clears stack
  }
}

Hybrid Approach

Use browser history for simple operations, custom history for complex ones:

class HybridHistoryManager {
  shouldUseBrowserHistory(operation: Operation): boolean {
    // Use browser history for simple text insertions
    if (operation.type === 'insertText' && operation.text.length === 1) {
      return true; // Let browser handle
    }
    
    // Use custom history for complex operations
    if (operation.type === 'format' || 
        operation.type === 'insertNode' ||
        operation.complex) {
      return false; // Use custom history
    }
    
    return false; // Default to custom
  }
  
  handleOperation(operation: Operation) {
    if (this.shouldUseBrowserHistory(operation)) {
      // Don't prevent default, let browser handle
      // But still track in our history for consistency
      this.recordInCustomHistory(operation);
    } else {
      // Prevent default, use custom history
      this.preventDefaultAndRecord(operation);
    }
  }
}

History Reconciliation

Reconcile browser history with model history:

class HistoryReconciler {
  reconcile() {
    // Detect if browser history was used
    const browserHistoryUsed = this.detectBrowserHistoryUse();
    
    if (browserHistoryUsed) {
      // Browser undid something, sync model
      const currentDOM = this.parseDOMToModel(this.editor);
      
      // Find matching history entry
      const matchingEntry = this.findMatchingHistoryEntry(currentDOM);
      
      if (matchingEntry) {
        // Restore model to this state
        this.model = matchingEntry.beforeModel;
      } else {
        // No match, sync from DOM
        this.model = currentDOM;
      }
    }
  }
  
  detectBrowserHistoryUse(): boolean {
    // Monitor for browser undo/redo
    let browserHistoryUsed = false;
    
    this.editor.addEventListener('beforeinput', (e) => {
      if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
        browserHistoryUsed = true;
        // Don't prevent, let browser handle
        // But sync model after
        setTimeout(() => {
          this.reconcile();
        }, 0);
      }
    });
    
    return browserHistoryUsed;
  }
}

Selection in History

Selection must be preserved and transformed correctly across history operations.

Selection Preservation

Always save and restore selection with history entries:

class HistoryManager {
  undo() {
    if (this.undoStack.length === 0) return false;
    
    const entry = this.undoStack.pop();
    
    // Save current state for redo
    const currentState = {
      model: this.model.clone(),
      selection: this.saveSelection()
    };
    this.redoStack.push(currentState);
    
    // Restore model
    this.model = entry.beforeModel;
    
    // Update DOM from model
    this.renderModelToDOM(this.model);
    
    // Restore selection in next frame (after DOM update)
    requestAnimationFrame(() => {
      this.restoreSelection(entry.beforeSelection);
    });
    
    return true;
  }
  
  saveSelection(): Selection {
    const selection = window.getSelection();
    if (selection.rangeCount === 0) return null;
    
    const range = selection.getRangeAt(0);
    
    // Convert to model selection
    return {
      anchor: this.domPositionToModelPath(range.startContainer, range.startOffset),
      focus: this.domPositionToModelPath(range.endContainer, range.endOffset),
      collapsed: range.collapsed
    };
  }
  
  restoreSelection(modelSelection: Selection) {
    if (!modelSelection) return;
    
    // Convert model selection to DOM
    const anchorPos = this.modelPathToDOMPosition(modelSelection.anchor);
    const focusPos = this.modelPathToDOMPosition(modelSelection.focus);
    
    if (!anchorPos || !focusPos) {
      // Selection invalid, find nearest valid position
      const nearest = this.findNearestValidPosition(modelSelection.anchor);
      if (nearest) {
        this.restoreSelection({ anchor: nearest, focus: nearest, collapsed: true });
      }
      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);
  }
}

Selection Transformation

Transform selection when operations affect it:

class SelectionTransformer {
  transformSelection(
    selection: Selection,
    operation: Operation
  ): Selection {
    // Transform selection based on operation
    switch (operation.type) {
      case 'insert':
        return this.transformForInsert(selection, operation);
      case 'delete':
        return this.transformForDelete(selection, operation);
      case 'replace':
        return this.transformForReplace(selection, operation);
      default:
        return selection;
    }
  }
  
  transformForInsert(selection: Selection, operation: Operation): Selection {
    const { path, data } = operation;
    
    // If insertion is before selection, shift selection
    if (this.isBefore(path, selection.anchor.path)) {
      return {
        anchor: {
          ...selection.anchor,
          offset: selection.anchor.offset + data.length
        },
        focus: {
          ...selection.focus,
          offset: selection.focus.offset + data.length
        }
      };
    }
    
    // If insertion is within selection, expand selection
    if (this.isWithin(path, selection)) {
      return {
        ...selection,
        // Selection expands to include inserted content
      };
    }
    
    return selection; // No change
  }
  
  transformForDelete(selection: Selection, operation: Operation): Selection {
    const { path, length } = operation;
    
    // If deletion is before selection, shift selection
    if (this.isBefore(path, selection.anchor.path)) {
      const shift = Math.min(length, selection.anchor.offset);
      return {
        anchor: {
          ...selection.anchor,
          offset: Math.max(0, selection.anchor.offset - shift)
        },
        focus: {
          ...selection.focus,
          offset: Math.max(0, selection.focus.offset - shift)
        }
      };
    }
    
    // If deletion overlaps selection, adjust selection
    if (this.overlaps(path, length, selection)) {
      // Collapse to deletion start
      return {
        anchor: path,
        focus: path,
        collapsed: true
      };
    }
    
    return selection;
  }
}

Transaction and History

Group related operations into transactions for atomic history entries.

Transaction Grouping

Group operations into transactions:

class TransactionHistory {
  private currentTransaction: Operation[] = [];
  private isInTransaction = false;
  
  startTransaction() {
    this.isInTransaction = true;
    this.currentTransaction = [];
  }
  
  addToTransaction(operation: Operation) {
    if (this.isInTransaction) {
      this.currentTransaction.push(operation);
    } else {
      // Not in transaction, record immediately
      this.record([operation]);
    }
  }
  
  commitTransaction() {
    if (this.currentTransaction.length > 0) {
      // Record all operations as single history entry
      this.record(this.currentTransaction);
      this.currentTransaction = [];
    }
    this.isInTransaction = false;
  }
  
  rollbackTransaction() {
    // Undo all operations in transaction
    this.currentTransaction.forEach(op => {
      this.model.apply(op.inverse);
    });
    this.currentTransaction = [];
    this.isInTransaction = false;
  }
  
  // Example: Formatting operation
  applyFormatting(format: Format) {
    this.startTransaction();
    
    // Multiple operations for formatting
    this.addToTransaction({ type: 'format', format, start: selection.start });
    this.addToTransaction({ type: 'format', format, end: selection.end });
    
    this.commitTransaction(); // Single undo entry
  }
}

Undo Transaction Boundaries

Define what constitutes a single undo operation:

class HistoryManager {
  // Group operations within time window
  private lastOperationTime = 0;
  private operationWindow = 300; // 300ms
  
  shouldGroupWithPrevious(operation: Operation): boolean {
    const now = Date.now();
    const timeSinceLastOp = now - this.lastOperationTime;
    
    // Group if within time window
    if (timeSinceLastOp < this.operationWindow) {
      return true;
    }
    
    this.lastOperationTime = now;
    return false;
  }
  
  recordOperation(operation: Operation) {
    if (this.shouldGroupWithPrevious(operation)) {
      // Add to previous entry
      const lastEntry = this.undoStack[this.undoStack.length - 1];
      lastEntry.operations.push(operation);
      lastEntry.afterModel = this.applyOperation(operation, lastEntry.afterModel);
    } else {
      // New entry
      this.record([operation]);
    }
  }
  
  // Group by operation type
  shouldGroupByType(op1: Operation, op2: Operation): boolean {
    // Group consecutive text insertions
    if (op1.type === 'insertText' && op2.type === 'insertText') {
      return this.areAdjacent(op1.path, op2.path);
    }
    
    return false;
  }
}

Edge Cases and Pitfalls

Common edge cases that break history management:

Focus Changes

Focus changes can clear browser history:

class HistoryManager {
  handleFocusChange() {
    this.editor.addEventListener('blur', () => {
      // Browser may clear undo stack on blur
      // Save current state before blur
      this.saveStateBeforeBlur();
    });
    
    this.editor.addEventListener('focus', () => {
      // Browser may have cleared stack
      // Restore if needed
      this.restoreStateAfterFocus();
    });
  }
  
  // Prevent history loss on focus change
  saveStateBeforeBlur() {
    // Save to persistent storage or keep in memory
    this.persistentState = {
      model: this.model.clone(),
      undoStack: this.undoStack,
      redoStack: this.redoStack
    };
  }
}

External DOM Changes

External DOM changes can corrupt history:

class HistoryManager {
  watchForExternalChanges() {
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (!this.isExpectedChange(mutation)) {
          // External change detected
          // Option 1: Revert to model
          this.revertToModel();
          
          // Option 2: Sync model from DOM
          // this.syncModelFromDOM();
          
          // Option 3: Clear history (safest)
          // this.clearHistory();
        }
      });
    });
    
    observer.observe(this.editor, {
      childList: true,
      subtree: true,
      attributes: true
    });
  }
  
  isExpectedChange(mutation: MutationRecord): boolean {
    // Check if this change was initiated by our code
    return mutation.target.hasAttribute('data-expected-change') ||
           this.isOurOperation(mutation);
  }
}

Browser Extensions

Browser extensions can corrupt history:

class HistoryManager {
  handleExtensionInterference() {
    // Extensions (Grammarly, spell checkers) modify DOM
    // This can corrupt browser history
    
    // Solution: Always use custom history
    this.disableBrowserHistory();
    
    // Monitor for extension changes
    this.watchForExternalChanges();
    
    // Revert extension changes
    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (this.isExtensionChange(mutation)) {
          // Revert to model
          this.revertToModel();
        }
      });
    });
    
    observer.observe(this.editor, {
      childList: true,
      subtree: true
    });
  }
  
  isExtensionChange(mutation: MutationRecord): boolean {
    // Detect extension-specific markers
    return mutation.target.classList?.contains('grammarly-') ||
           mutation.target.hasAttribute('data-grammarly') ||
           mutation.target.classList?.contains('spell-check-');
  }
}

Best Practices

Best practices for professional history management:

  • Always disable browser history: Use custom history for full control
  • Never prevent events during composition: Check isComposing before preventDefault()
  • Record composition as single operation: Don't record intermediate updates
  • Always preserve selection: Save and restore selection with each history entry
  • Use transactions for atomic operations: Group related operations
  • Transform selection correctly: Adjust selection when operations affect it
  • Handle external changes: Monitor and revert unexpected DOM modifications
  • Validate history entries: Check model consistency after undo/redo
  • Use efficient snapshots: Balance memory usage with reconstruction speed
  • Test across browsers: History behavior varies significantly
class ProfessionalHistoryManager {
  // Complete implementation
  constructor(model: DocumentModel, editor: HTMLElement) {
    this.model = model;
    this.editor = editor;
    this.undoStack = [];
    this.redoStack = [];
    this.compositionHandler = new CompositionHandler();
    
    this.init();
  }
  
  init() {
    // 1. Disable browser history
    this.disableBrowserHistory();
    
    // 2. Track composition state
    this.compositionHandler.init(this.editor);
    
    // 3. Handle events
    this.setupEventHandlers();
    
    // 4. Watch for external changes
    this.watchForExternalChanges();
  }
  
  handleBeforeInput(e: InputEvent) {
    // NEVER prevent during composition
    if (e.isComposing || this.compositionHandler.isComposing) {
      return; // Let browser handle
    }
    
    // Convert to operation
    const operation = this.domEventToOperation(e);
    
    // Prevent default
    e.preventDefault();
    
    // Apply to model
    this.model.apply(operation);
    
    // Record in history
    this.record([operation], this.saveSelection());
    
    // Update DOM
    this.renderModelToDOM();
    
    // Restore selection
    requestAnimationFrame(() => {
      this.restoreSelection(this.calculateSelectionAfterOperation(operation));
    });
  }
  
  undo() {
    if (this.undoStack.length === 0) return false;
    
    const entry = this.undoStack.pop();
    
    // Save for redo
    this.redoStack.push({
      model: this.model.clone(),
      selection: this.saveSelection()
    });
    
    // Restore model
    this.model = entry.beforeModel;
    
    // Update DOM
    this.renderModelToDOM();
    
    // Restore selection
    requestAnimationFrame(() => {
      this.restoreSelection(entry.beforeSelection);
    });
    
    return true;
  }
}