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
@@ -3,6 +3,7 @@
|
||||
/data/textures/
|
||||
/data/fonts/
|
||||
/data/sounds/
|
||||
/data/plugins/
|
||||
/dist/
|
||||
/node_modules/
|
||||
/logs/
|
||||
|
||||
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 948 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 963 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -25,6 +25,9 @@ declare(strict_types=1);
|
||||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||||
* DELETE ?r=sounds/{file} → 204
|
||||
*
|
||||
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
|
||||
* GET ?r=plugins/{file} → the module source as text/javascript
|
||||
*
|
||||
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
|
||||
*
|
||||
@@ -34,6 +37,7 @@ declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/PluginStore.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/SoundStore.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
@@ -213,6 +217,30 @@ function routeSounds(array $parts, string $method): never
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routePlugins(array $parts, string $method): never
|
||||
{
|
||||
if ($method !== 'GET') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$store = new PluginStore(dirname(__DIR__) . '/data/plugins');
|
||||
|
||||
if (count($parts) === 1) {
|
||||
Response::json($store->list());
|
||||
}
|
||||
|
||||
$source = $store->read($parts[1]);
|
||||
if ($source === null) {
|
||||
Response::error('Plugin not found', 404);
|
||||
}
|
||||
header('Content-Type: text/javascript; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
// A replaced file has to be the one the next editor start runs.
|
||||
header('Cache-Control: no-cache');
|
||||
echo $source;
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
@@ -231,6 +259,7 @@ try {
|
||||
'textures' => routeTextures($parts, $method),
|
||||
'fonts' => routeFonts($parts, $method),
|
||||
'sounds' => routeSounds($parts, $method),
|
||||
'plugins' => routePlugins($parts, $method),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The editor plugins in `data/plugins/`: one ES module per `.js` file, and every file there is
|
||||
* active. Read-only for now – putting a file there is done by hand, by the build and by the e2e
|
||||
* seeder. The folder is not served statically: a module is only ever handed out by its bare file
|
||||
* name, so no request can reach past it.
|
||||
*/
|
||||
final class PluginStore
|
||||
{
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
}
|
||||
|
||||
/** A file name as it may be asked for: no path, no leading dot, `.js` at the end. */
|
||||
public static function isValidName(string $name): bool
|
||||
{
|
||||
return (bool) preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/', $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted by file name, which is also the order the editor activates them in.
|
||||
*
|
||||
* @return list<array{file: string, bytes: int, updatedAt: int}>
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$files = is_dir($this->dir) ? scandir($this->dir) : [];
|
||||
$out = [];
|
||||
foreach ($files === false ? [] : $files as $file) {
|
||||
$path = "{$this->dir}/{$file}";
|
||||
if (!self::isValidName($file) || !is_file($path)) {
|
||||
continue;
|
||||
}
|
||||
$out[] = ['file' => $file, 'bytes' => (int) filesize($path), 'updatedAt' => (int) filemtime($path)];
|
||||
}
|
||||
usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file']));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** The module's source, or null when there is no such plugin. */
|
||||
public function read(string $file): ?string
|
||||
{
|
||||
$path = "{$this->dir}/{$file}";
|
||||
if (!self::isValidName($file) || !is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$source = file_get_contents($path);
|
||||
return $source === false ? null : $source;
|
||||
}
|
||||
}
|
||||
@@ -58,5 +58,24 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
|
||||
// Editor plugins: browser modules that get everything through `api`, so they import nothing.
|
||||
{
|
||||
files: ['plugins/**/*.js'],
|
||||
extends: [js.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: 'module',
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{ selector: 'ImportDeclaration', message: 'A plugin gets everything through `api`.' },
|
||||
],
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
prettier,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Demo plugin (#113): a tab "Assets" that shows every asset of the project as a node, laid out in
|
||||
* a grid in the order of the asset list. It follows the project: adding, renaming or deleting an
|
||||
* asset redraws the board.
|
||||
*
|
||||
* A plugin imports nothing – everything it needs comes in through `api`.
|
||||
*/
|
||||
|
||||
export const meta = { id: 'demo', name: 'Demo plugin', version: '1.0.0', api: 1 };
|
||||
|
||||
const COLUMNS = 4;
|
||||
const CELL_W = 220;
|
||||
const CELL_H = 64;
|
||||
|
||||
export function activate(api) {
|
||||
const board = new api.ui.NodeBoard();
|
||||
const show = () =>
|
||||
board.setNodes(
|
||||
api.project.assets().map((asset, i) => ({
|
||||
id: asset.id,
|
||||
title: asset.name,
|
||||
x: (i % COLUMNS) * CELL_W,
|
||||
y: Math.floor(i / COLUMNS) * CELL_H,
|
||||
})),
|
||||
);
|
||||
show();
|
||||
api.project.on('change', show);
|
||||
api.tabs.add({ id: 'assets', label: 'Assets', create: () => board });
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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). */
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Plugins (#113): what the loader does with every way a file can fail to be a plugin, and what the
|
||||
* API v1 lets a plugin see and do. The node board itself draws text and is checked in the browser
|
||||
* (e2e step 38); only its reading of plugin input is here.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
CreateAssetCommand,
|
||||
RenameAssetCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../src/core/commands/assetCommands';
|
||||
import { createEmptyProject } from '../../src/core/model/Project';
|
||||
import { zoomedAbout } from '../../src/editor/graph/graphView';
|
||||
import { activatePlugins, readMeta } from '../../src/editor/plugins/loader';
|
||||
import { boardNodes } from '../../src/editor/plugins/NodeBoard';
|
||||
import { createPluginApi, type PluginHost } from '../../src/editor/plugins/pluginApi';
|
||||
import { EditorState } from '../../src/editor/state';
|
||||
import { Widget } from '../../src/ui';
|
||||
|
||||
const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: unknown = 1) => ({
|
||||
meta: { id, name: `Plugin ${id}`, version: '1.0.0', api },
|
||||
activate,
|
||||
});
|
||||
|
||||
function loaderDeps(modules: Record<string, () => unknown>) {
|
||||
const reports: string[] = [];
|
||||
const activated: string[] = [];
|
||||
return {
|
||||
reports,
|
||||
activated,
|
||||
deps: {
|
||||
load: (file: string) => {
|
||||
const make = modules[file];
|
||||
return make ? Promise.resolve(make()) : Promise.reject(new Error('404'));
|
||||
},
|
||||
api: (meta: { id: string }) => {
|
||||
activated.push(meta.id);
|
||||
return { for: meta.id };
|
||||
},
|
||||
report: (message: string) => reports.push(message),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('the plugin loader', () => {
|
||||
it('activates in file-name order, whatever order the list came in', async () => {
|
||||
const order: string[] = [];
|
||||
const { deps } = loaderDeps({
|
||||
'b.js': () => plugin('b', () => order.push('b')),
|
||||
'a.js': () => plugin('a', () => order.push('a')),
|
||||
});
|
||||
const records = await activatePlugins(['b.js', 'a.js'], deps);
|
||||
expect(order).toEqual(['a', 'b']);
|
||||
expect(records.map((r) => [r.file, r.status])).toEqual([
|
||||
['a.js', 'active'],
|
||||
['b.js', 'active'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands every plugin its own API and waits for an async activate before the next', async () => {
|
||||
const seen: unknown[] = [];
|
||||
let first = false;
|
||||
const { deps } = loaderDeps({
|
||||
'a.js': () =>
|
||||
plugin('a', async (api) => {
|
||||
seen.push(api);
|
||||
await Promise.resolve();
|
||||
first = true;
|
||||
}),
|
||||
'b.js': () => plugin('b', (api) => seen.push(api, first)),
|
||||
});
|
||||
await activatePlugins(['a.js', 'b.js'], deps);
|
||||
expect(seen).toEqual([{ for: 'a' }, { for: 'b' }, true]);
|
||||
});
|
||||
|
||||
it('reports each broken plugin and goes on with the rest', async () => {
|
||||
const { deps, reports, activated } = loaderDeps({
|
||||
'good.js': () => plugin('good'),
|
||||
'no-meta.js': () => ({ activate: () => {} }),
|
||||
'no-activate.js': () => ({ meta: { id: 'x', api: 1 } }),
|
||||
'future.js': () => plugin('future', () => {}, 2),
|
||||
'throws.js': () =>
|
||||
plugin('throws', () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
});
|
||||
const records = await activatePlugins(
|
||||
['missing.js', 'no-meta.js', 'no-activate.js', 'future.js', 'throws.js', 'good.js'],
|
||||
deps,
|
||||
);
|
||||
const byFile = Object.fromEntries(records.map((r) => [r.file, r]));
|
||||
expect(byFile['good.js']?.status).toBe('active');
|
||||
expect(byFile['missing.js']).toMatchObject({ status: 'failed', error: 'does not load (404)' });
|
||||
expect(byFile['no-meta.js']).toMatchObject({ status: 'failed', error: 'exports no meta' });
|
||||
expect(byFile['no-activate.js']).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'exports no activate function',
|
||||
});
|
||||
expect(byFile['future.js']).toMatchObject({
|
||||
status: 'failed',
|
||||
error: 'needs plugin API 2, this editor offers 1',
|
||||
});
|
||||
expect(byFile['throws.js']).toMatchObject({ status: 'failed', error: 'activate failed (boom)' });
|
||||
expect(reports).toHaveLength(5);
|
||||
expect(reports).toContain('Plugin throws.js not activated: activate failed (boom)');
|
||||
// A plugin of the wrong version is never given an API at all.
|
||||
expect(activated).toEqual(['good', 'throws']);
|
||||
});
|
||||
|
||||
it('turns away a second plugin with an id that is active already', async () => {
|
||||
const { deps, activated } = loaderDeps({ 'a.js': () => plugin('same'), 'b.js': () => plugin('same') });
|
||||
const records = await activatePlugins(['a.js', 'b.js'], deps);
|
||||
expect(records.map((r) => r.status)).toEqual(['active', 'failed']);
|
||||
expect(records[1]?.error).toBe('a plugin with the id "same" is active already');
|
||||
expect(activated).toEqual(['same']);
|
||||
});
|
||||
|
||||
it('lets a failed plugin keep its id free for a later one', async () => {
|
||||
const { deps } = loaderDeps({
|
||||
'a.js': () =>
|
||||
plugin('same', () => {
|
||||
throw new Error('no');
|
||||
}),
|
||||
'b.js': () => plugin('same'),
|
||||
});
|
||||
expect((await activatePlugins(['a.js', 'b.js'], deps)).map((r) => r.status)).toEqual([
|
||||
'failed',
|
||||
'active',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills in a missing name and version from what is there', () => {
|
||||
expect(readMeta({ meta: { id: 'bare', api: 1 }, activate: () => {} })).toEqual({
|
||||
id: 'bare',
|
||||
name: 'bare',
|
||||
version: '',
|
||||
api: 1,
|
||||
});
|
||||
expect(readMeta(null)).toBe('exports no meta');
|
||||
expect(readMeta({ meta: { id: '', api: 1 }, activate: () => {} })).toBe(
|
||||
'meta.id must be a non-empty string',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
class Pane extends Widget {}
|
||||
|
||||
function editorHost(): {
|
||||
state: EditorState;
|
||||
host: PluginHost;
|
||||
tabs: Map<string, { label: string; content: Widget }>;
|
||||
} {
|
||||
const state = new EditorState();
|
||||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||||
const tabs = new Map<string, { label: string; content: Widget }>();
|
||||
const host: PluginHost = {
|
||||
assets: () => state.project.assets,
|
||||
onProjectChange: (listener) => state.on('project', listener),
|
||||
hasTab: (id) => id === 'scene' || tabs.has(id),
|
||||
addTab: (id, label, content) => tabs.set(id, { label, content }),
|
||||
};
|
||||
return { state, host, tabs };
|
||||
}
|
||||
|
||||
describe('the plugin API', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('lists the assets as id, name and type – copies, so a plugin cannot write the model', () => {
|
||||
const { state, host } = editorHost();
|
||||
const create = new CreateAssetCommand('behavior', 'Walker');
|
||||
state.execute(create);
|
||||
const api = createPluginApi('demo', host);
|
||||
const assets = api.project.assets();
|
||||
expect(assets.at(-1)).toEqual({ id: create.asset.id, name: 'Walker', type: 'behavior' });
|
||||
assets.at(-1)!.name = 'Changed';
|
||||
expect(state.project.assets.find((a) => a.id === create.asset.id)?.name).toBe('Walker');
|
||||
expect(Object.isFrozen(api) && Object.isFrozen(api.project) && Object.isFrozen(api.tabs)).toBe(true);
|
||||
});
|
||||
|
||||
it('calls a change listener when the asset list changes, not on every model change', () => {
|
||||
const { state, host } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
const calls = vi.fn();
|
||||
const off = api.project.on('change', calls);
|
||||
|
||||
const create = new CreateAssetCommand('sprite', 'Crate');
|
||||
state.execute(create);
|
||||
expect(calls).toHaveBeenCalledTimes(1);
|
||||
state.execute(new SetAssetPropertyCommand(create.asset.id, 'width', 42));
|
||||
state.preview();
|
||||
expect(calls).toHaveBeenCalledTimes(1);
|
||||
state.execute(new RenameAssetCommand(create.asset.id, 'Box'));
|
||||
expect(calls).toHaveBeenCalledTimes(2);
|
||||
// Back through rename, width and create: the width step leaves the list as it was.
|
||||
state.undo();
|
||||
state.undo();
|
||||
state.undo();
|
||||
expect(calls).toHaveBeenCalledTimes(4);
|
||||
|
||||
off();
|
||||
state.redo();
|
||||
expect(calls).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('reports a listener that throws and keeps it subscribed', () => {
|
||||
const { state, host } = editorHost();
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const api = createPluginApi('demo', host);
|
||||
let calls = 0;
|
||||
api.project.on('change', () => {
|
||||
calls++;
|
||||
throw new Error('listener broke');
|
||||
});
|
||||
state.execute(new CreateAssetCommand('sprite', 'A'));
|
||||
state.execute(new CreateAssetCommand('sprite', 'B'));
|
||||
expect(calls).toBe(2);
|
||||
expect(error).toHaveBeenCalledTimes(2);
|
||||
expect(String(error.mock.calls[0]?.[0])).toContain('[plugin demo]');
|
||||
});
|
||||
|
||||
it('knows only the "change" event', () => {
|
||||
const api = createPluginApi('demo', editorHost().host);
|
||||
expect(() => api.project.on('save', () => {})).toThrow('Unknown project event "save"');
|
||||
});
|
||||
|
||||
it('adds a tab with the widget its create returns', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
const pane = new Pane();
|
||||
expect(api.tabs.add({ id: 'assets', label: 'Assets', create: () => pane })).toBe(true);
|
||||
expect(tabs.get('assets')).toEqual({ label: 'Assets', content: pane });
|
||||
});
|
||||
|
||||
it('turns away a taken tab id with a warning, before anything is created', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const api = createPluginApi('demo', host);
|
||||
const create = vi.fn(() => new Pane());
|
||||
expect(api.tabs.add({ id: 'scene', label: 'Mine', create })).toBe(false);
|
||||
api.tabs.add({ id: 'assets', label: 'Assets', create });
|
||||
expect(createPluginApi('other', host).tabs.add({ id: 'assets', label: 'Again', create })).toBe(false);
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(tabs.get('assets')?.label).toBe('Assets');
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(String(warn.mock.calls[1]?.[0])).toBe('[plugin other] a tab "assets" exists already – not added');
|
||||
});
|
||||
|
||||
it('throws on a tab without id, label or create, or whose content is not a widget', () => {
|
||||
const { host, tabs } = editorHost();
|
||||
const api = createPluginApi('demo', host);
|
||||
expect(() => api.tabs.add({ id: '', label: 'X', create: () => new Pane() })).toThrow('id must be');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 3, create: () => new Pane() })).toThrow('label must be');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 'X', create: 'no' })).toThrow('create must be a function');
|
||||
expect(() => api.tabs.add({ id: 'x', label: 'X', create: () => ({ w: 10, h: 10 }) })).toThrow(
|
||||
'widget from api.ui',
|
||||
);
|
||||
expect(tabs.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a node board reading plugin input', () => {
|
||||
it('takes titles and positions as given', () => {
|
||||
expect(boardNodes([{ id: 'a', title: 'Crate', x: 10, y: -20 }])).toEqual([
|
||||
{ id: 'a', title: 'Crate', x: 10, y: -20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes do with what plain JavaScript may hand over', () => {
|
||||
expect(boardNodes([{ id: 7, title: 12 }, { x: NaN, y: '5', title: {} }, null])).toEqual([
|
||||
{ id: '7', title: '12', x: 0, y: 0 },
|
||||
{ id: '1', title: '', x: 0, y: 0 },
|
||||
{ id: '2', title: '', x: 0, y: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on something that is not a list', () => {
|
||||
expect(() => boardNodes({ id: 'a' })).toThrow('expects an array');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zooming a graph view about a point', () => {
|
||||
it('keeps the graph point under the pointer where it was', () => {
|
||||
const view = { pan: { x: 100, y: 50 }, zoom: 1 };
|
||||
const at = { x: 300, y: 250 };
|
||||
const next = zoomedAbout(view, at, 2);
|
||||
expect(next.zoom).toBe(2);
|
||||
// Graph point (200, 200) sat under the pointer before and has to after.
|
||||
expect(200 * next.zoom + next.pan.x).toBe(at.x);
|
||||
expect(200 * next.zoom + next.pan.y).toBe(at.y);
|
||||
});
|
||||
|
||||
it('stops at the zoom limits', () => {
|
||||
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 2 }, { x: 0, y: 0 }, 10).zoom).toBe(2.5);
|
||||
expect(zoomedAbout({ pan: { x: 0, y: 0 }, zoom: 0.3 }, { x: 0, y: 0 }, 0.1).zoom).toBe(0.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||
|
||||
/**
|
||||
* Plugins (#113). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and the
|
||||
* seeder; the editor loads it at start-up through the API. What only a running editor can show:
|
||||
* that the module really loads and activates, that its tab joins the row, and that its node board
|
||||
* draws one node per asset and follows the asset list – created through the + menu, renamed, and
|
||||
* taken away again by undo.
|
||||
*/
|
||||
export async function plugins(t) {
|
||||
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } =
|
||||
t;
|
||||
|
||||
await press('Escape');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
|
||||
// 38a. Loading runs after the project has booted, so on a fresh page it may still be under way.
|
||||
let records = [];
|
||||
for (let i = 0; i < 50 && records.length === 0; i++) {
|
||||
records = await json('__gm.plugins.records()');
|
||||
if (records.length === 0) await sleep(100);
|
||||
}
|
||||
const demo = records.find((r) => r.file === 'demo-plugin.js');
|
||||
check(
|
||||
'the demo plugin is loaded from data/plugins and active',
|
||||
demo?.status === 'active' && demo.meta?.id === 'demo' && demo.meta.api === 1,
|
||||
JSON.stringify(records),
|
||||
);
|
||||
check(
|
||||
'no plugin failed',
|
||||
records.every((r) => r.status === 'active'),
|
||||
JSON.stringify(records.filter((r) => r.status !== 'active')),
|
||||
);
|
||||
|
||||
// 38b. Its tab sits after every built-in one – in the row or behind », depending on the width.
|
||||
const row = await json('__gm.tabRow()');
|
||||
const order = [...row.shown.map((p) => p.tab), ...row.hidden];
|
||||
check('the plugin tab is in the tab row', order.includes('plugin:assets'), JSON.stringify(order));
|
||||
check(
|
||||
'and it comes after the built-in tabs',
|
||||
row.hidden.length > 0 ? row.hidden.at(-1) === 'plugin:assets' : row.shown.at(-1)?.tab === 'plugin:assets',
|
||||
JSON.stringify(row),
|
||||
);
|
||||
|
||||
// 38c. One node per asset, titled by its name, in the order of the asset list.
|
||||
await openTab(t, 'plugin:assets');
|
||||
await settle();
|
||||
const names = () => json('__gm.state.project.assets.map((a) => a.name)');
|
||||
const board = () => json("__gm.plugins.board('assets')");
|
||||
const titles = (b) => b.nodes.map((n) => n.title);
|
||||
let shown = await board();
|
||||
check(
|
||||
'clicking the tab opens the node board',
|
||||
(await json('__gm.tab()')) === 'plugin:assets' && shown?.visible === true,
|
||||
JSON.stringify({ tab: await json('__gm.tab()'), visible: shown?.visible }),
|
||||
);
|
||||
const expected = await names();
|
||||
check(
|
||||
'the board shows every asset as a node titled with its name',
|
||||
JSON.stringify(titles(shown)) === JSON.stringify(expected),
|
||||
JSON.stringify({ nodes: titles(shown), assets: expected }),
|
||||
);
|
||||
|
||||
// 38d. Pan, zoom and Fit. The framing to come back to is taken from Fit itself, not from the first
|
||||
// show: in a chained run step 14 opened this tab in a narrowed column, and the board framed
|
||||
// its nodes for that width then.
|
||||
await t.click(shown.fit.x, shown.fit.y);
|
||||
await settle();
|
||||
shown = await board();
|
||||
const fitted = { zoom: shown.zoom, pan: shown.pan };
|
||||
// Any hit inside the board: the same name is also in the asset panel and the scene picker.
|
||||
const inside = (p) =>
|
||||
p.x > shown.rect.x &&
|
||||
p.x < shown.rect.x + shown.rect.w &&
|
||||
p.y > shown.rect.y &&
|
||||
p.y < shown.rect.y + shown.rect.h;
|
||||
const drawn = await json(`__gm.locateAll(${JSON.stringify(expected[0])})`);
|
||||
check(
|
||||
'after Fit the node titles are drawn inside the board',
|
||||
drawn.some(inside),
|
||||
JSON.stringify({ drawn, rect: shown.rect }),
|
||||
);
|
||||
await shot('38-plugin-tab');
|
||||
const centre = { x: shown.rect.x + shown.rect.w / 2, y: shown.rect.y + shown.rect.h - 40 };
|
||||
await drag(centre.x, centre.y, centre.x + 80, centre.y - 30);
|
||||
shown = await board();
|
||||
check(
|
||||
'dragging on the board pans it',
|
||||
Math.round(shown.pan.x - fitted.pan.x) === 80 && Math.round(shown.pan.y - fitted.pan.y) === -30,
|
||||
JSON.stringify({ fitted, now: shown.pan }),
|
||||
);
|
||||
await wheel(centre.x, centre.y, -300);
|
||||
await settle();
|
||||
shown = await board();
|
||||
check(
|
||||
'the wheel zooms it',
|
||||
shown.zoom > fitted.zoom,
|
||||
JSON.stringify({ fitted: fitted.zoom, now: shown.zoom }),
|
||||
);
|
||||
await t.click(shown.fit.x, shown.fit.y);
|
||||
await settle();
|
||||
shown = await board();
|
||||
check(
|
||||
'Fit frames the nodes as before the pan and zoom',
|
||||
Math.abs(shown.zoom - fitted.zoom) < 1e-6 &&
|
||||
Math.abs(shown.pan.x - fitted.pan.x) < 1e-6 &&
|
||||
Math.abs(shown.pan.y - fitted.pan.y) < 1e-6,
|
||||
JSON.stringify({ fitted, now: { zoom: shown.zoom, pan: shown.pan } }),
|
||||
);
|
||||
|
||||
// 38e. The board follows the asset list: a sprite created through the + menu, while the tab
|
||||
// stays open, is a node at once.
|
||||
// Through the menu's own entry: in a chained run an asset named "Sprite" is in the list already.
|
||||
await clickText('+');
|
||||
await pickAssetType(t, 'Sprite');
|
||||
await settle();
|
||||
const withNew = await names();
|
||||
shown = await board();
|
||||
check(
|
||||
'creating an asset adds its node',
|
||||
withNew.length === expected.length + 1 && JSON.stringify(titles(shown)) === JSON.stringify(withNew),
|
||||
JSON.stringify({ nodes: titles(shown), assets: withNew }),
|
||||
);
|
||||
check('and the tab stays open', (await json('__gm.tab()')) === 'plugin:assets', await json('__gm.tab()'));
|
||||
|
||||
// 38f. Renaming shows under the new name.
|
||||
const created = await json('(({ id, name }) => ({ id, name }))(__gm.state.project.assets.at(-1))');
|
||||
const newId = created.id;
|
||||
await mutate(
|
||||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = 'Renamed by e2e'`,
|
||||
);
|
||||
shown = await board();
|
||||
check(
|
||||
'renaming an asset renames its node',
|
||||
shown.nodes.find((n) => n.id === newId)?.title === 'Renamed by e2e',
|
||||
JSON.stringify(shown.nodes.at(-1)),
|
||||
);
|
||||
await mutate(
|
||||
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = ${JSON.stringify(created.name)}`,
|
||||
);
|
||||
|
||||
// 38g. Undo takes the created asset away again, and its node with it.
|
||||
await undo();
|
||||
await settle();
|
||||
shown = await board();
|
||||
check(
|
||||
'deleting the asset (undo of the create) removes its node',
|
||||
(await names()).length === expected.length &&
|
||||
JSON.stringify(titles(shown)) === JSON.stringify(expected) &&
|
||||
!shown.nodes.some((n) => n.id === newId),
|
||||
JSON.stringify({ nodes: titles(shown), assets: await names() }),
|
||||
);
|
||||
|
||||
// 38h. Another tab hides the board; the plugin's pane is a tab like any other.
|
||||
await showTab('scene');
|
||||
check('switching to another tab hides the board', (await board()).visible === false);
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { sound } from './34-sound.mjs';
|
||||
import { loadScene } from './35-load-scene.mjs';
|
||||
import { graphGroups } from './36-graph-groups.mjs';
|
||||
import { nineSlice } from './37-nine-slice.mjs';
|
||||
import { plugins } from './38-plugins.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -79,4 +80,5 @@ export const STEPS = [
|
||||
loadScene,
|
||||
graphGroups,
|
||||
nineSlice,
|
||||
plugins,
|
||||
];
|
||||
@@ -112,6 +112,8 @@ export const TAB_LABELS = {
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
particles: 'Particles',
|
||||
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
|
||||
'plugin:assets': 'Assets',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
*
|
||||
* Usage: node tools/seed-e2e.mjs [projectId] [baseUrl]
|
||||
*/
|
||||
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sineWav } from './lib/audio.mjs';
|
||||
import { edge, node, port, sceneObject, variable } from './lib/builders.mjs';
|
||||
import { PROJECTS, cliArgs, uploadFont, uploadSound, writeProject } from './lib/projects.mjs';
|
||||
@@ -13,6 +15,16 @@ import { adjustColorsShader } from './lib/shaders.mjs';
|
||||
|
||||
const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture);
|
||||
|
||||
/**
|
||||
* The plugins that ship with the repository, where the editor loads them from – the build does the
|
||||
* same, this makes a run after a plain checkout independent of it. The server is this checkout
|
||||
* (locally and in CI), so a file copy is enough.
|
||||
*/
|
||||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||||
mkdirSync(`${ROOT}data/plugins`, { recursive: true });
|
||||
for (const file of readdirSync(`${ROOT}plugins`).filter((f) => f.endsWith('.js')))
|
||||
copyFileSync(`${ROOT}plugins/${file}`, `${ROOT}data/plugins/${file}`);
|
||||
|
||||
/**
|
||||
* Two typefaces for the font step to pick and to drop. They are put on the server but not made
|
||||
* into assets: the step is about the picker and the drop making them. The files come from the
|
||||
|
||||
+20
-2
@@ -1,4 +1,4 @@
|
||||
import { copyFileSync } from 'node:fs';
|
||||
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
@@ -21,11 +21,29 @@ function publishIndexHtml(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor plugins are loaded from `data/plugins/`, which is not in the repository; the ones that
|
||||
* ship with it live in `plugins/` and are put there on every build. A file of the same name is
|
||||
* replaced, anything else in the folder is left alone.
|
||||
*/
|
||||
function publishPlugins(): Plugin {
|
||||
return {
|
||||
name: 'game-maker:publish-plugins',
|
||||
apply: 'build',
|
||||
closeBundle() {
|
||||
const target = resolve(__dirname, 'data/plugins');
|
||||
mkdirSync(target, { recursive: true });
|
||||
for (const file of readdirSync(resolve(__dirname, 'plugins')).filter((f) => f.endsWith('.js')))
|
||||
copyFileSync(resolve(__dirname, 'plugins', file), resolve(target, file));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
root: 'web',
|
||||
base: '/game-maker/dist/',
|
||||
publicDir: false,
|
||||
plugins: [publishIndexHtml()],
|
||||
plugins: [publishIndexHtml(), publishPlugins()],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
|
||||
Reference in new issue
Block a user