pdf-tei-editor / docs /development /plugin-system-frontend.md
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
|
Raw
History Blame Contribute Delete
20.5 kB

Frontend Plugin System Architecture

This document provides a comprehensive technical overview of the frontend plugin system architecture in the PDF-TEI Editor. For practical plugin development guidance, see the Plugin Development Guide in the code-assistant documentation.

Overview

The PDF-TEI Editor uses a plugin-based architecture that supports both class-based plugins and object-based plugins. The system provides:

  • Dependency resolution - Automatic topological sorting ensures plugins load in correct order
  • Dual architecture - Supports both Plugin classes and object-based plugin descriptors
  • Endpoint system - Flexible extension points for plugin communication
  • Centralized management - PluginManager handles registration and invocation
  • State orchestration - Application class coordinates plugins with immutable state management

Core Components

PluginManager

The PluginManager (app/src/modules/plugin-manager.js) is responsible for:

  • Plugin registration - Maintains registry of all plugins and their dependencies
  • Dependency resolution - Uses topological sorting to determine load order
  • Endpoint invocation - Orchestrates calls to plugin extension points
  • Cache management - Optimizes endpoint lookups via caching

Key Features

Dependency Resolution:

const manager = new PluginManager();

manager.register({
  name: 'logger',
  install() { /* ... */ }
});

manager.register({
  name: 'database',
  deps: ['logger'],  // Will load after logger
  install() { /* ... */ }
});

// Plugins invoked in dependency order: logger β†’ database
await manager.invoke('install');

Endpoint Invocation Modes:

The manager supports flexible invocation patterns through flags and options:

  • No-Call Flag (! prefix): Retrieve values without calling functions

    const configs = await manager.invoke('!config.timeout');  // [undefined, 5000]
    
  • Throw Flag (! suffix): Fail fast on errors

    await manager.invoke('install!');  // Throws on first error
    
  • Execution Modes:

    • parallel (default): All plugins execute concurrently
    • sequential: Plugins execute in dependency order, one at a time
  • Result Formats:

    • first: Return first fulfilled value
    • values: Array of all fulfilled values
    • full: Complete {status, value/reason} objects

Plugin Class Conversion:

The manager automatically converts Plugin class instances into plugin configuration objects using the getEndpoints() method, allowing both patterns to coexist seamlessly.

Plugin Base Class

The Plugin base class (app/src/modules/plugin-base.js) is implemented on top of the object-based plugin pattern. It converts a class instance into a plugin descriptor object by auto-mounting class methods as extension points via getExtensionPoints().

Auto-mounted extension points β€” methods recognized by name convention, no declaration needed:

  • Lifecycle methods: install, ready, start, shutdown, updateInternalState, onStateUpdate
  • Per-key state handlers: any method matching on<Key>Change (e.g. onXmlChange β†’ onStateUpdate.xml)

Manually mounted extension points β€” require explicit registration:

  • static extensionPoints = [ep.path] with [ep.path](...args) { return this.method(...args) } β€” computed method delegating to a named method; key is the full EP path string, conflict-free

See plugin-communication.md for when and how to use each mechanism.

Additional features:

  • Singleton pattern β€” createInstance() and getInstance() ensure one instance per class
  • State management β€” this.state (read-only), this.dispatchStateChange(), this.hasStateChanged()
  • Context access β€” PluginContext provides controlled access to application services
  • Dependency injection β€” getDependency(name) returns another plugin's public API

Use private getter properties to access dependencies lazily rather than assigning them in the constructor. This avoids circular dependency issues and keeps deps declarations minimal:

class MyPlugin extends Plugin {
  // Resolved at call time β€” no constructor assignment, no deps entry needed
  // unless the dependency must be installed before this plugin's install() runs
  get #logger()    { return this.getDependency('logger') }
  get #xmlEditor() { return this.getDependency('xmleditor') }

  async someAction() {
    this.#logger.debug('action triggered')
    const tree = this.#xmlEditor.getXmlTree()
  }
}

Only add a plugin to deps when it must be fully installed before this plugin's own install() runs.

Lifecycle Methods

class MyPlugin extends Plugin {
  async install(initialState) {
    // Called during plugin registration
    // Setup UI, register templates
  }

