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
@@ -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)));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user