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

+2
View File
@@ -8,6 +8,7 @@ import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { startPlugins } from './plugins/startPlugins';
import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
@@ -41,4 +42,5 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
fontLibrary.onChange(() => requestRender());
installDebugHook(app, state, editor);
await editor.boot();
await startPlugins(API_ENDPOINT, state, editor);
}
+38 -5
View File
@@ -43,6 +43,7 @@ import type { PlayFrom } from './settings';
import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installFileImport } from './fileImport';
import type { PluginRecord } from './plugins/loader';
export type CentreTab =
| 'scene'
@@ -56,6 +57,10 @@ export type CentreTab =
| 'container'
| 'list';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
export type PluginTabId = `plugin:${string}`;
export type TabId = CentreTab | PluginTabId;
const TAB_ORDER = [
'scene',
'game',
@@ -124,7 +129,7 @@ export class Editor implements PlayHost {
readonly assets: AssetPanel;
readonly inspector: Inspector;
readonly console = new ConsolePanel();
tab: CentreTab = 'scene';
tab: TabId = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
private readonly play: PlaySession;
@@ -136,8 +141,11 @@ export class Editor implements PlayHost {
readonly consoleSplitter = new Splitter('y');
private readonly workspace: HStack;
private readonly centre: VStack;
private readonly tabs: TabStrip<CentreTab>;
private readonly tabs: TabStrip<TabId>;
private readonly panes: Record<CentreTab, Widget>;
private readonly pluginPanes = new Map<PluginTabId, Widget>();
/** Every file in `data/plugins/` and what became of it; empty until the plugins are loaded. */
plugins: PluginRecord[] = [];
private readonly toolbar: Toolbar;
/** Drawn-frame count at the last measurement, not a counter of its own. */
private fpsFrames = 0;
@@ -205,7 +213,10 @@ export class Editor implements PlayHost {
this.leftColumn.add(this.hierarchy, 1);
this.centre = new VStack();
this.tabs = new TabStrip(TAB_ORDER, TAB_LABELS, (tab) => this.showTab(tab));
this.tabs = new TabStrip<TabId>(
TAB_ORDER.map((tab) => ({ tab, label: TAB_LABELS[tab] })),
(tab) => this.showTab(tab),
);
this.tabs.minHeight = Theme.controlHeight + 8;
// Written out per tab rather than derived, because a `Record<CentreTab, …>` is what makes
// adding a tab a type error everywhere it has to be handled instead of a silent omission.
@@ -308,7 +319,28 @@ export class Editor implements PlayHost {
state.on('mode', () => this.editorRun.invalidate());
}
showTab(tab: CentreTab): void {
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
hasTab(id: string): boolean {
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
}
/** Appends a plugin's tab after the built-in ones; `hasTab` has said the id is free. */
addPluginTab(id: string, label: string, content: Widget): void {
const tab: PluginTabId = `plugin:${id}`;
this.pluginPanes.set(tab, content);
content.visible = false;
this.centre.addChildAt(content, this.centre.getChildIndex(this.consoleSplitter));
content.flex = 1;
this.tabs.addTab(tab, label);
this.centre.relayout();
}
/** What a plugin put into its tab, for the check scripts. */
pluginPane(id: string): Widget | undefined {
return this.pluginPanes.get(`plugin:${id}`);
}
showTab(tab: TabId): void {
this.tab = tab;
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
@@ -317,6 +349,7 @@ export class Editor implements PlayHost {
: () => this.state.activeScene,
);
for (const key of TAB_ORDER) this.panes[key].visible = key === tab;
for (const [key, pane] of this.pluginPanes) pane.visible = key === tab;
this.tabs.setActive(tab);
this.centre.relayout();
ui().focus.validate();
@@ -326,7 +359,7 @@ export class Editor implements PlayHost {
* Global x-extent of the tab buttons in the row, from the widget box – not `getBounds()`, which
* on an untoggled ghost tab sees nothing but the label (the background is not drawn at all).
*/
tabRow(): { shown: TabPlace<CentreTab>[]; hidden: CentreTab[] } {
tabRow(): { shown: TabPlace<TabId>[]; hidden: TabId[] } {
return { shown: this.tabs.places(), hidden: this.tabs.hidden() };
}
+27 -15
View File
@@ -15,38 +15,50 @@ export interface TabPlace<T extends string> {
* menu, so the row survives any window width and any number of tabs.
*/
export class TabStrip<T extends string> extends HStack {
private readonly order: T[] = [];
private readonly labels = new Map<T, string>();
private readonly buttons = new Map<T, Button>();
private readonly overflow: Button;
private hiddenTabs: T[] = [];
private active: T;
constructor(
private readonly order: readonly T[],
private readonly labels: Record<T, string>,
tabs: readonly { tab: T; label: string }[],
private readonly onPick: (tab: T) => void,
) {
super({ gap: 2, padding: 4, align: 'center' });
this.active = order[0]!;
for (const tab of order) {
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
const button = new Button(labels[tab], {
variant: 'ghost',
padding: 4,
onClick: () => this.onPick(tab),
});
this.buttons.set(tab, button);
this.add(button);
}
this.active = tabs[0]!.tab;
this.overflow = new Button('»', {
variant: 'ghost',
width: OVERFLOW_WIDTH,
onClick: () => this.openMenu(),
});
this.add(this.overflow);
for (const { tab, label } of tabs) this.place(tab, label);
this.buttons.get(this.active)!.setToggled(true);
}
/** Appends a tab at the end of the row, in front of the `»` button. */
addTab(tab: T, label: string): void {
this.place(tab, label);
this.relayout();
}
private place(tab: T, label: string): void {
// Half the theme padding: nine tabs at the full 8 px overflow the centre column of a 1280 px
// window by 27 px, and a button past its edge sits under the inspector (measured, CI run 5).
const button = new Button(label, {
variant: 'ghost',
padding: 4,
onClick: () => this.onPick(tab),
});
this.order.push(tab);
this.labels.set(tab, label);
this.buttons.set(tab, button);
this.add(button);
this.setChildIndex(this.overflow, this.children.length - 1);
}
setActive(tab: T): void {
this.active = tab;
for (const [key, button] of this.buttons) button.setToggled(key === tab);
@@ -92,7 +104,7 @@ export class TabStrip<T extends string> extends HStack {
private openMenu(): void {
const items = this.hiddenTabs.map((tab) => ({
label: this.labels[tab],
label: this.labels.get(tab)!,
onSelect: () => this.onPick(tab),
}));
new Menu(items).openBelow(this.overflow);
+18 -2
View File
@@ -22,7 +22,8 @@ import { soundLibrary } from '../viewport/SoundLibrary';
import { textureLibrary } from '../viewport/TextureLibrary';
import { currentFramesDialog } from './animation/FramesDialog';
import { currentRowDialog } from './animation/RowDialog';
import type { CentreTab, Editor } from './Editor';
import type { Editor, TabId } from './Editor';
import { NodeBoard } from './plugins/NodeBoard';
import { currentFontPicker } from './font/FontPicker';
import { currentNodePalette } from './graph/NodePalette';
import { currentSoundPicker } from './sound/SoundPicker';
@@ -658,6 +659,21 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
(app.renderer as unknown as { gl?: WebGL2RenderingContext }).gl?.getContextAttributes()
?.desynchronized === true,
tab: () => editor.tab,
showTab: (tab: CentreTab) => editor.showTab(tab),
showTab: (tab: TabId) => editor.showTab(tab),
plugins: {
/** Every file in `data/plugins/` and what became of it. */
records: () => editor.plugins,
/** A plugin tab's node board as drawn, with its canvas and Fit button; null without one. */
board: (tabId: string) => {
const pane = editor.pluginPane(tabId);
if (!(pane instanceof NodeBoard)) return null;
return {
...pane.debugInfo(),
visible: pane.visible,
rect: rectOf(pane),
fit: centreOf(pane.fitButton),
};
},
},
};
}
+8 -32
View File
@@ -47,10 +47,8 @@ import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { beadsInFlight, launchBead } from './beads';
import {
activeDrag,
clamp,
type DragPayload,
drawBand,
drawGrid,
endDrag,
onDragChange,
pressCount,
@@ -91,6 +89,7 @@ import { openAnnotationMenu, openLinkMenu, openNodeMenu } from './graphMenus';
import { NodePalette, type PaletteAction } from './NodePalette';
import { type PaletteEntry, paletteEntries } from './paletteEntries';
import { NODE_WIDTH, NodeView, TITLE_HEIGHT } from './NodeView';
import { drawGraphGrid, FIT_MARGIN, GRID_STEP, ZOOM_LIMITS, zoomedAbout } from './graphView';
/** One running instance's answer for a machine: which state it holds, and whose instance it is. */
export interface LiveMachineState {
@@ -100,16 +99,8 @@ export interface LiveMachineState {
ownerId: string;
}
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
const GRID_STEP = 24;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 8;
const MIN_ZOOM = 0.25;
const MAX_ZOOM = 2.5;
/** Stand-in node height for a node with no view yet – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
/** Breathing room around the framed graph, so nodes do not touch the edge. */
const FIT_MARGIN = 24;
/** Pointer travel (screen pixels) before a press turns into a drag rather than a click. */
const DRAG_THRESHOLD = 3;
/** Live values change every frame; ten updates a second are still readable and cost far less text layout. */
@@ -209,12 +200,11 @@ export class GraphCanvas extends Widget {
this.on('pointerup', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('pointerupoutside', (e: FederatedPointerEvent) => this.onPointerUp(e));
this.on('wheel', (e: FederatedWheelEvent) => {
const local = this.toLocal(e.global);
const before = this.screenToGraph(local.x, local.y);
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
const after = this.screenToGraph(local.x, local.y);
this.pan.x += (after.x - before.x) * this.zoom;
this.pan.y += (after.y - before.y) * this.zoom;
({ pan: this.pan, zoom: this.zoom } = zoomedAbout(
{ pan: this.pan, zoom: this.zoom },
this.toLocal(e.global),
zoomFactor(e.deltaY),
));
this.applyView();
e.preventDefault();
});
@@ -514,10 +504,7 @@ export class GraphCanvas extends Widget {
this.applyView();
return;
}
const framing = frameBoxes(boxes, { w: this.w, h: this.h }, FIT_MARGIN, {
min: MIN_ZOOM,
max: MAX_ZOOM,
});
const framing = frameBoxes(boxes, { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS);
this.zoom = framing.zoom;
this.pan = { x: framing.panX, y: framing.panY };
this.applyView();
@@ -1167,18 +1154,7 @@ export class GraphCanvas extends Widget {
}
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
drawGraphGrid(this.grid, { pan: this.pan, zoom: this.zoom }, this.w, this.h);
}
/** Global screen position of a port (tests drive connections with it). */
+40
View File
@@ -0,0 +1,40 @@
import type { Graphics } from 'pixi.js';
import { clamp, drawGrid } from '../../ui';
import type { Point } from '../framing';
/**
* What every surface that shows nodes shares with the graph editor: how far it zooms, how the grid
* is drawn and how the wheel zooms about the pointer. The graph editor and a plugin's node board
* look and move the same because they both come through here.
*/
/** Spacing of the background grid – and, with snapping on, of the positions a drag can leave. */
export const GRID_STEP = 24;
export const ZOOM_LIMITS = { min: 0.25, max: 2.5 };
/** Breathing room around the framed nodes, so they do not touch the edge. */
export const FIT_MARGIN = 24;
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
const MIN_GRID_STEP = 8;
export interface GraphView {
/** Where the graph origin sits on screen. */
pan: Point;
/** Screen pixels per graph unit. */
zoom: number;
}
/** The view after zooming by `factor` about a screen point, which keeps the graph point under it. */
export function zoomedAbout(view: GraphView, at: Point, factor: number): GraphView {
const zoom = clamp(view.zoom * factor, ZOOM_LIMITS.min, ZOOM_LIMITS.max);
const graphX = (at.x - view.pan.x) / view.zoom;
const graphY = (at.y - view.pan.y) / view.zoom;
return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom };
}
export function drawGraphGrid(g: Graphics, view: GraphView, w: number, h: number): void {
g.clear();
const step = GRID_STEP * view.zoom;
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen, but no axis lines: it marks nothing.
drawGrid(g, { originX: view.pan.x, originY: view.pan.y, step, w, h, alpha: 0.04 });
}
+180
View File
@@ -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);
}
}
+95
View File
@@ -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;
}
+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>;
+37
View File
@@ -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,
});
}