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:
1 parent
1eb28314cf
commit
f140c2fd77
58 files changed
+1969
-1171
No files matched your search
+48
-172
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user