  async initialize() {
    // Called after registration
    // Optional initialization logic
  }

  async start() {
    // Called when app starts
    // Begin plugin operations
  }

  async shutdown() {
    // Called on window.beforeunload
    // Cleanup resources
  }
}

State Management in Plugin Classes

Plugin classes get automatic state management through the base class:

class MyPlugin extends Plugin {
  async onStateUpdate(changedKeys, state) {
    // Catch-all: called on every state change
    if (changedKeys.includes('user')) {
      this.updateUI();
    }
  }

  async handleAction() {
    await this.dispatchStateChange({ customProperty: 'value' });
  }

  get currentUser() {
    return this.state.user;  // read-only
  }
}

Per-Key State Handlers (on<Key>Change)

Instead of a catch-all onStateUpdate, declare methods named on<Key>Change where Key is the state property name with the first letter capitalized. The base class auto-discovers these and registers them as onStateUpdate.<key> extension points, so they are called only when that specific key changes.

class MyPlugin extends Plugin {
  // Called only when state.xml changes
  async onXmlChange(newXml, prevXml) { ... }

  // Called only when state.user changes
  async onUserChange(newUser, prevUser) { ... }

  // Called only when state.sessionId changes
  async onSessionIdChange(newId, prevId) { ... }
}

Naming: on + state key with first letter uppercased + Change (e.g. state.editorReadOnly β†’ onEditorReadOnlyChange).

Per-key handlers receive (newValue, prevValue). Use this.state to access other state properties. Both on<Key>Change methods and onStateUpdate can coexist in the same class.

Custom Extension Points

Declare static extensionPoints and implement a computed method that delegates to a named method:

import ep from '../extension-points.js'

class MyPlugin extends Plugin {
  static extensionPoints = [ep.toolbar.contentItems];

  /**
   * Extension point handler for `ep.toolbar.contentItems`.
   * Called by ToolbarPlugin during start() to collect this plugin's toolbar contributions.
   * Delegates to {@link MyPlugin#getToolbarContentItems}.
   * @returns {Array<{element: HTMLElement, priority: number, position: string}>}
   */
  [ep.toolbar.contentItems](...args) { return this.getToolbarContentItems(...args) }

