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

+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)));
},
};
}