Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 09:22:49 +02:00
1 parent 1eb28314cf
commit f140c2fd77
58 files changed
+1969 -1171

No files matched your search

+48 -172
View File
@@ -1,45 +1,43 @@
import type { Application } from 'pixi.js';
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
import { worldPositionOf } from '../core/graph';
import { listScenes } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { Viewport } from '../viewport/Viewport';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { AssetPanel } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
export type CentreTab = 'scene' | 'graph' | 'game';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
log: string[];
}
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
const MAX_RIGHT_WIDTH = 600;
/**
* Composes toolbar, panels, viewports, graph editor and runtime into the one application that
* edits and plays. Owns the play session: the world, its systems and the tick.
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
* project is `PlaySession`, loading it is `ProjectSession`.
*/
export class Editor {
export class Editor implements PlayHost {
/** Scene tab: the editable model view (live positions while playing). */
readonly viewport: Viewport;
readonly viewport: SceneViewport;
/** Game tab: the running world through its camera. */
readonly gameView: Viewport;
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
private readonly play: PlaySession;
private readonly projects: ProjectSession;
private readonly leftColumn: VStack;
private readonly inspector: Inspector;
private readonly leftSplitter = new Splitter('x');
@@ -48,33 +46,37 @@ export class Editor {
private readonly centre: VStack;
private readonly tabButtons: Record<CentreTab, Button>;
private readonly toolbar: Toolbar;
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
private fpsFrames = 0;
private fpsElapsed = 0;
private leftWidth = 240;
private rightWidth = 300;
constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) {
constructor(app: Application, root: VStack, state: EditorState, store: ProjectStore) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
// A run holds a copy of the project, so it has to end before a different one is loaded.
this.projects = new ProjectSession(state, store, () => {
if (this.play.isPlaying) this.play.stop();
});
this.graphEditor.traceProvider = (assetId) => this.play.traceFor(assetId);
this.graphEditor.timeProvider = () => this.play.time;
this.toolbar = new Toolbar(state, {
openProjectMenu: (anchor) => this.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()),
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
});
root.add(this.toolbar);
this.workspace = new HStack();
this.viewport = new Viewport(state);
this.gameView = new Viewport(state, { game: true });
this.graphEditor = new GraphEditor(state);
this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
this.hierarchy = new HierarchyPanel(state);
this.leftColumn.add(this.hierarchy, 1);
this.inspector = new Inspector(state);
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
this.centre = new VStack();
@@ -97,12 +99,12 @@ export class Editor {
this.leftColumn.minWidth = this.leftWidth;
this.inspector.minWidth = this.rightWidth;
this.leftSplitter.onDrag = (delta) => {
this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + delta));
this.leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, this.leftWidth + delta));
this.leftColumn.minWidth = this.leftWidth;
this.workspace.relayout();
};
this.rightSplitter.onDrag = (delta) => {
this.rightWidth = Math.max(200, Math.min(600, this.rightWidth - delta));
this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta));
this.inspector.minWidth = this.rightWidth;
this.workspace.relayout();
};
@@ -115,14 +117,14 @@ export class Editor {
root.resize(app.screen.width, app.screen.height);
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play(), stop: () => this.stop() });
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
state.on('selection', () => {
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
});
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
state.on('project', () => this.world?.syncFromModel());
state.on('project', () => this.play.syncFromModel());
}
showTab(tab: CentreTab): void {
@@ -135,144 +137,26 @@ export class Editor {
ui().focus.validate();
}
// --- project lifecycle -----------------------------------------------------------------------
/** Opens the project named in the URL, else the first one on the server, else a new one. */
async boot(): Promise<void> {
const wanted = new URLSearchParams(location.search).get('project');
try {
if (wanted) {
await this.open(wanted);
return;
}
await this.openFirstOrCreate();
} catch (error) {
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
}
boot(): Promise<void> {
return this.projects.boot();
}
private async open(id: string): Promise<void> {
if (this.world) this.stop();
const loaded = await this.store.load(id);
this.state.loadProject(loaded.project, loaded.revision);
const url = new URL(location.href);
url.searchParams.set('project', id);
history.replaceState(null, '', url);
}
// --- PlayHost --------------------------------------------------------------------------------
private async openFirstOrCreate(): Promise<void> {
const list = await this.store.list();
if (list[0]) await this.open(list[0].id);
else await this.createProject('My first project');
}
private async createProject(name: string): Promise<void> {
const created = await this.store.create(name);
await this.open(created.project.id);
}
private async deleteCurrentProject(): Promise<void> {
if (this.world) this.stop();
await this.store.delete(this.state.project.id);
await this.openFirstOrCreate();
}
private openProjectMenu(anchor: Widget): void {
const open = async () => {
const list = await this.store.list();
const items: MenuItem[] = [
{ label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) },
...list.map((p) => ({
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
onSelect: () => void this.open(p.id),
disabled: p.id === this.state.project.id,
})),
{
label: 'Delete this project…',
danger: true,
onSelect: () =>
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() },
]).openCentered(),
},
];
new Menu(items).openBelow(anchor);
};
void open().catch((error) => this.state.setNotice(String(error)));
}
private openSceneMenu(anchor: Widget): void {
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
}));
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
new Menu(items).openBelow(anchor);
}
// --- play mode -------------------------------------------------------------------------------
/**
* Play: the state switches to a runtime copy of the project, the world is built from that
* copy and shown in the Game tab. All panels stay open and edit the copy – the world picks
* those edits up immediately, and nothing reaches the saved model.
*/
private play(): void {
const scene = this.state.activeScene;
if (!scene) return;
ui().focus.blur();
const runtime = this.state.enterPlay();
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
world.addSystem(collisions);
world.addSystem(this.behaviors);
this.gameView.setWorld(world);
showGame(): void {
this.showTab('game');
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
if (world.stopRequested) this.stop();
}
/** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */
private stop(): void {
const world = this.world;
this.world = null;
this.behaviors = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
this.gameView.setWorld(null);
this.state.exitPlay();
leaveGame(): void {
if (this.tab === 'game') this.showTab('scene');
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
this.state.setNotice(lastError ?? '');
}
// --- frame -----------------------------------------------------------------------------------
private tick(dt: number): void {
this.measureFps(dt);
const world = this.world;
if (!world) return;
world.tick(Math.min(dt, 0.1));
this.input.endFrame();
if (world.stopRequested) {
this.stop();
return;
}
// Restart game: throw the run away and start the scene again from the saved model.
if (world.restartRequested) {
this.stop();
this.play();
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
this.state.runtimeTick();
if (this.graphEditor.visible) this.graphEditor.redraw();
this.play.tick(dt);
}
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
@@ -288,14 +172,6 @@ export class Editor {
/** Read-only view of the running world for the check scripts. */
worldSnapshot(): WorldSnapshot | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
return this.play.snapshot();
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Owns everything that only exists while the project is playing: the world, its systems, the
* keyboard and the per-frame step.
*
* The key rule lives here: play never touches the saved model. `state.enterPlay()` swaps in a
* runtime copy, the world is built from that copy, edits made while playing go into it, and
* `exitPlay()` throws it away again.
*/
import { worldPositionOf } from '../core/graph';
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { ui } from '../ui';
import type { GameViewport } from '../viewport/GameViewport';
import type { GraphEditor } from './graph/GraphEditor';
import type { EditorState } from './state';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
log: string[];
}
/** What a play session needs from the surrounding editor, and nothing more. */
export interface PlayHost {
gameView: GameViewport;
graphEditor: GraphEditor;
/** Bring the game view to the front – play has started. */
showGame(): void;
/** Leave the game view if it is the one on screen – play has stopped. */
leaveGame(): void;
}
export class PlaySession {
private world: World | null = null;
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
constructor(private readonly state: EditorState, private readonly host: PlayHost) {}
get isPlaying(): boolean {
return this.world !== null;
}
/** Live execution data for the graph editor (null when not playing). */
traceFor(assetId: string): Trace | null {
return this.behaviors?.traceFor(assetId) ?? null;
}
get time(): number {
return this.world?.time ?? 0;
}
/** Mirrors an edit made while playing into the running world at once. */
syncFromModel(): void {
this.world?.syncFromModel();
}
/**
* Starts a run: the state switches to a runtime copy, the world is built from it and shown
* in the Game tab. All panels stay open and edit the copy.
*/
play(): void {
const scene = this.state.activeScene;
if (!scene) return;
ui().focus.blur();
const runtime = this.state.enterPlay();
const world = World.instantiate(runtime, scene.id);
this.world = world;
this.input.attach();
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
const collisions = new CollisionSystem();
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
world.addSystem(collisions);
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
if (world.stopRequested) this.stop();
}
/** Ends the run: the world and the runtime copy are discarded, the saved model returns untouched. */
stop(): void {
const world = this.world;
this.world = null;
this.behaviors = null;
this.input.detach();
ui().focus.blur();
world?.destroy();
this.host.gameView.setWorld(null);
this.state.exitPlay();
this.host.leaveGame();
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
this.state.setNotice(lastError ?? '');
}
/** One frame. Does nothing when not playing. */
tick(dt: number): void {
const world = this.world;
if (!world) return;
world.tick(Math.min(dt, 0.1));
this.input.endFrame();
if (world.stopRequested) {
this.stop();
return;
}
// Restart game: throw the run away and start the scene again from the saved model.
if (world.restartRequested) {
this.stop();
this.play();
return;
}
// Mirror what the systems did into the runtime copy so the panels show live values.
// A destroyed entity removes its object – that is a structural change the lists must see.
if (world.syncToModel()) this.state.preview();
this.state.runtimeTick();
if (this.host.graphEditor.visible) this.host.graphEditor.redraw();
}
/** Read-only view of the running world for the check scripts. */
snapshot(): WorldSnapshot | null {
const world = this.world;
if (!world) return null;
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
}),
log: [...world.log],
};
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger
* it. Talks only to the store and the editor state – no widgets of its own beyond the menus.
*/
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
import { listScenes } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import { Dialog, Menu, type MenuItem, type Widget } from '../ui';
import type { EditorState } from './state';
export class ProjectSession {
/**
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
* a run in progress has to end first, since it holds a copy of the project being replaced.
*/
constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {}
/** Opens the project named in the URL, else the first one on the server, else a new one. */
async boot(): Promise<void> {
const wanted = new URLSearchParams(location.search).get('project');
try {
if (wanted) {
await this.open(wanted);
return;
}
await this.openFirstOrCreate();
} catch (error) {
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
}
}
private async open(id: string): Promise<void> {
this.beforeSwitch();
const loaded = await this.store.load(id);
this.state.loadProject(loaded.project, loaded.revision);
const url = new URL(location.href);
url.searchParams.set('project', id);
history.replaceState(null, '', url);
}
private async openFirstOrCreate(): Promise<void> {
const list = await this.store.list();
if (list[0]) await this.open(list[0].id);
else await this.create('My first project');
}
private async create(name: string): Promise<void> {
const created = await this.store.create(name);
await this.open(created.project.id);
}
private async deleteCurrent(): Promise<void> {
this.beforeSwitch();
await this.store.delete(this.state.project.id);
await this.openFirstOrCreate();
}
openProjectMenu(anchor: Widget): void {
const open = async () => {
const list = await this.store.list();
const items: MenuItem[] = [
{ label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) },
...list.map((p) => ({
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
onSelect: () => void this.open(p.id),
disabled: p.id === this.state.project.id,
})),
{
label: 'Delete this project…',
danger: true,
onSelect: () =>
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
]).openCentered(),
},
];
new Menu(items).openBelow(anchor);
};
void open().catch((error) => this.state.setNotice(String(error)));
}
openSceneMenu(anchor: Widget): void {
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
}));
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
new Menu(items).openBelow(anchor);
}
}
+26
View File
@@ -2,9 +2,12 @@
* Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset, findUsages } from '../core/model/Project';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
@@ -27,3 +30,26 @@ export function addCollider(state: EditorState, sceneId: string, objectId: strin
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
}
/**
* Asks before deleting an asset, spelling out what goes with it. Both ways of deleting (the
* asset list's context menu and the Delete key) go through here, so the warning cannot differ
* depending on how the user triggered it.
*/
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const asset = findAsset(state.project, assetId);
if (!asset) return;
const usages = findUsages(state.project, assetId).length;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
new Dialog(`Delete "${asset.name}"?`, detail, [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
]).openCentered();
}
+1 -2
View File
@@ -1,5 +1,4 @@
import type { Application, Container } from 'pixi.js';
import { Text } from 'pixi.js';
import { type Application, type Container, Text } from 'pixi.js';
import type { Editor } from './Editor';
import type { EditorState } from './state';
+19 -15
View File
@@ -5,12 +5,18 @@ import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { Menu, type Size, Theme, textStyle, Widget } from '../../ui';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
import { NodeView, portColor } from './NodeView';
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
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 centring the view – real nodes vary with their port count. */
const NODE_HEIGHT_ESTIMATE = 80;
interface PortAnchor {
nodeId: string;
@@ -63,7 +69,7 @@ export class GraphEditor extends Widget {
this.on('wheel', (e: FederatedWheelEvent) => {
const local = this.toLocal(e.global);
const before = this.screenToGraph(local.x, local.y);
this.zoom = Math.max(0.25, Math.min(2.5, this.zoom * Math.exp(-e.deltaY * 0.0015)));
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;
@@ -148,13 +154,13 @@ export class GraphEditor extends Widget {
this.needsFit = false;
const asset = this.asset();
if (!asset || asset.graph.nodes.length === 0) {
this.pan = { x: this.w / 2 - 80, y: this.h / 3 };
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
this.zoom = 1;
} else {
const xs = asset.graph.nodes.map((n) => n.x);
const ys = asset.graph.nodes.map((n) => n.y);
const cx = (Math.min(...xs) + Math.max(...xs) + 176) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + 80) / 2;
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
this.zoom = 1;
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
}
@@ -306,7 +312,7 @@ export class GraphEditor extends Widget {
const c = Theme.colors;
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
this.drawGrid();
this.syncGrid();
const asset = this.asset();
this.canvas.visible = asset !== null;
if (!asset) {
@@ -359,15 +365,13 @@ export class GraphEditor extends Widget {
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
}
private drawGrid(): void {
private syncGrid(): void {
this.grid.clear();
const step = GRID_STEP * this.zoom;
if (step < 8) return;
const startX = ((this.pan.x % step) + step) % step;
const startY = ((this.pan.y % step) + step) % step;
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.04 });
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
}
// --- test hooks -----------------------------------------------------------------------------
@@ -385,7 +389,7 @@ export class GraphEditor extends Widget {
nodeGlobal(nodeId: string): { x: number; y: number } | null {
const view = this.views.get(nodeId);
if (!view) return null;
const g = view.toGlobal({ x: view.w / 2, y: 12 });
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
return { x: g.x, y: g.y };
}
}
+5 -5
View File
@@ -7,6 +7,7 @@ const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'ac
export class NodePalette extends Popup {
private readonly search: TextField;
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
private filtered: NodeDef[] = [];
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
private picked = false;
@@ -25,10 +26,9 @@ export class NodePalette extends Popup {
},
});
this.content.add(this.search);
const scroll = new ScrollContainer();
scroll.minHeight = 260;
scroll.setContent(this.list);
this.content.add(scroll, 1);
this.scroll.minHeight = 260;
this.scroll.setContent(this.list);
this.content.add(this.scroll, 1);
this.list.rowHeight = Theme.rowHeight - 2;
this.list.emptyText = 'No matching node';
this.list.onSelect = (id) => {
@@ -50,7 +50,7 @@ export class NodePalette extends Popup {
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
(this.content.items[1] as ScrollContainer | undefined)?.relayoutContent();
this.scroll.relayoutContent();
}
private pick(def: NodeDef): void {
+5 -1
View File
@@ -6,7 +6,11 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
/** Node geometry in graph units; the editor relies on it for edge anchors and hit tests. */
/**
* Node geometry in graph units – what the node lays itself out, draws and hit-tests with.
* Exported as the single source of these numbers: edge anchors have to land exactly on the
* ports drawn here.
*/
export const NODE_WIDTH = 176;
export const TITLE_HEIGHT = 24;
export const ROW_HEIGHT = 20;
+2 -12
View File
@@ -1,6 +1,6 @@
import type { Container } from 'pixi.js';
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
import type { EditorState, Selection } from '../state';
import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
@@ -22,7 +22,6 @@ export class Inspector extends Panel {
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
this.minWidth = 220;
this.host = this.emptyHost();
this.scroll.setContent(this.host.body);
this.content.add(this.scroll, 1);
@@ -75,7 +74,7 @@ export class Inspector extends Panel {
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
private refreshValues(): void {
const s = this.state.selection;
if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
if (!sameSelection(s, this.builtFor) || this.currentSignature() !== this.signature) {
this.rebuild();
return;
}
@@ -87,12 +86,3 @@ export class Inspector extends Panel {
if (!ok) this.rebuild();
}
}
function sameTarget(a: Selection, b: Selection): boolean {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(',');
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+10 -20
View File
@@ -2,13 +2,13 @@ import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand
import { nextVariableName } from '../../core/commands/naming';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, TextField, Theme } from '../../ui';
import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
@@ -71,23 +71,13 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
/** Previews straight into the asset while editing; the command records the value from before the preview. */
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
let original: PropertyValue | undefined;
return {
onInput: (value) => {
const target = findAsset(host.state.project, assetId);
if (!target) return;
if (original === undefined) original = target.props[def.key];
target.props[def.key] = value;
host.state.preview();
},
onChange: (value) => {
const target = findAsset(host.state.project, assetId);
if (!target) return;
if (original !== undefined) target.props[def.key] = original;
original = undefined;
host.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
},
};
return previewingHandlers<Asset>(host, {
target: () => findAsset(host.state.project, assetId),
read: (asset) => asset.props[def.key],
write: (asset, value) => (asset.props[def.key] = value),
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value),
});
}
/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */
@@ -126,7 +116,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
);
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
if (!field.grow) row.add(new Spacer(), 1);
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
+5 -5
View File
@@ -1,6 +1,6 @@
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, TextField, Theme, ui, type Widget } from '../../ui';
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui';
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -21,10 +21,10 @@ export interface FieldRow {
}
/** Width of the label column every inspector row aligns to. */
export const FIELD_LABEL_WIDTH = 64;
const FIELD_LABEL_WIDTH = 64;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
export interface FieldAction {
interface FieldAction {
label: string;
onSelect: () => void;
}
@@ -58,7 +58,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
const { row, label } = labelledRow(def.label);
const field = createField(def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
if (!field.grow) row.add(new Spacer(), 1);
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
@@ -107,7 +107,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
};
}
export interface Field {
interface Field {
widget: Widget;
/** Whether the widget should take the remaining row width. */
grow: boolean;
+8 -25
View File
@@ -2,12 +2,12 @@ import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/grap
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import type { Asset } from '../../core/model/Asset';
import { isPlaceableType } from '../../core/model/AssetTypes';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Label } from '../../ui';
import { createFieldRow, type FieldHandlers } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
@@ -83,30 +83,13 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
/** Previews straight into the node's params; the command records the value from before the preview. */
function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers {
let original: PropertyValue | undefined;
let had = false;
return {
onInput: (value) => {
const target = currentNode(host.state.project, assetId, nodeId);
if (!target) return;
if (original === undefined) {
had = target.params[key] !== undefined;
original = target.params[key] ?? value;
}
target.params[key] = value;
host.state.preview();
},
onChange: (value) => {
const target = currentNode(host.state.project, assetId, nodeId);
if (!target) return;
if (original !== undefined) {
if (had) target.params[key] = original;
else delete target.params[key];
}
original = undefined;
host.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value));
},
};
return previewingHandlers<GraphNode>(host, {
target: () => currentNode(host.state.project, assetId, nodeId),
read: (node) => node.params[key],
write: (node, value) => (node.params[key] = value),
remove: (node) => delete node.params[key],
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
});
}
function currentNode(project: Project, assetId: string, nodeId: string): GraphNode | undefined {
+19 -45
View File
@@ -21,11 +21,12 @@ import type { Axis, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import { deviatesFromAsset, deviatesFromTemplate, isOverridden, resolveSceneObjectProps } from '../../core/properties/resolve';
import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z'];
const OFFSET_AXES = ['x', 'y'] as const;
@@ -180,32 +181,22 @@ interface ComponentSpec<V extends object> {
/**
* A number row editing one component of a vector on the object (position, editor offset).
* Scrubbing previews straight into the model; the command is built from the value before the
* preview so undo returns there, not to the last previewed frame.
* The row edits one axis, the command carries the whole vector: the other components come
* from the target after the preview was taken back, so they are the pre-preview ones.
*/
function componentRow<V extends object>(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec<V>): void {
let original: V | undefined;
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
{
onInput: (next) => {
const target = currentObject(host.state, sceneId, objectId);
if (!target) return;
if (!original) original = { ...spec.read(target) };
spec.write(target, withAxis(spec.read(target), next));
host.state.preview();
},
onChange: (next) => {
const target = currentObject(host.state, sceneId, objectId);
if (!target) return;
const previous = original ?? { ...spec.read(target) };
if (original) spec.write(target, { ...original });
original = undefined;
host.state.execute(spec.command(withAxis(previous, next)));
},
},
previewingHandlers<SceneObject, V>(host, {
target: () => currentObject(host.state, sceneId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }),
write: spec.write,
toModel: (next, object) => withAxis(spec.read(object), next),
commit: spec.command,
}),
spec.reset,
spec.apply,
);
@@ -224,34 +215,17 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
* the container is placed, never at draw time.
*/
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
let original: PropertyValue | undefined;
let hadOverride = false;
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = currentObject(host.state, sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
host.state.preview();
},
onChange: (value) => {
const target = currentObject(host.state, sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
previewingHandlers<SceneObject>(host, {
target: () => currentObject(host.state, sceneId, objectId),
read: (object) => object.overrides[def.key],
write: (object, value) => (object.overrides[def.key] = value),
remove: (object) => delete object.overrides[def.key],
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
}),
() => {
const { template, asset } = bases();
return [
+58
View File
@@ -0,0 +1,58 @@
import type { Command } from '../../core/commands/Command';
import type { PropertyValue } from '../../core/model/Properties';
import type { FieldHandlers } from './fieldFactory';
import type { InspectorHost } from './host';
/** Where an inspector row finds the value it edits, and what committing it means. */
export interface PreviewedValue<Target, Model = PropertyValue> {
/** The model object holding the value, or undefined when the selection is gone – then the row does nothing. */
target: () => Target | undefined;
/** The stored value; undefined means the key is not set on the target at all. */
read: (target: Target) => Model | undefined;
write: (target: Target, value: Model) => void;
/** Unsets the key again; needed wherever `read` can return undefined. */
remove?: (target: Target) => void;
/** The widget value as the model stores it (default: unchanged). Called on the target as it is at that moment. */
toModel?: (input: PropertyValue, target: Target) => Model;
commit: (value: Model) => Command;
}
/**
* Row handlers that preview into the model while editing and take that preview back before
* committing.
*
* Scrubbing has to be visible at once, so the value goes straight into the model and is only
* redrawn (`preview`), without an undo entry. A command records the value it finds at execute
* time, so committing right away would make the last previewed frame the undo target: the
* value from before the preview is written back first – including "the key did not exist",
* via `remove` – and only then is the command executed.
*/
export function previewingHandlers<Target, Model = PropertyValue>(host: InspectorHost, spec: PreviewedValue<Target, Model>): FieldHandlers {
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
let previewed = false;
let before: Model | undefined;
return {
onInput: (input) => {
const target = spec.target();
if (!target) return;
if (!previewed) {
previewed = true;
before = spec.read(target);
}
spec.write(target, toModel(input, target));
host.state.preview();
},
onChange: (input) => {
const target = spec.target();
if (!target) return;
if (previewed) {
if (before === undefined) spec.remove?.(target);
else spec.write(target, before);
}
previewed = false;
before = undefined;
// Built after the restore: `toModel` may fill the rest of a compound value from the target.
host.state.execute(spec.commit(toModel(input, target)));
},
};
}
+5 -22
View File
@@ -1,10 +1,11 @@
import { CreateAssetCommand, DeleteAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { findAsset } from '../../core/model/Project';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
/** Lists all assets of the project; creates, places and deletes them. */
@@ -84,25 +85,7 @@ export class AssetPanel extends Panel {
} else {
items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene });
}
items.push({ label: 'Delete…', danger: true, onSelect: () => this.confirmDelete(id) });
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
}
private confirmDelete(id: string): void {
const asset = findAsset(this.state.project, id);
if (!asset) return;
const usages = findUsages(this.state.project, id).length;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: asset.type === 'container' && usages > 0
? `${usages} placed container(s) are removed from scenes together with their content.`
: usages > 0
? `${usages} placed instance(s) are removed from scenes as well.`
: 'This cannot be undone with Ctrl+Z after reload.';
new Dialog(`Delete "${asset.name}"?`, detail, [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(id)) },
]).openCentered();
}
}
+3 -13
View File
@@ -1,8 +1,6 @@
import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { findAsset } from '../core/model/Project';
import { Dialog, ui } from '../ui';
import { createContainer, removeObjects } from './actions';
import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeObjects } from './actions';
import type { EditorState } from './state';
export interface ShortcutActions {
@@ -40,15 +38,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'asset') {
const asset = findAsset(state.project, s.assetId);
if (asset) {
new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(s.assetId)) },
]).openCentered();
}
}
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
if (mod && event.key === 'Enter' && state.mode === 'edit') {
+23 -12
View File
@@ -136,8 +136,28 @@ export class EditorState {
* In play mode the command changes the runtime copy only (see `enterPlay`).
*/
execute(command: Command): boolean {
try {
return this.runEdit(() => {
this.commands.execute(command);
return true;
});
}
undo(): void {
this.runEdit(() => this.commands.undo());
}
redo(): void {
this.runEdit(() => this.commands.redo());
}
/**
* Runs one history operation. A `CommandError` is the documented way for a command to
* reject an edit, so it becomes a notice; anything else is a real defect and keeps
* travelling. Undo and redo go through here too – they can reject just like `execute`.
*/
private runEdit(operation: () => boolean): boolean {
try {
if (!operation()) return false;
} catch (error) {
if (error instanceof CommandError) {
this.setNotice(error.message);
@@ -149,16 +169,6 @@ export class EditorState {
return true;
}
undo(): void {
if (!this.commands.undo()) return;
this.markDirty();
}
redo(): void {
if (!this.commands.redo()) return;
this.markDirty();
}
/**
* The model was mutated directly (live preview while scrubbing). Notifies listeners
* but does not touch the undo stack or the save status – the committing command does.
@@ -246,7 +256,8 @@ export class EditorState {
}
}
function sameSelection(a: Selection, b: Selection): boolean {
/** Whether two selections mean the same thing (same kind, same ids, same order). */
export function sameSelection(a: Selection, b: Selection): boolean {
if (a === b) return true;
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;