  getToolbarContentItems() {
    return [{ element: this.#ui, priority: 5, position: 'center' }]
  }
}

See plugin-communication.md for the full pattern, including how the host plugin invokes contributions.

PluginContext

The PluginContext (app/src/modules/plugin-context.js) provides Plugin classes with controlled access to application services:

  • updateState(changes) - Dispatch state changes
  • hasStateChanged(state, ...keys) - Check if keys changed
  • getChangedStateKeys(state) - Get all changed keys

This abstraction prevents direct access to the Application instance and enforces proper encapsulation.

Application Orchestrator

The Application class (app/src/app.js) coordinates between plugins and state management:

  • Plugin registration - Registers plugins with the PluginManager
  • State updates - Orchestrates state changes through StateManager
  • Plugin notifications - Notifies plugins of state changes via endpoints
  • Singleton API - Exports singleton instance and plugin APIs
// app.js simplified structure
export class Application {
  constructor() {
    this.pluginManager = new PluginManager();
    this.stateManager = new StateManager();
  }

  async updateState(changes) {
    // Update state immutably
    const newState = await this.stateManager.updateState(changes);

    // Notify plugins
    await this.pluginManager.invoke('updateInternalState', newState);
    const changedKeys = this.stateManager.getChangedKeys();
    await this.pluginManager.invoke('onStateUpdate', [changedKeys, newState]);

    return newState;
  }
}

// Export singleton API
export const app = Application.getInstance();

Plugin Types

Plugin Classes

Plugin classes extend the Plugin base class and receive automatic state management:

import Plugin from '../modules/plugin-base.js';

class MyPlugin extends Plugin {
  constructor(context) {
    super(context, {
      name: 'my-plugin',
      deps: ['dependency1']
    });
  }

  /**
   * @param {ApplicationState} state
   */
  async install(state) {
    await super.install(state);
    // Setup UI
  }

  /**
   * @param {(keyof ApplicationState)[]} changedKeys
   * @param {ApplicationState} state
   */
  async onStateUpdate(changedKeys, state) {
    if (changedKeys.includes('user')) {
      this.updateUI();
    }
  }

  async handleClick() {
    await this.dispatchStateChange({
      customProperty: 'new value'
    });
  }

  getEndpoints() {
    return {
      ...super.getEndpoints(),
      'custom.action': this.handleCustomAction.bind(this)
    };
  }
}

export default MyPlugin;

Features:

  • Automatic state management via this.state
  • Built-in lifecycle methods
  • Singleton pattern: MyPlugin.getInstance()
  • Auto-discovered change handlers: onXmlChange(newVal, prevVal) for any state key
  • getDependency(name) for runtime access to other plugins' APIs

Plugin Objects

Plugin objects are plain JavaScript descriptors that the system uses directly. The class-based Plugin class is implemented on top of this primitive pattern. If you need to understand the lower-level mechanics or work with object-based plugins directly, see Object-Based Plugin Pattern.

import { updateState } from '../app.js';

let currentState;

/**
 * @param {String[]} changedKeys
 * @param {ApplicationState} state
 */
async function onStateUpdate(changedKeys, state) {
  currentState = state;
  if (changedKeys.includes('user')) {
    // React to changes
  }
}

async function handleAction() {
  await updateState({ customProperty: 'new value' });
}

export const api = { handleAction };

export const plugin = {
  name: 'my-plugin',
  deps: ['dependency1'],
  api,
  install: async (state) => { /* setup */ },
  onStateUpdate
};

export default plugin;

Characteristics:

  • Manual state management β€” track state in a closure variable
  • The api field is what getDependency('my-plugin') returns in other plugins
  • Extension point paths map to nested object properties (state.update β†’ plugin.state.update)

Endpoint System

Endpoints are extension points where plugins can provide functionality. Defined in app/src/endpoints.js.

Standard Lifecycle Endpoints

  • install - Plugin initialization, receives initial state
  • start - Application startup after all plugins installed
  • ready - Deferred initialization after page load
  • shutdown - Cleanup on application exit

State Management Endpoints

  • updateInternalState - Silent state sync for Plugin classes
  • onStateUpdate - Reactive notifications with changed keys

Custom Endpoints

Plugins can define custom endpoints for specialized functionality:

// endpoints.js
const endpoints = {
  validation: {
    validate: "validation.validate",
    configure: "validation.configure",
    result: "validation.result"
  },
  log: {
    debug: "log.debug",
    info: "log.info",
    warn: "log.warn"
  }
}

Plugins expose custom endpoints via getEndpoints():

class ValidationPlugin extends Plugin {
  getEndpoints() {
    return {
      ...super.getEndpoints(),
      'validation.validate': this.validate.bind(this),
      'validation.configure': this.configure.bind(this)
    };
  }
}

Other plugins can invoke these endpoints:

// Invoke validation from another plugin
await app.pluginManager.invoke('validation.validate', {
  type: 'xml',
  text: xmlContent
});

Plugin Registration and Loading

Registration Flow

Plugins are collected in app/src/plugins.js, which is the central registry:

// app/src/plugins.js

// Class-based plugins β€” imported from plugin-registry.js (auto-generated)
import { MyPlugin } from './plugin-registry.js';

// Object-based plugins β€” imported directly
import myObjectPlugin from './plugins/my-object-plugin.js';

const plugins = [
  MyPlugin,          // Plugin class β€” instantiated automatically
  myObjectPlugin,    // Plugin object β€” used as-is
  // ...
];

export default plugins;

// Export singleton APIs for cross-plugin access
export const myPlugin = MyPlugin.getInstance();

To add a new class-based plugin:

  1. Create app/src/plugins/my-plugin.js with the class
  2. Run node bin/build.js --steps=plugins to add it to plugin-registry.js
  3. Import from ./plugin-registry.js and add to the plugins array in plugins.js

Loading Process

  1. Registration - Plugins registered with PluginManager
  2. Dependency resolution - Topological sort determines load order
  3. Instantiation - Plugin classes instantiated via createInstance()
  4. Installation - install endpoint invoked sequentially in dependency order
  5. Startup - start endpoint invoked after all installations complete
  6. Ready - ready endpoint invoked after initial page load

Dependency Order Example

const plugins = [
  configPlugin,        // No dependencies - loads first
  urlHashStatePlugin,  // deps: ['config']
  clientPlugin,        // deps: ['config']
  dialogPlugin,        // deps: ['config']
  validationPlugin,    // deps: ['dialog']
  // ...
];

// Resolved order:
// config β†’ urlHashState, client, dialog β†’ validation β†’ ...

State Management Integration

The plugin system is tightly integrated with immutable state management. See state-management.md for comprehensive state details, and plugin-communication.md for when to use state propagation vs. other inter-plugin mechanisms.

Key Principles

  • onStateUpdate and on<Key>Change handlers are observers β€” they react to state but never call dispatchStateChange themselves (creates infinite loops)
  • State changes only from event handlers or async operations (API responses, timers)
  • Use dispatchStateChange() in Plugin classes, updateState() in object-based plugins
  • Store plugin-specific data in state.ext[this.name] to avoid key collisions

State Update Flow

Event Handler β†’ dispatchStateChange()
                      ↓
              Application.updateState()
                      ↓
              New immutable state created
                      ↓
              Plugins notified via onStateUpdate / on<Key>Change
                      ↓
              Plugins update UI

Template Registration System

Plugins use a template registration system supporting both development and production modes. See architecture.md for details.

Usage in Plugins

import { registerTemplate, createSingleFromTemplate } from '../ui.js';

class MyPlugin extends Plugin {
  async install(state) {
    await super.install(state);

    // Register template (async, done once)
    await registerTemplate('my-template', 'my-template.html');

    // Create elements (synchronous)
    const element = createSingleFromTemplate('my-template');
    document.body.appendChild(element);
  }
}

Key Points:

  • registerTemplate() is async, called during install
  • createSingleFromTemplate() is synchronous, fast
  • Templates support parameter substitution via ${param} syntax
  • Development mode loads from files, production from bundled JSON

Memory Management

The plugin system implements several memory management strategies:

  • State history limit - StateManager keeps only last 10 states
  • Endpoint cache - PluginManager caches endpoint lookups, cleared on registration changes
  • Singleton instances - Plugin class instances stored in WeakMap-style registry
  • Proper cleanup - shutdown endpoint allows plugins to clean up resources

Best Practices

Plugin Design

  • Single responsibility - Each plugin handles one feature or concern
  • Minimal dependencies - Only depend on truly required plugins
  • Explicit endpoints - Use getEndpoints() to document plugin capabilities
  • State extensions - Use state.ext[pluginName] for plugin-specific state

State Management

See plugin-communication.md for the full state propagation pattern and decision guide.

  • Never mutate β€” use dispatchStateChange() or updateState()
  • Never call state updates inside onStateUpdate β€” use on<Key>Change handlers for reactive UI updates
  • Use changedKeys.includes() in catch-all onStateUpdate to avoid unnecessary work

Performance

  • Template registration - Register templates during install, create during runtime
  • Conditional updates - Only update UI for relevant state changes
  • Endpoint caching - Trust the PluginManager's cache
  • Parallel invocation - Default parallel mode is fastest for independent operations

Migrating Object-Based Plugins to Class-Based

To convert an object-based plugin to a Plugin class:

  1. Create class extending Plugin

    class MyPlugin extends Plugin {
      constructor(context) {
        super(context, { name: 'my-plugin', deps: [] });
      }
    }
    
  2. Move endpoint functions to methods

    async install(state) {
      await super.install(state);
      // Original install code
    }
    
  3. Replace manual state tracking

    // Before: let currentState;
    // After: this.state (automatic)
    
  4. Update state changes

    // Before: await updateState({ ... });
    // After: await this.dispatchStateChange({ ... });
    
  5. Implement getEndpoints() for custom endpoints

    getEndpoints() {
      return {
        ...super.getEndpoints(),
        'custom.action': this.handleAction.bind(this)
      };
    }
    
  6. Export class and update registration

    // plugins/my-plugin.js
    export default MyPlugin;
    
    // Run build step, then update plugins.js:
    import { MyPlugin } from './plugin-registry.js';
    const plugins = [MyPlugin, ...];
    export const myPlugin = MyPlugin.getInstance();
    

Related Documentation