/** * Base Plugin class for class-based plugin architecture * @import { ApplicationState } from '../state.js' * @import { PluginContext } from './plugin-context.js' */ import { createNavigableElement } from './navigable-element.js' /** * Base class for plugins that provides state management and lifecycle methods */ export class Plugin { /** * @param {PluginContext} context - Plugin context providing controlled access to application services * @param {object} [config] - Plugin configuration * @param {string} [config.name] - Plugin name * @param {string[]} [config.deps] - Plugin dependencies */ constructor(context, config = {}) { if (!context) { throw new Error('PluginContext is required for Plugin constructor'); } this.context = context; this.name = config.name || this.constructor.name.toLowerCase(); this.deps = config.deps || []; this.#state = null; } /** @type {Map} */ static instances = new Map(); /** * Create singleton instance of this plugin class * @template T * @this {new (context: PluginContext) => T} * @param {PluginContext} context - Plugin context * @returns {T} The singleton instance */ static createInstance(context) { if (!Plugin.instances.has(this)) { const PluginClass = /** @type {new (context: PluginContext) => T} */ (this); Plugin.instances.set(this, new PluginClass(context)); } return /** @type {T} */ (Plugin.instances.get(this)); } /** * Get singleton instance of this plugin class * @template T * @this {new (context: PluginContext) => T} * @returns {T} The singleton instance */ static getInstance() { const instance = Plugin.instances.get(this); if (!instance) { throw new Error(`Plugin ${this.name} not instantiated. Call createInstance() first.`); } return /** @type {T} */ (instance); } /** @type {ApplicationState|null} */ #state = null; // // Plugin lifecycle methods (override in subclasses) // /** * Plugin installation - override in subclasses * @param {ApplicationState} initialState */ async install(initialState) { this.#state = initialState; // Override for initialization logic } /** * Plugin initialization - override in subclasses */ async initialize() { // Override for initialization logic } /** * Plugin startup - override in subclasses */ async start() { // Override for startup logic } /** * Plugin (temporary) stop - override in subclasses */ async stop() { // Override for startup logic } /** * Plugin shutdown - override in subclasses * Called on window beforeunload */ async shutdown() { // Override for cleanup logic } // // State management // /** * React to state changes - override in subclasses * @param {string[]} changedKeys - Keys that changed in the state */ async onStateUpdate(changedKeys) { // Override for reactive behavior // Base implementation is empty - no need to call super() // eslint-disable-next-line no-unused-vars void changedKeys; } // // Internal state management // /** * Update internal state reference * @param {ApplicationState} newState */ updateInternalState(newState) { this.#state = newState; } // // Convenience methods for plugin implementations // /** * Returns the public API object for this plugin instance. * By default returns `this` (the instance itself is the API). * Override when the plugin wraps a separate API object and * `getDependency(name)` should return that object instead of the instance. * @returns {object} */ getApi() { return this; } /** * Create a scoped navigable UI tree from a root element. * Wraps createNavigableElement() so plugins can access their own DOM elements * without using the global `ui` object: * * @example * const span = createSingleFromTemplate('my-widget') * this.#ui = this.createUi(span) * // this.#ui.myButton, this.#ui.mySelect, … * * @template {Element} T * @param {T} element - Root element created from a plugin template * @returns {T & Record} Element with named descendants added as properties */ createUi(element) { return createNavigableElement(element); } /** * Read-only access to current state * @returns {ApplicationState|null} */ get state() { return this.#state; } /** * Dispatch state changes through the plugin context. * Must NOT be called from within an onStateUpdate handler. Use scheduleStateChange() * when dispatching is needed after async work triggered by onStateUpdate. * @param {Partial} changes * @returns {Promise} New state after changes applied */ async dispatchStateChange(changes) { if (!this.#state) { throw new Error("State hasn't been initialized") } const newState = await this.context.updateState(changes); this.#state = newState; return newState; } /** * Schedule a state change to run after the current propagation cycle completes. * Use this when async work triggered by onStateUpdate produces a result that must * be reflected in state (e.g. an API call that determines editorReadOnly). * @param {Partial} changes * @returns {Promise} */ async scheduleStateChange(changes) { if (!this.#state) { throw new Error("State hasn't been initialized") } const newState = await this.context.scheduleStateChange(changes); this.#state = newState; return newState; } /** * Check if specific state keys have changed * @param {...keyof ApplicationState} keys * @returns {boolean} */ hasStateChanged(...keys) { if (!this.#state) { return false; } return this.context.hasStateChanged(this.#state, ...keys); } /** * Get all changed state keys * @returns {Array} */ getChangedStateKeys() { if (!this.#state) { return /** @type {Array} */ ([]); } return /** @type {Array} */ (this.context.getChangedStateKeys(this.#state)); } /** * Get the public API of another registered plugin by name. * Mirrors the backend `context.get_dependency(id)` pattern. * Use this instead of static imports of other plugins' APIs. * @template {keyof import('../plugin-registry.js').PluginRegistryTypes} N * @param {N} name - Plugin name * @returns {import('../plugin-registry.js').PluginRegistryTypes[N]} The plugin's public API */ getDependency(name) { return this.context.getDependency(name); } /** * Get extension point mappings for this plugin. * * The base implementation auto-discovers: * - Standard lifecycle methods: `install`, `start`, `shutdown`, `updateInternalState`, `onStateUpdate` * - Computed methods declared in `static extensionPoints`: each path must have a corresponding * computed method `[ep.ns.name](...args) { return this.method(...args) }` * - Per-key state handlers: methods matching `onChange` are registered as `onStateUpdate.` * * @example * static extensionPoints = [ep.toolbar.contentItems]; * * [ep.toolbar.contentItems](...args) { return this.getToolbarContentItems(...args) } * * @example * async onXmlChange(newValue, prevValue) { ... } // called when state.xml changes * * @returns {Record} Mapping of extension point paths to bound methods */ getExtensionPoints() { /** @type {Record} */ const pts = {}; // Standard lifecycle extension points for (const name of ['install', 'ready', 'start', 'shutdown', 'updateInternalState', 'onStateUpdate']) { if (typeof this[name] === 'function') { pts[name] = this[name].bind(this); } } // Auto-discover static extensionPoints declarations. // Each path must have a corresponding computed method whose key is the full path string: // [ep.toolbar.contentItems]() { return [...] } // Methods with dots in their name cannot exist in standard JS, so this[path] // resolves only to computed methods — no naming conflicts between EP namespaces. const staticPoints = /** @type {typeof Plugin} */ (this.constructor).extensionPoints; if (Array.isArray(staticPoints)) { for (const path of staticPoints) { const value = this[path]; if (typeof value === 'function') { pts[path] = value.bind(this); } } } // Auto-discover onChange methods → register as 'onStateUpdate.' const onChangeRe = /^on([A-Z][a-zA-Z]*)Change$/; for (const proto of this.#prototypeChain()) { for (const key of Object.getOwnPropertyNames(proto)) { const match = onChangeRe.exec(key); if (match && typeof this[key] === 'function') { const stateKey = match[1].charAt(0).toLowerCase() + match[1].slice(1); const epKey = `onStateUpdate.${stateKey}`; if (!pts[epKey]) { pts[epKey] = this[key].bind(this); } } } } return pts; } /** * Iterate the prototype chain from this class up to (but not including) Plugin itself. * @returns {Iterable} */ * #prototypeChain() { let proto = Object.getPrototypeOf(this); while (proto && proto !== Plugin.prototype) { yield proto; proto = Object.getPrototypeOf(proto); } } } export default Plugin;