Plugin Development Guide

Guide to developing plugins that extend model-based contenteditable editors with custom functionality.

Overview

Plugins allow you to extend editor functionality without modifying core code. They can add formatting, handle special content types, integrate with external services, and customize editor behavior.

Plugin benefits:

  • Modular architecture - features can be added/removed independently
  • Reusable across projects
  • Easy to test in isolation
  • Community contributions

Plugin Architecture

Plugins integrate with the editor through hooks, commands, and the editor API.

Plugin Interface

All plugins implement a standard interface:

interface Plugin {
  name: string;
  version?: string;
  
  // Lifecycle methods
  install(editor: Editor): void | Promise<void>;
  uninstall?(editor: Editor): void | Promise<void>;
  
  // Optional: Plugin configuration
  configure?(config: PluginConfig): void;
}

class MyPlugin implements Plugin {
  name = 'my-plugin';
  version = '1.0.0';
  
  install(editor: Editor) {
    // Register hooks, commands, etc.
  }
  
  uninstall(editor: Editor) {
    // Cleanup
  }
}

Plugin Lifecycle

Plugins go through a lifecycle:

// 1. Plugin instantiation
const plugin = new MyPlugin();

// 2. Plugin installation (called by editor)
await editor.use(plugin);
// or
await plugin.install(editor);

// 3. Plugin active (handling events, commands, etc.)

// 4. Plugin uninstallation (optional)
await plugin.uninstall?.(editor);
// or
editor.removePlugin(plugin);

Creating Plugins

Create a plugin by implementing the Plugin interface and registering hooks or commands.

Basic Plugin Structure

A minimal plugin:

class BasicPlugin implements Plugin {
  name = 'basic-plugin';
  #editor: Editor | null = null;

  install(editor: Editor) {
    this.#editor = editor;
    
    // Register event listeners
    editor.on('operation', this.#handleOperation.bind(this));
    
    // Register commands
    editor.registerCommand('basic:action', this.#handleCommand.bind(this));
  }

  uninstall(editor: Editor) {
    // Remove event listeners
    editor.off('operation', this.#handleOperation);
    
    // Unregister commands
    editor.unregisterCommand('basic:action');
    
    this.#editor = null;
  }

  #handleOperation(operation: Operation) {
    // Handle operations
  }

  #handleCommand() {
    // Handle command
  }
}

Using Hooks

Plugins can hook into editor lifecycle and operations:

class HookPlugin implements Plugin {
  name = 'hook-plugin';

  install(editor: Editor) {
    // Hook into operation lifecycle
    editor.hooks.beforeOperation.tap((operation) => {
      // Modify operation before it's applied
      if (operation.type === 'insert') {
        // Transform operation
      }
    });

    editor.hooks.afterOperation.tap((operation) => {
      // React to operation after it's applied
    });

    // Hook into rendering
    editor.hooks.beforeRender.tap(() => {
      // Prepare for render
    });

    editor.hooks.afterRender.tap(() => {
      // Post-render cleanup
    });
  }
}

Adding Commands

Plugins can register commands that can be called programmatically or via keyboard shortcuts:

class CommandPlugin implements Plugin {
  name = 'command-plugin';

  install(editor: Editor) {
    // Register command
    editor.registerCommand('format:bold', () => {
      const selection = editor.getSelection();
      if (selection.isCollapsed) {
        // Apply format to current word or selection
        editor.toggleFormat('bold', selection);
      }
    });

    // Register keyboard shortcut
    editor.registerShortcut('Mod-b', 'format:bold');
  }
}

Plugin Examples

Real-world plugin examples:

Formatting Plugin

A plugin that adds text formatting:

class FormattingPlugin implements Plugin {
  name = 'formatting';
  #editor: Editor | null = null;

