From 57a97ba94f88b8cea57d286ea1b6977164eebfe5 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 17 Sep 2026 19:00:03 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 2 +- api/index.php | 29 +++ api/lib/PluginStore.php | 52 +++++ eslint.config.js | 19 ++ plugins/demo-plugin.js | 29 +++ src/editor/App.ts | 2 + src/editor/Editor.ts | 43 ++++- src/editor/TabStrip.ts | 42 ++-- src/editor/debugHook.ts | 20 +- src/editor/graph/GraphCanvas.ts | 40 +--- src/editor/graph/graphView.ts | 40 ++++ src/editor/plugins/NodeBoard.ts | 180 ++++++++++++++++++ src/editor/plugins/loader.ts | 95 +++++++++ src/editor/plugins/pluginApi.ts | 88 +++++++++ src/editor/plugins/startPlugins.ts | 37 ++++ tests/editor/plugins.test.ts | 296 +++++++++++++++++++++++++++++ tools/e2e/38-plugins.mjs | 157 +++++++++++++++ tools/e2e/index.mjs | 2 + tools/e2e/lib/scaffold.mjs | 2 + tools/seed-e2e.mjs | 12 ++ vite.config.ts | 22 ++- 22 files changed, 1153 insertions(+), 57 deletions(-) create mode 100644 api/lib/PluginStore.php create mode 100644 plugins/demo-plugin.js create mode 100644 src/editor/graph/graphView.ts create mode 100644 src/editor/plugins/NodeBoard.ts create mode 100644 src/editor/plugins/loader.ts create mode 100644 src/editor/plugins/pluginApi.ts create mode 100644 src/editor/plugins/startPlugins.ts create mode 100644 tests/editor/plugins.test.ts create mode 100644 tools/e2e/38-plugins.mjs diff --git a/.gitignore b/.gitignore index ca3f8450..c7d9b775 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /data/textures/ /data/fonts/ /data/sounds/ +/data/plugins/ /dist/ /node_modules/ /logs/ diff --git a/README.md b/README.md index c8ba27b4..cb1944d5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/index.php b/api/index.php index 4f849054..acfac142 100644 --- a/api/index.php +++ b/api/index.php @@ -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 $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) { diff --git a/api/lib/PluginStore.php b/api/lib/PluginStore.php new file mode 100644 index 00000000..569939cb --- /dev/null +++ b/api/lib/PluginStore.php @@ -0,0 +1,52 @@ + + */ + 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; + } +} diff --git a/eslint.config.js b/eslint.config.js index d07c8ffa..c29d58db 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, ); diff --git a/plugins/demo-plugin.js b/plugins/demo-plugin.js new file mode 100644 index 00000000..adb63566 --- /dev/null +++ b/plugins/demo-plugin.js @@ -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 }); +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 12229817..71495273 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 fontLibrary.onChange(() => requestRender()); installDebugHook(app, state, editor); await editor.boot(); + await startPlugins(API_ENDPOINT, state, editor); } diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 894e0912..9575733f 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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; + private readonly tabs: TabStrip; private readonly panes: Record; + private readonly pluginPanes = new Map(); + /** 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( + 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` 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[]; hidden: CentreTab[] } { + tabRow(): { shown: TabPlace[]; hidden: TabId[] } { return { shown: this.tabs.places(), hidden: this.tabs.hidden() }; } diff --git a/src/editor/TabStrip.ts b/src/editor/TabStrip.ts index 6b45d9f4..7cdb4c34 100644 --- a/src/editor/TabStrip.ts +++ b/src/editor/TabStrip.ts @@ -15,38 +15,50 @@ export interface TabPlace { * menu, so the row survives any window width and any number of tabs. */ export class TabStrip extends HStack { + private readonly order: T[] = []; + private readonly labels = new Map(); private readonly buttons = new Map(); private readonly overflow: Button; private hiddenTabs: T[] = []; private active: T; constructor( - private readonly order: readonly T[], - private readonly labels: Record, + 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 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); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index c97d430a..0d5a38a2 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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), + }; + }, + }, }; } diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index faead646..5fe0fde8 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -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). */ diff --git a/src/editor/graph/graphView.ts b/src/editor/graph/graphView.ts new file mode 100644 index 00000000..bf4f962e --- /dev/null +++ b/src/editor/graph/graphView.ts @@ -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 }); +} diff --git a/src/editor/plugins/NodeBoard.ts b/src/editor/plugins/NodeBoard.ts new file mode 100644 index 00000000..a93ba309 --- /dev/null +++ b/src/editor/plugins/NodeBoard.ts @@ -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; + 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 { + 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); + } +} diff --git a/src/editor/plugins/loader.ts b/src/editor/plugins/loader.ts new file mode 100644 index 00000000..4f85a06f --- /dev/null +++ b/src/editor/plugins/loader.ts @@ -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; + /** 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; + const meta = exports.meta; + if (typeof meta !== 'object' || meta === null) return 'exports no meta'; + const { id, name, version, api } = meta as Record; + 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 { + const records: PluginRecord[] = []; + const ids = new Set(); + 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; +} diff --git a/src/editor/plugins/pluginApi.ts b/src/editor/plugins/pluginApi.ts new file mode 100644 index 00000000..c9b9d16f --- /dev/null +++ b/src/editor/plugins/pluginApi.ts @@ -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; diff --git a/src/editor/plugins/startPlugins.ts b/src/editor/plugins/startPlugins.ts new file mode 100644 index 00000000..266462a9 --- /dev/null +++ b/src/editor/plugins/startPlugins.ts @@ -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 { + 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, + }); +} diff --git a/tests/editor/plugins.test.ts b/tests/editor/plugins.test.ts new file mode 100644 index 00000000..d83343d0 --- /dev/null +++ b/tests/editor/plugins.test.ts @@ -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 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; +} { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const tabs = new Map(); + 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); + }); +}); diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs new file mode 100644 index 00000000..234bf8e7 --- /dev/null +++ b/tools/e2e/38-plugins.mjs @@ -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); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 0abf0bb2..a01d4f36 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ]; diff --git a/tools/e2e/lib/scaffold.mjs b/tools/e2e/lib/scaffold.mjs index 0af8a58a..f89d6d25 100644 --- a/tools/e2e/lib/scaffold.mjs +++ b/tools/e2e/lib/scaffold.mjs @@ -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', }; /** diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index f4cb8cec..1284d98f 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -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 diff --git a/vite.config.ts b/vite.config.ts index ce9c1e1d..8b7d23d7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,