Event System Architecture

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

Overview

The event system is the communication layer between the editor core and plugins. It allows plugins to listen to and react to editor state changes, user interactions, and lifecycle events.

A well-designed event system provides type safety, efficient event propagation, and flexible event handling patterns.

Event Bubbling and Capturing

Bubbling Implementation

Implement event bubbling similar to DOM events, allowing parent handlers to process events before children:

class EventEmitter {
  #listeners = new Map();
  
  on(event, handler, options) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(handler);
  }
  
  emit(event, data, bubbles = true) {
    const handlers = this.#listeners.get(event);
    if (handlers) {
      handlers.forEach(handler => {
        handler(data);
      });
    }
    
    // Bubble up to parent
    if (bubbles && this.#parent) {
      this.#parent.emit(event, data, true);
    }
  }
}

// Usage: Node-level events bubble to document
class DocumentNode {
  constructor(parent) {
    this.#parent = parent;
    this.#emitter = new EventEmitter();
  }
  
  emit(event, data) {
    // Emit on this node
    this.#emitter.emit(event, data, false);
    
    // Bubble to parent
    if (this.#parent) {
      this.#parent.emit(event, data);
    }
  }
}

// Handler on document receives all node events
document.on('textInsert', (data) => {
  console.log('Text inserted anywhere in document:', data);
});

Capture Phase

Support capture phase for events that need to be handled before bubbling:

class EventEmitter {
  #captureListeners = new Map();
  #bubbleListeners = new Map();
  
  on(event, handler, options = {}) {
    const listeners = options.capture 
      ? this.#captureListeners 
      : this.#bubbleListeners;
    
    if (!listeners.has(event)) {
      listeners.set(event, new Set());
    }
    listeners.get(event).add(handler);
  }
  
  emit(event, data) {
    // Capture phase: parent to child
    this.#emitCapture(event, data);
    
    // Target phase: current node
    this.#emitTarget(event, data);
    
    // Bubble phase: child to parent
    this.#emitBubble(event, data);
  }
  
  #emitCapture(event, data) {
    // Emit to capture listeners
    const handlers = this.#captureListeners.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
    
    // Continue to children
    this.#children.forEach(child => {
      child.#emitCapture(event, data);
    });
  }
  
  #emitBubble(event, data) {
    // Emit to bubble listeners
    const handlers = this.#bubbleListeners.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
    
    // Continue to parent
    if (this.#parent) {
      this.#parent.#emitBubble(event, data);
    }
  }
}

Event Delegation Pattern

Delegation Benefits

Event delegation reduces memory usage and improves performance:

  • Single listener on container instead of many on individual nodes
  • Works with dynamically added nodes
  • Reduces event listener overhead
  • Simplifies event management

Delegation Implementation

Use event delegation to handle events efficiently:

class Editor {
  constructor(container) {
    this.#container = container;
    
    // Single listener on container
    this.#container.addEventListener('click', (e) => {
      this.#handleClick(e);
    });
    
    this.#container.addEventListener('input', (e) => {
      this.#handleInput(e);
    });
  }
  
  #handleClick(e) {
    const target = e.target;
    
    // Find the model node for this DOM element
    const node = this.#findNodeForElement(target);
    if (!node) return;
    
    // Dispatch to appropriate handler based on node type
    switch (node.type) {
      case 'link':
        this.#handleLinkClick(node, e);
        break;
      case 'image':
        this.#handleImageClick(node, e);
        break;
    }
  }
  
  #handleInput(e) {
    // Convert DOM input to model operation
    const operation = this.#domToOperation(e);
    this.applyOperation(operation);
  }
  
  #findNodeForElement(element) {
    // Walk up DOM tree to find node marker
    let current = element;
    while (current && current !== this.#container) {
      const nodeId = current.getAttribute('data-node-id');
      if (nodeId) {
        return this.#getNodeById(nodeId);
      }
      current = current.parentElement;
    }
    return null;
  }
}

Custom Event System

Typed Events

Implement a custom event system with typed events:

// Define event types
const EditorEvents = {
  OPERATION: 'operation',
  SELECTION_CHANGE: 'selectionChange',
  DOCUMENT_CHANGE: 'documentChange',
  ERROR: 'error'
};

// Typed event emitter
class TypedEventEmitter {
  #listeners = new Map();
  
  on(event, handler) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, new Set());
    }
    this.#listeners.get(event).add(handler);
  }
  
  off(event, handler) {
    const handlers = this.#listeners.get(event);
    if (handlers) {
      handlers.delete(handler);
    }
  }
  
  emit(event, data) {
    const handlers = this.#listeners.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
  }
}

// Usage
const editor = new TypedEventEmitter();

editor.on(EditorEvents.OPERATION, (data) => {
  console.log('Operation:', data.operation);
});

editor.on(EditorEvents.ERROR, (data) => {
  console.error('Error:', data.error);
});

editor.emit(EditorEvents.OPERATION, { 
  operation: { type: 'insertText', text: 'Hello' } 
});

Event Priority

Support event priority for ordered event handling:

class PriorityEventEmitter {
  #listeners = new Map();
  
  on(event, handler, priority = 0) {
    if (!this.#listeners.has(event)) {
      this.#listeners.set(event, []);
    }
    
    const listeners = this.#listeners.get(event);
    listeners.push({ handler, priority });
    
    // Sort by priority (higher priority first)
    listeners.sort((a, b) => b.priority - a.priority);
  }
  
  emit(event, data) {
    const listeners = this.#listeners.get(event);
    if (listeners) {
      listeners.forEach(({ handler }) => {
        handler(data);
      });
    }
  }
}

// Usage
const emitter = new PriorityEventEmitter();

// High priority handler (runs first)
emitter.on('operation', (data) => {
  console.log('High priority:', data);
}, 100);

// Normal priority handler
emitter.on('operation', (data) => {
  console.log('Normal priority:', data);
}, 0);

// Low priority handler (runs last)
emitter.on('operation', (data) => {
  console.log('Low priority:', data);
}, -100);

Event Lifecycle

Complete event lifecycle management:

class EventLifecycle {
  #preHandlers = new Map();
  #handlers = new Map();
  #postHandlers = new Map();
  
  on(event, handler, phase = 'main') {
    const map = phase === 'pre' 
      ? this.#preHandlers 
      : phase === 'post' 
        ? this.#postHandlers 
        : this.#handlers;
    
    if (!map.has(event)) {
      map.set(event, new Set());
    }
    map.get(event).add(handler);
  }
  
  emit(event, data) {
    // Pre-handlers
    this.#callHandlers(this.#preHandlers, event, data);
    
    // Main handlers
    const result = this.#callHandlers(this.#handlers, event, data);
    
    // Post-handlers
    this.#callHandlers(this.#postHandlers, event, data);
    
    return result;
  }
  
  #callHandlers(map, event, data) {
    const handlers = map.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
  }
  
  // Prevent default behavior
  preventDefault(event) {
    event.defaultPrevented = true;
  }
  
  // Stop propagation
  stopPropagation(event) {
    event.propagationStopped = true;
  }
}