  install(editor: Editor) {
    this.#editor = editor;

    // Register formatting commands
    editor.registerCommand('format:bold', () => this.#toggleBold());
    editor.registerCommand('format:italic', () => this.#toggleItalic());
    editor.registerCommand('format:underline', () => this.#toggleUnderline());

    // Register shortcuts
    editor.registerShortcut('Mod-b', 'format:bold');
    editor.registerShortcut('Mod-i', 'format:italic');
    editor.registerShortcut('Mod-u', 'format:underline');
  }

  #toggleBold() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('bold', selection);
  }

  #toggleItalic() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('italic', selection);
  }

  #toggleUnderline() {
    if (!this.#editor) return;
    const selection = this.#editor.getSelection();
    this.#editor.toggleFormat('underline', selection);
  }

  uninstall(editor: Editor) {
    editor.unregisterCommand('format:bold');
    editor.unregisterCommand('format:italic');
    editor.unregisterCommand('format:underline');
    this.#editor = null;
  }
}

Image Plugin

A plugin that handles images:

class ImagePlugin implements Plugin {
  name = 'image';
  #editor: Editor | null = null;
  #uploadUrl: string;

  constructor(uploadUrl: string) {
    this.#uploadUrl = uploadUrl;
  }

  install(editor: Editor) {
    this.#editor = editor;

    // Handle paste
    editor.element.addEventListener('paste', async (e) => {
      const items = e.clipboardData?.items;
      if (!items) return;

      for (const item of items) {
        if (item.type.startsWith('image/')) {
          e.preventDefault();
          const file = item.getAsFile();
          if (file) {
            await this.#handleImagePaste(file);
          }
        }
      }
    });

    // Register command
    editor.registerCommand('image:insert', async (file: File) => {
      await this.#uploadAndInsert(file);
    });
  }

  async #handleImagePaste(file: File) {
    await this.#uploadAndInsert(file);
  }

  async #uploadAndInsert(file: File) {
    if (!this.#editor) return;

    // Upload image
    const formData = new FormData();
    formData.append('image', file);
    
    const response = await fetch(this.#uploadUrl, {
      method: 'POST',
      body: formData
    });
    
    const { url } = await response.json();

    // Insert into editor
    const selection = this.#editor.getSelection();
    this.#editor.insertImage(url, selection);
  }

  uninstall(editor: Editor) {
    editor.unregisterCommand('image:insert');
    this.#editor = null;
  }
}

Plugin API

Plugins have access to the editor API for manipulating content, selection, and state.

Editor API

Core editor methods:

interface Editor {
  // Selection
  getSelection(): Selection;
  setSelection(selection: Selection): void;

  // Content manipulation
  insertText(text: string, selection?: Selection): void;
  insertNode(node: Node, selection?: Selection): void;
  deleteContent(selection: Selection): void;
  
  // Formatting
  toggleFormat(format: string, selection: Selection): void;
  hasFormat(format: string, selection: Selection): boolean;
  
  // Commands
  registerCommand(name: string, handler: Function): void;
  unregisterCommand(name: string): void;
  executeCommand(name: string, ...args: any[]): void;
  
  // Events
  on(event: string, handler: Function): void;
  off(event: string, handler: Function): void;
  emit(event: string, data?: any): void;
  
  // Hooks
  hooks: {
    beforeOperation: Hook;
    afterOperation: Hook;
    beforeRender: Hook;
    afterRender: Hook;
  };
}

Model API

Access to document model:

interface Model {
  // Read operations
  getNode(path: Path): Node | null;
  getText(range?: Range): string;
  
  // Write operations
  applyOperation(operation: Operation): void;
  
  // Traversal
  walk(callback: (node: Node, path: Path) => void): void;
  
  // Query
  findNodes(predicate: (node: Node) => boolean): Node[];
}

View API

Access to DOM view:

interface View {
  // DOM access
  element: HTMLElement;
  getNodeElement(node: Node): HTMLElement | null;
  
  // Rendering
  render(): void;
  updateNode(node: Node): void;
  
  // Selection
  getDOMSelection(): Selection | null;
  setDOMSelection(selection: Selection): void;
}

Best Practices

Key principles for plugin development:

  • Keep plugins focused: Each plugin should have a single, well-defined purpose
  • Clean up on uninstall: Remove all event listeners, commands, and references
  • Use hooks when possible: Prefer hooks over direct API calls for extensibility
  • Handle errors gracefully: Don't let plugin errors break the editor
  • Document your plugin: Provide clear API documentation and examples
  • Test thoroughly: Test with different browsers, IMEs, and edge cases
  • Consider performance: Avoid expensive operations in hot paths