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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a390e52ff
commit
57a97ba94f
22 files changed
+1153
-57
No files matched your search
@@ -0,0 +1,180 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
|
||||
import type { NodeDef } from '../../core/graph';
|
||||
import { Button, HStack, Label, type Size, Spacer, Theme, VStack, Widget, zoomFactor } from '../../ui';
|
||||
import { type Box, frameBoxes } from '../framing';
|
||||
import { drawGraphGrid, FIT_MARGIN, type GraphView, ZOOM_LIMITS, zoomedAbout } from '../graph/graphView';
|
||||
import { NodeView } from '../graph/NodeView';
|
||||
|
||||
/** One node as a plugin hands it over: a title at a position in graph units. */
|
||||
export interface BoardNode {
|
||||
id: string;
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what a plugin passed to `setNodes`. A plugin is plain JavaScript, so nothing about the
|
||||
* shape is guaranteed: a title that is not text is empty and a position that is not a number is 0, but
|
||||
* something that is not a list at all is a mistake worth an exception.
|
||||
*/
|
||||
export function boardNodes(input: unknown): BoardNode[] {
|
||||
if (!Array.isArray(input)) throw new TypeError('NodeBoard.setNodes expects an array of nodes');
|
||||
return input.map((item: unknown, index) => {
|
||||
const raw = (typeof item === 'object' && item !== null ? item : {}) as Record<string, unknown>;
|
||||
const number = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
||||
const text = (value: unknown, otherwise: string) =>
|
||||
typeof value === 'string' ? value : typeof value === 'number' ? String(value) : otherwise;
|
||||
return {
|
||||
id: text(raw.id, String(index)),
|
||||
title: text(raw.title, ''),
|
||||
x: number(raw.x),
|
||||
y: number(raw.y),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A node with a title and nothing else, drawn by the graph editor's own node view. */
|
||||
function titleOnly(title: string): NodeDef {
|
||||
return { type: '', label: title, category: 'data', description: '', inputs: [], outputs: [], params: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph a node board shows: pan with any button, zoom with the wheel, nothing to edit. The
|
||||
* nodes are pictures here – they take no pointer events, so a press on one pans like anywhere else.
|
||||
*/
|
||||
class BoardCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly layer = new Container();
|
||||
private readonly views: NodeView[] = [];
|
||||
private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 };
|
||||
private panning: { start: { x: number; y: number }; pan: { x: number; y: number } } | null = null;
|
||||
/** Framed once there is a size and something to frame; after that the view is the user's. */
|
||||
private needsFit = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.layer.eventMode = 'none';
|
||||
this.addChild(this.bg, this.grid, this.layer, this.clip);
|
||||
this.layer.mask = this.clip;
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
this.panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...this.view.pan } };
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (!this.panning) return;
|
||||
const { start, pan } = this.panning;
|
||||
this.view.pan = { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y };
|
||||
this.applyView();
|
||||
});
|
||||
this.on('pointerup', () => (this.panning = null));
|
||||
this.on('pointerupoutside', () => (this.panning = null));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
this.view = zoomedAbout(this.view, this.toLocal(e.global), zoomFactor(e.deltaY));
|
||||
this.applyView();
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(nodes: readonly BoardNode[]): void {
|
||||
for (const view of this.views.splice(0)) view.destroy({ children: true });
|
||||
for (const node of nodes) {
|
||||
const view = new NodeView(
|
||||
{ id: node.id, type: '', x: node.x, y: node.y, params: {} },
|
||||
titleOnly(node.title),
|
||||
);
|
||||
view.cursor = 'default';
|
||||
this.layer.addChild(view);
|
||||
this.views.push(view);
|
||||
}
|
||||
if (this.needsFit) this.fit();
|
||||
else this.applyView();
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
if (this.w === 0 || this.h === 0) return;
|
||||
const boxes: Box[] = this.views.map((v) => ({ id: v.node.id, x: v.x, y: v.y, w: v.w, h: v.h }));
|
||||
const framing = frameBoxes(boxes, { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS);
|
||||
this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom };
|
||||
this.needsFit = boxes.length === 0;
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
debugInfo(): { nodes: { id: string; title: string }[]; zoom: number; pan: { x: number; y: number } } {
|
||||
return {
|
||||
nodes: this.views.map((v) => ({ id: v.node.id, title: v.def.label })),
|
||||
zoom: this.view.zoom,
|
||||
pan: { ...this.view.pan },
|
||||
};
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
if (this.needsFit) this.fit();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.graphBackground);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
drawGraphGrid(this.grid, this.view, this.w, this.h);
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.layer.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y));
|
||||
this.layer.scale.set(this.view.zoom);
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only graph for plugins (`api.ui.NodeBoard`): nodes with a title, a Fit button above them.
|
||||
* It looks like the graph editor because it draws with the same node view and grid.
|
||||
*/
|
||||
export class NodeBoard extends VStack {
|
||||
readonly fitButton: Button;
|
||||
private readonly canvas = new BoardCanvas();
|
||||
private readonly count = new Label('', { style: 'small', align: 'right' });
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.fitButton = new Button('Fit', { onClick: () => this.fit() });
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
header.add(this.fitButton);
|
||||
header.add(new Spacer(), 1);
|
||||
header.add(this.count);
|
||||
this.count.minWidth = 80;
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
this.setNodes([]);
|
||||
}
|
||||
|
||||
setNodes(nodes: unknown): void {
|
||||
const read = boardNodes(nodes);
|
||||
this.canvas.setNodes(read);
|
||||
this.count.setText(read.length === 1 ? '1 node' : `${read.length} nodes`);
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.canvas.fit();
|
||||
}
|
||||
|
||||
/** The nodes as drawn and the view – what the check scripts read. */
|
||||
debugInfo(): ReturnType<BoardCanvas['debugInfo']> {
|
||||
return this.canvas.debugInfo();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.clear();
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { PLUGIN_API_VERSION } from './pluginApi';
|
||||
|
||||
export interface PluginMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
api: number;
|
||||
}
|
||||
|
||||
/** What became of one file in `data/plugins/`; the check scripts and a later settings list read it. */
|
||||
export interface PluginRecord {
|
||||
file: string;
|
||||
status: 'active' | 'failed';
|
||||
meta: PluginMeta | null;
|
||||
/** Why it is not active. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PluginLoaderDeps {
|
||||
/** The module a file holds. */
|
||||
load(file: string): Promise<unknown>;
|
||||
/** A fresh API for a plugin that passed the checks. */
|
||||
api(meta: PluginMeta): unknown;
|
||||
/** One line for the user about a plugin that could not be activated. */
|
||||
report(message: string): void;
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** The module's `meta` as the loader needs it, or why it cannot be used. */
|
||||
export function readMeta(module: unknown): PluginMeta | string {
|
||||
const exports = (typeof module === 'object' && module !== null ? module : {}) as Record<string, unknown>;
|
||||
const meta = exports.meta;
|
||||
if (typeof meta !== 'object' || meta === null) return 'exports no meta';
|
||||
const { id, name, version, api } = meta as Record<string, unknown>;
|
||||
if (typeof id !== 'string' || id === '') return 'meta.id must be a non-empty string';
|
||||
if (typeof exports.activate !== 'function') return 'exports no activate function';
|
||||
if (api !== PLUGIN_API_VERSION)
|
||||
return `needs plugin API ${String(api)}, this editor offers ${PLUGIN_API_VERSION}`;
|
||||
return {
|
||||
id,
|
||||
name: typeof name === 'string' && name !== '' ? name : id,
|
||||
version: typeof version === 'string' ? version : '',
|
||||
api,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and activates every plugin, one after the other in file-name order, so that what an
|
||||
* earlier one registers (a tab id, say) is settled before the next runs. Nothing a plugin does
|
||||
* wrong stops the others or the editor: a file that does not load, a module that is not a plugin,
|
||||
* an API version this editor does not offer, a second plugin of the same id and an `activate`
|
||||
* that throws each leave a failed record and a report.
|
||||
*/
|
||||
export async function activatePlugins(
|
||||
files: readonly string[],
|
||||
deps: PluginLoaderDeps,
|
||||
): Promise<PluginRecord[]> {
|
||||
const records: PluginRecord[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const file of [...files].sort()) {
|
||||
const fail = (meta: PluginMeta | null, error: string) => {
|
||||
records.push({ file, status: 'failed', meta, error });
|
||||
deps.report(`Plugin ${file} not activated: ${error}`);
|
||||
};
|
||||
let module: unknown;
|
||||
try {
|
||||
module = await deps.load(file);
|
||||
} catch (error) {
|
||||
fail(null, `does not load (${messageOf(error)})`);
|
||||
continue;
|
||||
}
|
||||
const meta = readMeta(module);
|
||||
if (typeof meta === 'string') {
|
||||
fail(null, meta);
|
||||
continue;
|
||||
}
|
||||
if (ids.has(meta.id)) {
|
||||
fail(meta, `a plugin with the id "${meta.id}" is active already`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const activate = (module as { activate: (api: unknown) => unknown }).activate;
|
||||
await activate(deps.api(meta));
|
||||
} catch (error) {
|
||||
fail(meta, `activate failed (${messageOf(error)})`);
|
||||
continue;
|
||||
}
|
||||
ids.add(meta.id);
|
||||
records.push({ file, status: 'active', meta });
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiRequest } from '../../core/store/http';
|
||||
import type { Editor } from '../Editor';
|
||||
import type { EditorState } from '../state';
|
||||
import { activatePlugins } from './loader';
|
||||
import { createPluginApi, type PluginHost } from './pluginApi';
|
||||
|
||||
/**
|
||||
* Loads what is in `data/plugins/` into the running editor (#113). A plugin is an ES module with
|
||||
* no imports of its own, fetched through the API route rather than as a static file – so the
|
||||
* folder cannot be used to reach anything but a plugin. A failure is a console line and a notice;
|
||||
* the editor goes on without that plugin.
|
||||
*/
|
||||
export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise<void> {
|
||||
const report = (message: string) => {
|
||||
console.error(message);
|
||||
state.setNotice(message, 'quiet');
|
||||
};
|
||||
let files: string[];
|
||||
try {
|
||||
const list = await apiRequest<{ file: string }[]>(endpoint, 'GET', 'plugins');
|
||||
files = list.map((entry) => entry.file);
|
||||
} catch (error) {
|
||||
report(`Plugins not loaded: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return;
|
||||
}
|
||||
const host: PluginHost = {
|
||||
assets: () => state.project.assets,
|
||||
onProjectChange: (listener) => state.on('project', listener),
|
||||
hasTab: (id) => editor.hasTab(id),
|
||||
addTab: (id, label, content) => editor.addPluginTab(id, label, content),
|
||||
};
|
||||
editor.plugins = await activatePlugins(files, {
|
||||
load: (file) => import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}`),
|
||||
api: (meta) => createPluginApi(meta.id, host),
|
||||
report,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user