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.

Overview

Hooks provide a way for plugins to tap into the editor's lifecycle and modify behavior at specific points. They enable a plugin architecture where functionality can be extended without modifying core code.

The hook system is inspired by webpack's tapable system and Vue's plugin system. It provides both synchronous and asynchronous hooks, allowing for flexible plugin integration.

Synchronous Hooks

Basic SyncHook

Synchronous hooks execute callbacks in sequence. Each callback receives the same arguments:

export class SyncHook {
  #callbacks = [];
  
  tap(fn) {
    this.#callbacks.push(fn);
  }
  
  call(...args) {
    this.#callbacks.forEach(callback => {
      callback(...args);
    });
  }
  
  untap(fn) {
    const index = this.#callbacks.indexOf(fn);
    if (index > -1) {
      this.#callbacks.splice(index, 1);
    }
  }
}

// Usage
const hooks = {
  beforeOperation: new SyncHook(),
  afterOperation: new SyncHook(),
  render: new SyncHook()
};

// Register callbacks
hooks.beforeOperation.tap((operation) => {
  console.log('Before operation:', operation);
});

hooks.beforeOperation.tap((operation) => {
  // Can modify operation
  if (operation.type === 'insertText' && operation.text.length > 100) {
    operation.text = operation.text.substring(0, 100);
  }
});

// Call hook
hooks.beforeOperation.call(operation);

Waterfall Hook

Waterfall hooks pass return values to the next callback:

export class SyncWaterfallHook {
  #callbacks = [];
  
  tap(fn) {
    this.#callbacks.push(fn);
  }
  
  call(initialValue) {
    return this.#callbacks.reduce((value, callback) => {
      return callback(value);
    }, initialValue);
  }
}

// Usage: Transform document through multiple plugins
const transformHook = new SyncWaterfallHook();

transformHook.tap((doc) => {
  // Plugin 1: Normalize whitespace
  return normalizeWhitespace(doc);
});

transformHook.tap((doc) => {
  // Plugin 2: Validate structure
  return validateStructure(doc);
});

const transformed = transformHook.call(originalDocument);

Asynchronous Hooks

Parallel Hook

Asynchronous hooks allow parallel execution:

export class AsyncParallelHook {
  #callbacks = [];
  
  tapPromise(fn) {
    this.#callbacks.push(fn);
  }
  
  async promise() {
    // Execute all callbacks in parallel
    await Promise.all(this.#callbacks.map(callback => callback()));
  }
}

// Usage
const hooks = {
  initAsync: new AsyncParallelHook()
};

// Multiple plugins can initialize in parallel
hooks.initAsync.tapPromise(async () => {
  await loadUserPreferences();
});

hooks.initAsync.tapPromise(async () => {
  await loadDocumentHistory();
});

// Both run in parallel
await hooks.initAsync.promise();

Series Hook

Series hooks execute sequentially:

export class AsyncSeriesHook {
  #callbacks = [];
  
  tapPromise(fn) {
    this.#callbacks.push(fn);
  }
  
  async promise() {
    // Execute callbacks sequentially
    for (const callback of this.#callbacks) {
      await callback();
    }
  }
}

// Usage
const hooks = {
  saveAsync: new AsyncSeriesHook()
};

// Save operations must be sequential
hooks.saveAsync.tapPromise(async () => {
  await saveToLocalStorage();
});

hooks.saveAsync.tapPromise(async () => {
  await syncToServer();
});

// Run sequentially
await hooks.saveAsync.promise();

Hook Lifecycle Management

Hook Definition

Define a complete hook lifecycle for editor operations:

// Editor hooks definition
const hooks = {
  // Initialization
  init: new SyncHook(),
  initAsync: new AsyncParallelHook(),
  ready: new SyncHook(),
  
  // Operations
  beforeOperation: new SyncHook(),
  operation: new SyncHook(),
  afterOperation: new SyncHook(),
  
  // Rendering
  beforeRender: new SyncHook(),
  render: new SyncHook(),
  afterRender: new SyncHook(),
  
  // Selection
  selectionChange: new SyncHook(),
  
  // Cleanup
  destroy: new SyncHook()
};

class Editor {
  constructor() {
    this.#hooks = hooks;
  }
  
  applyOperation(operation) {
    // Before operation hooks
    this.#hooks.beforeOperation.call(operation);
    
    // Apply operation
    this.#doApplyOperation(operation);
    
    // Operation hooks
    this.#hooks.operation.call(operation);
    
    // After operation hooks
    this.#hooks.afterOperation.call(operation);
    
    // Trigger render
    this.render();
  }
  
  render() {
    const doc = this.getDocument();
    
    this.#hooks.beforeRender.call(doc);
    this.#doRender(doc);
    this.#hooks.render.call(doc);
    this.#hooks.afterRender.call(doc);
  }
}

Hook Integration

Plugins can integrate with hooks:

class HistoryPlugin {
  apply(editor) {
    // Hook into operations
    editor.hooks.beforeOperation.tap((operation) => {
      // Save state before operation
      this.#saveState(editor.getDocument());
    });
    
    editor.hooks.afterOperation.tap((operation) => {
      // Update history after operation
      this.#updateHistory(operation);
    });
  }
}

class ValidationPlugin {
  apply(editor) {
    // Hook into rendering
    editor.hooks.beforeRender.tap((doc) => {
      // Validate document before rendering
      const errors = this.#validate(doc);
      if (errors.length > 0) {
        console.warn('Validation errors:', errors);
      }
    });
  }
}

Advanced Patterns

Advanced hook patterns for complex scenarios:

// Conditional hook execution
class ConditionalHook extends SyncHook {
  call(...args) {
    this.#callbacks.forEach(callback => {
      if (callback.condition(...args)) {
        callback.fn(...args);
      }
    });
  }
}

// Hook with priority
class PriorityHook extends SyncHook {
  tap(fn, priority = 0) {
    this.#callbacks.push({ fn, priority });
    this.#callbacks.sort((a, b) => b.priority - a.priority);
  }
  
  call(...args) {
    this.#callbacks.forEach(({ fn }) => {
      fn(...args);
    });
  }
}

// Hook with context
class ContextHook extends SyncHook {
  call(context, ...args) {
    this.#callbacks.forEach(callback => {
      callback.call(context, ...args);
    });
  }
}