Add ESLint and Prettier, and close the node test gaps
Tooling: - Prettier as the single formatting authority (110 columns, single quotes, measured against the existing style); Markdown excluded because table-cell padding turns the README glossary into 1000-character lines. - Type-aware ESLint ending in eslint-config-prettier, so a rule always means "this is a bug", never "this looks different". - .editorconfig and an `npm run check` that runs typecheck, lint, format and tests in one go. Findings fixed on the first run: three more duplicate imports, four String(unknown) calls (parameter types narrowed instead of casts), two dead initialisers, two redundant casts, a console.log for a real error. Two warnings were false here and are disabled with a reason in place. Tests 132 -> 269: - node-catalog.test.ts covers every node with a run(), with a guard that fails when a new node has no row. - behavior-events.test.ts covers the ten event nodes through a running World, checking timing rather than existence. - Input's state machine (press/release/releaseAll) is reachable without DOM events now; the DOM handlers are thin adapters. Compare fell back to == without a configured operator although the schema default is <; aligned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f140c2fd77
commit
d54a620ebd
113 files changed
+5049
-666
No files matched your search
@@ -4,7 +4,12 @@ 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';
|
||||
import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection';
|
||||
import {
|
||||
buildObjectSection,
|
||||
buildObjectsSection,
|
||||
objectSignature,
|
||||
refreshObjectSection,
|
||||
} from './objectSection';
|
||||
|
||||
/**
|
||||
* Shows the properties of the current selection. The rows are generated from the
|
||||
@@ -33,7 +38,12 @@ export class Inspector extends Panel {
|
||||
}
|
||||
|
||||
private emptyHost(): InspectorHost {
|
||||
return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null };
|
||||
return {
|
||||
state: this.state,
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,9 +90,11 @@ export class Inspector extends Panel {
|
||||
}
|
||||
if (!s) return;
|
||||
const ok =
|
||||
s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId)
|
||||
: s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
|
||||
s.kind === 'asset'
|
||||
? refreshAssetSection(this.host, s.assetId)
|
||||
: s.kind === 'node'
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,50 @@
|
||||
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import {
|
||||
RenameAssetCommand,
|
||||
ResetAssetPropertyCommand,
|
||||
SetAssetPropertyCommand,
|
||||
} from '../../core/commands/assetCommands';
|
||||
import { nextVariableName } from '../../core/commands/naming';
|
||||
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
|
||||
import {
|
||||
AddVariableCommand,
|
||||
RemoveVariableCommand,
|
||||
RenameVariableCommand,
|
||||
} from '../../core/commands/variableCommands';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
|
||||
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, 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' };
|
||||
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
};
|
||||
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
|
||||
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
|
||||
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
text: 'Aa',
|
||||
};
|
||||
|
||||
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
|
||||
export function assetSignature(project: Project, assetId: string): string {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
|
||||
return asset
|
||||
? schemaForAsset(asset)
|
||||
.map((d) => `${d.key}:${d.kind}`)
|
||||
.join(',')
|
||||
: 'missing';
|
||||
}
|
||||
|
||||
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
|
||||
@@ -58,11 +86,21 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
|
||||
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
|
||||
const values = resolveAssetProps(asset);
|
||||
for (const def of schema) {
|
||||
const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
if (!target || !isModified(target, def.key)) return [];
|
||||
return [{ label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)) }];
|
||||
});
|
||||
const row = createFieldRow(
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
assetHandlers(host, asset.id, def),
|
||||
() => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
if (!target || !isModified(target, def.key)) return [];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to default',
|
||||
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
row.setModified(isModified(asset, def.key));
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
@@ -90,7 +128,8 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
label: VARIABLE_KIND_LABELS[kind],
|
||||
onSelect: () => {
|
||||
const current = findAsset(host.state.project, asset.id);
|
||||
if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||
if (current?.type === 'behavior')
|
||||
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
|
||||
},
|
||||
}));
|
||||
new Menu(items).openBelow(add);
|
||||
@@ -103,7 +142,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
for (const def of asset.variables) {
|
||||
const row = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
row.minHeight = Theme.rowHeight;
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
|
||||
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
|
||||
mark.minWidth = 18;
|
||||
row.add(mark);
|
||||
row.add(
|
||||
@@ -117,7 +156,13 @@ 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 Spacer(), 1);
|
||||
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
|
||||
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,19 @@
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, 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. */
|
||||
@@ -54,7 +67,13 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
|
||||
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
|
||||
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
|
||||
*/
|
||||
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
|
||||
export function createFieldRow(
|
||||
def: PropertyDef,
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
reset?: FieldActions,
|
||||
apply?: FieldActions,
|
||||
): FieldRow {
|
||||
const { row, label } = labelledRow(def.label);
|
||||
const field = createField(def, value, handlers);
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
@@ -141,10 +160,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
|
||||
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
|
||||
}
|
||||
case 'enum': {
|
||||
const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
|
||||
const labelOf = (v: PropertyValue) =>
|
||||
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
|
||||
const button = new Button(`${labelOf(value)} ▾`);
|
||||
button.onClick = () => {
|
||||
new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button);
|
||||
new Menu(
|
||||
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
|
||||
).openBelow(button);
|
||||
};
|
||||
return { widget: button, grow: true, setValue: (v) => button.setText(`${labelOf(v)} ▾`) };
|
||||
}
|
||||
@@ -160,7 +182,10 @@ class KeyCaptureButton extends Button {
|
||||
private key: string;
|
||||
private capturing = false;
|
||||
|
||||
constructor(key: string, private readonly onCapture: (key: string) => void) {
|
||||
constructor(
|
||||
key: string,
|
||||
private readonly onCapture: (key: string) => void,
|
||||
) {
|
||||
super(keyLabel(key));
|
||||
this.key = key;
|
||||
this.onClick = () => {
|
||||
|
||||
@@ -16,7 +16,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
const node = asset.graph.nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return 'missing';
|
||||
const def = nodeDef(node.type);
|
||||
const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
|
||||
const connected = def
|
||||
? inlineInputs(def)
|
||||
.filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0)
|
||||
.map((p) => p.key)
|
||||
: [];
|
||||
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
|
||||
}
|
||||
|
||||
@@ -35,7 +39,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) {
|
||||
const effective = resolveParamDef(host.state.project, param, asset);
|
||||
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key));
|
||||
const row = createFieldRow(
|
||||
effective,
|
||||
node.params[param.key] ?? effective.default,
|
||||
paramHandlers(host, assetId, nodeId, param.key),
|
||||
);
|
||||
host.fields.set(`param:${param.key}`, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
@@ -44,13 +52,22 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
|
||||
for (const port of free) {
|
||||
const inline = inlineInputDef(port);
|
||||
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key));
|
||||
const row = createFieldRow(
|
||||
inline,
|
||||
node.params[port.key] ?? inline.default,
|
||||
paramHandlers(host, assetId, nodeId, port.key),
|
||||
);
|
||||
host.fields.set(`input:${port.key}`, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
addGap(host);
|
||||
host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
|
||||
host.body.add(
|
||||
new Button('Delete node', {
|
||||
variant: 'danger',
|
||||
onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
|
||||
@@ -60,7 +77,10 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
|
||||
if (!node || !def) return false;
|
||||
for (const [key, row] of host.fields) {
|
||||
const [scope, name] = key.split(':') as ['param' | 'input', string];
|
||||
const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
|
||||
const fallback =
|
||||
scope === 'param'
|
||||
? def.params.find((p) => p.key === name)?.default
|
||||
: inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
|
||||
row.setValue(node.params[name] ?? fallback ?? '');
|
||||
}
|
||||
return true;
|
||||
@@ -76,7 +96,12 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
|
||||
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
|
||||
}
|
||||
if (param.key === 'asset') {
|
||||
return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) };
|
||||
return {
|
||||
...param,
|
||||
options: project.assets
|
||||
.filter((a) => isPlaceableType(a.type))
|
||||
.map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })),
|
||||
};
|
||||
}
|
||||
return param;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,17 @@ import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierar
|
||||
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, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
isCommonProp,
|
||||
type PropertyDef,
|
||||
schemaForAsset,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
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';
|
||||
@@ -34,13 +43,23 @@ const OFFSET_AXES = ['x', 'y'] as const;
|
||||
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
|
||||
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string {
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
sceneId: string,
|
||||
objectIds: readonly string[],
|
||||
objectId: string,
|
||||
): string {
|
||||
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
|
||||
const scene = findScene(project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || !asset) return 'missing';
|
||||
return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',');
|
||||
return (
|
||||
`${object.parentId}|${object.templateId}|` +
|
||||
schemaForAsset(asset)
|
||||
.map((d) => `${d.key}:${d.kind}`)
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
@@ -57,11 +76,18 @@ export function buildObjectsSection(host: InspectorHost, sceneId: string, object
|
||||
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
|
||||
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }));
|
||||
host.body.add(
|
||||
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
|
||||
);
|
||||
addNote(host, 'Groups the selection into a reusable container', 'small');
|
||||
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
|
||||
addGap(host);
|
||||
host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) }));
|
||||
host.body.add(
|
||||
new Button('Remove from scene', {
|
||||
variant: 'danger',
|
||||
onClick: () => removeObjects(host.state, sceneId, objectIds),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
|
||||
@@ -81,7 +107,13 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
host.body.add(nameRow.row);
|
||||
|
||||
const assetRow = labelledRow('Asset');
|
||||
assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
|
||||
assetRow.row.add(
|
||||
new Button(asset.name, {
|
||||
variant: 'ghost',
|
||||
onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}),
|
||||
1,
|
||||
);
|
||||
host.body.add(assetRow.row);
|
||||
|
||||
host.body.add(parentRow(host, scene, object));
|
||||
@@ -90,12 +122,19 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
const link = templateLinkOf(host.state.project, scene, object);
|
||||
if (link) {
|
||||
const linkRow = labelledRow('Part of');
|
||||
linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
|
||||
linkRow.row.add(
|
||||
new Button(link.asset.name, {
|
||||
variant: 'ghost',
|
||||
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
|
||||
}),
|
||||
1,
|
||||
);
|
||||
host.body.add(linkRow.row);
|
||||
}
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
|
||||
for (const def of COMMON_PROPS)
|
||||
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
|
||||
|
||||
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
|
||||
for (const axis of POSITION_AXES) {
|
||||
@@ -107,8 +146,26 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
read: (o) => o.position,
|
||||
write: (o, v) => (o.position = v),
|
||||
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
|
||||
reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []),
|
||||
apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []),
|
||||
reset: () =>
|
||||
moved()
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
apply: () =>
|
||||
moved()
|
||||
? [
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,7 +183,15 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
reset: () => {
|
||||
const current = currentObject(host.state, sceneId, objectId);
|
||||
if (!current || current.editorOffset[axis] === 0) return [];
|
||||
return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to 0',
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -135,7 +200,8 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
const schema = schemaForAsset(asset);
|
||||
const values = resolveSceneObjectProps(asset, object);
|
||||
const own = schema.filter((def) => !isCommonProp(def.key));
|
||||
if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
|
||||
markObjectRows(host, object, asset, link);
|
||||
@@ -149,7 +215,12 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
|
||||
if (asset.type !== 'collider') {
|
||||
addGap(host);
|
||||
host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) }));
|
||||
host.body.add(
|
||||
new Button('+ Add collider', {
|
||||
variant: 'ghost',
|
||||
onClick: () => addCollider(host.state, sceneId, objectId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +255,13 @@ interface ComponentSpec<V extends object> {
|
||||
* 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 {
|
||||
function componentRow<V extends object>(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
value: number,
|
||||
spec: ComponentSpec<V>,
|
||||
): void {
|
||||
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 },
|
||||
@@ -214,7 +291,13 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
|
||||
* override is what makes the object follow the asset again: the template is only read when
|
||||
* the container is placed, never at draw time.
|
||||
*/
|
||||
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
def: PropertyDef,
|
||||
values: Record<string, PropertyValue>,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, sceneId, objectId, def.key);
|
||||
const row = createFieldRow(
|
||||
def,
|
||||
@@ -229,15 +312,46 @@ function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def
|
||||
() => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
|
||||
...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Reset to asset',
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
() => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
|
||||
...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
|
||||
...(template
|
||||
? [
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(asset
|
||||
? [
|
||||
{
|
||||
label: 'Apply to asset',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
);
|
||||
@@ -256,11 +370,16 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
|
||||
const parent = findObject(scene, object.parentId);
|
||||
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
|
||||
button.onClick = () => {
|
||||
const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }];
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
|
||||
];
|
||||
for (const { object: candidate, depth } of treeOrder(scene)) {
|
||||
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
|
||||
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
|
||||
items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) });
|
||||
items.push({
|
||||
label: candidate.id === object.parentId ? `● ${name}` : name,
|
||||
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
|
||||
});
|
||||
}
|
||||
new Menu(items).openBelow(button);
|
||||
};
|
||||
@@ -273,11 +392,22 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
|
||||
* the template (position per axis, overrides), for any other object one from the asset. The
|
||||
* editor offset has one base only – no offset at all.
|
||||
*/
|
||||
function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
|
||||
for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
|
||||
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
|
||||
function markObjectRows(
|
||||
host: InspectorHost,
|
||||
object: SceneObject,
|
||||
asset: Asset,
|
||||
link: TemplateLink | undefined,
|
||||
): void {
|
||||
for (const axis of POSITION_AXES)
|
||||
host.fields
|
||||
.get(axis)
|
||||
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
|
||||
for (const axis of OFFSET_AXES)
|
||||
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
|
||||
const deviates = link
|
||||
? deviatesFromTemplate(object, link.template, def.key)
|
||||
: deviatesFromAsset(object, asset, def.key);
|
||||
host.fields.get(def.key)?.setModified(deviates);
|
||||
}
|
||||
}
|
||||
@@ -287,7 +417,12 @@ function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset,
|
||||
* differs: the template of the container the object belongs to, and the asset behind it.
|
||||
* Evaluated on demand so a menu never acts on a stale snapshot.
|
||||
*/
|
||||
function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
|
||||
function deviations(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
key: string,
|
||||
): { template: boolean; asset: boolean } {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
@@ -300,14 +435,23 @@ function deviations(state: InspectorHost['state'], sceneId: string, objectId: st
|
||||
}
|
||||
|
||||
/** Whether the object sits somewhere else on `axis` than its container template says. */
|
||||
function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean {
|
||||
function positionMoved(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
axis: Axis,
|
||||
): boolean {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const link = scene && object && templateLinkOf(state.project, scene, object);
|
||||
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
|
||||
}
|
||||
|
||||
function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined {
|
||||
function currentObject(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
objectId: string,
|
||||
): SceneObject | undefined {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
return scene && findObject(scene, objectId);
|
||||
}
|
||||
@@ -27,7 +27,10 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
* 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 {
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user