import { Widget } from '../../ui'; import { NodeBoard } from './NodeBoard'; /** The API version this editor offers. A plugin names the one it was written for in `meta.api`. */ export const PLUGIN_API_VERSION = 1; export interface PluginAsset { id: string; name: string; type: string; } /** What the editor lends the API – narrow, so the API can be exercised without an editor. */ export interface PluginHost { assets(): PluginAsset[]; /** Called after every change to the model; returns the unsubscribe. */ onProjectChange(listener: () => void): () => void; /** Whether a tab of this id exists already, built in or added by a plugin. */ hasTab(id: string): boolean; addTab(id: string, label: string, content: Widget): void; /** Takes a plugin's tab away again, its content with it. */ removeTab(id: string): void; } /** * The host as one plugin sees it: every listener it subscribes and every tab it adds is written * into `scope`, so switching the plugin off can take them away. An unsubscribe the plugin calls * itself stays harmless when the scope calls it again. */ export function scopedHost(host: PluginHost, scope: { add(cleanup: () => void): void }): PluginHost { return { ...host, onProjectChange(listener) { let off: (() => void) | null = host.onProjectChange(listener); const unsubscribe = () => { off?.(); off = null; }; scope.add(unsubscribe); return unsubscribe; }, addTab(id, label, content) { host.addTab(id, label, content); scope.add(() => host.removeTab(id)); }, }; } /** A change of the asset list, as `project.on('change')` sees it: ids, names and types in order. */ function assetSignature(assets: readonly PluginAsset[]): string { return JSON.stringify(assets.map((a) => [a.id, a.name, a.type])); } function requireString(value: unknown, what: string): string { if (typeof value !== 'string' || value === '') throw new TypeError(`${what} must be a non-empty string`); return value; } /** * The `api` a plugin's `activate` receives, version 1: read the assets and hear when they change, * add a tab, and build its content from `api.ui`. Every plugin gets its own, so what it does can * be told apart in the console. */ export function createPluginApi(pluginId: string, host: PluginHost) { const project = Object.freeze({ assets: (): PluginAsset[] => host.assets().map(({ id, name, type }) => ({ id, name, type })), /** * Only a change a plugin can see calls back: the model changes on every step of a drag, the * asset list almost never. A listener that throws is reported and stays subscribed. */ on(event: string, listener: () => void): () => void { if (event !== 'change') throw new Error(`Unknown project event "${event}" – API v1 knows "change"`); if (typeof listener !== 'function') throw new TypeError('project.on needs a listener function'); let seen = assetSignature(host.assets()); return host.onProjectChange(() => { const next = assetSignature(host.assets()); if (next === seen) return; seen = next; try { listener(); } catch (error) { console.error(`[plugin ${pluginId}] change listener failed:`, error); } }); }, }); const tabs = Object.freeze({ /** False, with a warning, when the id is taken – by a built-in tab or an earlier plugin. */ add(tab: { id: unknown; label: unknown; create: unknown }): boolean { const id = requireString(tab.id, 'tabs.add: id'); const label = requireString(tab.label, 'tabs.add: label'); if (typeof tab.create !== 'function') throw new TypeError('tabs.add: create must be a function'); if (host.hasTab(id)) { console.warn(`[plugin ${pluginId}] a tab "${id}" exists already – not added`); return false; } const content: unknown = (tab.create as () => unknown)(); if (!(content instanceof Widget)) throw new TypeError(`tabs.add: create must return a widget from api.ui (tab "${id}")`); host.addTab(id, label, content); return true; }, }); return Object.freeze({ version: PLUGIN_API_VERSION, project, tabs, ui: Object.freeze({ NodeBoard }), }); }