import type { PropertyValue } from '../model/Properties'; import { findUsages, type Project } from '../model/Project'; import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireBehavior } from './require'; /** Declares a new variable on a behavior; its default value is written to `props`. */ 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; value: PropertyValue | undefined }; private overrides: { holderId: string; objectId: 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 { holder, object } of findUsages(project, this.assetId)) { const value = object.overrides[this.key]; if (value === undefined) continue; this.overrides.push({ holderId: holder.id, objectId: object.id, value }); delete object.overrides[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 usages = findUsages(project, this.assetId); for (const o of this.overrides) { const object = usages.find((u) => u.holder.id === o.holderId && u.object.id === o.objectId)?.object; if (object) object.overrides[this.key] = o.value; } } } /** * Renames a variable everywhere: definition, default, instance overrides (in scenes and in * container templates) and 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 { object } of findUsages(project, this.assetId)) { if (object.overrides[from] === undefined) continue; object.overrides[to] = object.overrides[from]!; delete object.overrides[from]; } // Every node that picks a variable stores it under the param key `variable`. for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to; } } const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; /** Identifier-like, and not one of the common property keys (a variable "enabled" would shadow the switch). */ export function isValidVariableName(name: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && !isCommonProp(name); }