A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import {
|
|
type FieldValue,
|
|
isStruct,
|
|
type PropertyBag,
|
|
type PropertyValue,
|
|
type StructValue,
|
|
} from '../model/Properties';
|
|
import { overrideSites } from '../model/instance';
|
|
import type { Project } from '../model/Project';
|
|
import type { VariableKind } from '../properties/kinds';
|
|
import {
|
|
emptyFieldValue,
|
|
isValidFieldName,
|
|
isValidVariableName,
|
|
type PropertyDef,
|
|
type StructFieldDef,
|
|
variableDef,
|
|
} from '../properties/PropertySchema';
|
|
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
|
import { type Command, CommandError } from './Command';
|
|
import { renameVariablePorts } from './portCommands';
|
|
import { requireBehavior } from './require';
|
|
|
|
/** The variable's default value is written to `props` as well. */
|
|
export class AddVariableCommand implements Command {
|
|
readonly label: string;
|
|
readonly def: PropertyDef;
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
key: string,
|
|
kind: VariableKind,
|
|
) {
|
|
this.def = variableDef(key, kind);
|
|
this.label = `Add variable ${key}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
const asset = requireBehavior(project, this.assetId);
|
|
if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME);
|
|
if (asset.variables.some((v) => v.key === this.def.key))
|
|
throw new CommandError(`Variable "${this.def.key}" already exists`);
|
|
asset.variables.push(this.def);
|
|
asset.props[this.def.key] = this.def.default;
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
const asset = requireBehavior(project, this.assetId);
|
|
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
|
|
delete asset.props[this.def.key];
|
|
}
|
|
}
|
|
|
|
/** Removes a variable together with its default and every instance override. */
|
|
export class RemoveVariableCommand implements Command {
|
|
readonly label: string;
|
|
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
|
|
private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
private readonly key: string,
|
|
) {
|
|
this.label = `Remove variable ${key}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
const asset = requireBehavior(project, this.assetId);
|
|
const def = removeFirst(asset.variables, (v) => v.key === this.key);
|
|
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
|
this.removed = { def, value: asset.props[this.key] };
|
|
delete asset.props[this.key];
|
|
this.overrides = [];
|
|
for (const site of overrideSites(project, (id) => id === this.assetId)) {
|
|
const value = site.bag[this.key];
|
|
if (value === undefined) continue;
|
|
this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
|
|
delete site.bag[this.key];
|
|
}
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
if (!this.removed) return;
|
|
const asset = requireBehavior(project, this.assetId);
|
|
restoreAt(asset.variables, [this.removed.def]);
|
|
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
|
const sites = overrideSites(project, (id) => id === this.assetId);
|
|
for (const o of this.overrides) {
|
|
const site = sites.find(
|
|
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
|
|
);
|
|
if (site) site.bag[this.key] = o.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */
|
|
export class RenameVariableCommand implements Command {
|
|
readonly label: string;
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
private readonly from: string,
|
|
private readonly to: string,
|
|
) {
|
|
this.label = `Rename variable ${from}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
this.rename(project, this.from, this.to);
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
this.rename(project, this.to, this.from);
|
|
}
|
|
|
|
private rename(project: Project, from: string, to: string): void {
|
|
const asset = requireBehavior(project, this.assetId);
|
|
if (from === to) return;
|
|
if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME);
|
|
const def = asset.variables.find((v) => v.key === from);
|
|
if (!def) throw new CommandError(`Variable "${from}" not found`);
|
|
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
|
|
def.key = to;
|
|
def.label = to;
|
|
if (asset.props[from] !== undefined) {
|
|
asset.props[to] = asset.props[from]!;
|
|
delete asset.props[from];
|
|
}
|
|
for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
|
|
if (bag[from] === undefined) continue;
|
|
bag[to] = bag[from]!;
|
|
delete bag[from];
|
|
}
|
|
renameVariablePorts(project, this.assetId, from, to);
|
|
}
|
|
}
|
|
|
|
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
|
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
|
|
|
/** Where declared fields live: on a struct variable. A list item carries its own shape instead. */
|
|
interface FieldOwner {
|
|
fields(): readonly StructFieldDef[];
|
|
setFields(next: readonly StructFieldDef[]): void;
|
|
}
|
|
|
|
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
|
|
return requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
|
}
|
|
|
|
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
|
const def = variableOf(project, assetId, key);
|
|
if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`);
|
|
return { fields: () => def.fields, setFields: (next) => (def.fields = next) };
|
|
}
|
|
|
|
/**
|
|
* One struct-carrying value of a variable (its declared default, the behavior's own, an override); a
|
|
* field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo.
|
|
*/
|
|
interface StructSite {
|
|
id: string;
|
|
read(): StructValue;
|
|
write(value: StructValue): void;
|
|
}
|
|
|
|
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
|
return (
|
|
valueHolders(project, assetId, key)
|
|
// An override the object does not have stays absent: writing one here would invent a deviation.
|
|
.filter((h) => !h.optional || isStruct(h.read()))
|
|
.map((holder) => ({
|
|
id: holder.id,
|
|
read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}),
|
|
write: (value: StructValue) => holder.write(value),
|
|
}))
|
|
);
|
|
}
|
|
|
|
/** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */
|
|
interface ValueHolder {
|
|
id: string;
|
|
/** An override may simply not be there; the declared default and the behavior's own always are. */
|
|
optional: boolean;
|
|
read(): PropertyValue | undefined;
|
|
write(value: PropertyValue): void;
|
|
}
|
|
|
|
function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] {
|
|
const asset = requireBehavior(project, assetId);
|
|
const def = asset.variables.find((v) => v.key === key);
|
|
const holders: ValueHolder[] = [];
|
|
if (def)
|
|
holders.push({
|
|
id: 'declared',
|
|
optional: false,
|
|
read: () => def.default,
|
|
write: (value) => {
|
|
def.default = value;
|
|
},
|
|
});
|
|
holders.push(bagHolder('asset', false, asset.props, key));
|
|
for (const site of overrideSites(project, (id) => id === assetId))
|
|
holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key));
|
|
return holders;
|
|
}
|
|
|
|
function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder {
|
|
return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) };
|
|
}
|
|
|
|
function withoutField(value: StructValue, key: string): StructValue {
|
|
const next: Record<string, FieldValue> = { ...value };
|
|
delete next[key];
|
|
return next;
|
|
}
|
|
|
|
/** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */
|
|
function renameField(value: StructValue, from: string, to: string): StructValue {
|
|
const next: Record<string, FieldValue> = {};
|
|
for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item;
|
|
return next;
|
|
}
|
|
|
|
export class AddStructFieldCommand implements Command {
|
|
readonly label: string;
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
private readonly key: string,
|
|
private readonly field: StructFieldDef,
|
|
) {
|
|
this.label = `Add field ${field.key}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
const owner = requireFieldOwner(project, this.assetId, this.key);
|
|
if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD);
|
|
if (owner.fields().some((f) => f.key === this.field.key))
|
|
throw new CommandError(`Field "${this.field.key}" already exists`);
|
|
owner.setFields([...owner.fields(), { ...this.field }]);
|
|
for (const site of structSites(project, this.assetId, this.key))
|
|
site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) });
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
const owner = requireFieldOwner(project, this.assetId, this.key);
|
|
owner.setFields(owner.fields().filter((f) => f.key !== this.field.key));
|
|
for (const site of structSites(project, this.assetId, this.key))
|
|
site.write(withoutField(site.read(), this.field.key));
|
|
}
|
|
}
|
|
|
|
/** Takes the field's value out of every struct that carries it, and gives each one back on undo. */
|
|
export class RemoveStructFieldCommand implements Command {
|
|
readonly label: string;
|
|
private removed?: { field: Removed<StructFieldDef>; values: Map<string, FieldValue> };
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
private readonly key: string,
|
|
private readonly field: string,
|
|
) {
|
|
this.label = `Remove field ${field}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
const owner = requireFieldOwner(project, this.assetId, this.key);
|
|
const fields = [...owner.fields()];
|
|
const field = removeFirst(fields, (f) => f.key === this.field);
|
|
if (!field) throw new CommandError(`Field "${this.field}" not found`);
|
|
const values = new Map<string, FieldValue>();
|
|
for (const site of structSites(project, this.assetId, this.key)) {
|
|
const value = site.read();
|
|
const item = value[this.field];
|
|
if (item !== undefined) values.set(site.id, item);
|
|
site.write(withoutField(value, this.field));
|
|
}
|
|
owner.setFields(fields);
|
|
this.removed = { field, values };
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
if (!this.removed) return;
|
|
const owner = requireFieldOwner(project, this.assetId, this.key);
|
|
const fields = [...owner.fields()];
|
|
restoreAt(fields, [this.removed.field]);
|
|
owner.setFields(fields);
|
|
for (const site of structSites(project, this.assetId, this.key)) {
|
|
const value = this.removed.values.get(site.id);
|
|
if (value !== undefined) site.write({ ...site.read(), [this.field]: value });
|
|
}
|
|
}
|
|
}
|
|
|
|
export class RenameStructFieldCommand implements Command {
|
|
readonly label: string;
|
|
|
|
constructor(
|
|
private readonly assetId: string,
|
|
private readonly key: string,
|
|
private readonly from: string,
|
|
private readonly to: string,
|
|
) {
|
|
this.label = `Rename field ${from}`;
|
|
}
|
|
|
|
execute(project: Project): void {
|
|
this.rename(project, this.from, this.to);
|
|
}
|
|
|
|
undo(project: Project): void {
|
|
this.rename(project, this.to, this.from);
|
|
}
|
|
|
|
private rename(project: Project, from: string, to: string): void {
|
|
const owner = requireFieldOwner(project, this.assetId, this.key);
|
|
if (from === to) return;
|
|
if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD);
|
|
const fields = owner.fields();
|
|
if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`);
|
|
if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`);
|
|
owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f)));
|
|
for (const site of structSites(project, this.assetId, this.key))
|
|
site.write(renameField(site.read(), from, to));
|
|
}
|
|
}
|