Plugins in data/plugins are loaded at startup and can add a tab with a read-only node board, and the demo plugin shows every asset as a node (#113)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 19:00:03 +02:00
1 parent 2a390e52ff
commit 57a97ba94f
22 files changed
+1153 -57

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
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;
}
/** 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 }),
});
}
export type PluginApi = ReturnType<typeof createPluginApi>;