import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible, } from '../graph/Graph'; import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { nodeDef } from '../graph/NodeRegistry'; import { behaviorLookup } from '../model/behaviorPrograms'; import { newId } from '../ids'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import type { Project } from '../model/Project'; import { coerceValue, defaultsOf } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireBehavior, requireNode, requireNodeDef } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export class AddNodeCommand implements Command { readonly label: string; readonly node: GraphNode; constructor( private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}, ) { // An unknown type is rejected in `execute`: constructors run outside the editor's error // handling, so throwing here would crash instead of showing a notice. const def = nodeDef(type); this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params }, }; this.label = def ? `Add ${def.label}` : `Add ${type}`; } execute(project: Project): void { requireNodeDef(this.node.type); const graph = requireBehavior(project, this.assetId).graph; // The two terminals give the behavior its signature; a second one of either would make // "which ports does this behavior have" ambiguous. if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type)) throw new CommandError(`This behavior already has a ${nodeDef(this.node.type)?.label} node`); graph.nodes.push(this.node); } undo(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; graph.nodes = removeById(graph.nodes, this.node.id); } } /** Removes a node and every edge touching it. */ export class RemoveNodeCommand implements Command { readonly label = 'Remove node'; private removed?: { node: Removed; edges: Removed[] }; constructor( private readonly assetId: string, private readonly nodeId: string, ) {} execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId); if (!node) throw new CommandError(`Node ${this.nodeId} not found`); // `edgesOfNode` reads the edge list only, so it still finds them after the node is gone. const touching = edgesOfNode(graph, this.nodeId); this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) }; } undo(project: Project): void { if (!this.removed) return; const graph = requireBehavior(project, this.assetId).graph; restoreAt(graph.nodes, [this.removed.node]); restoreAt(graph.edges, this.removed.edges); } } /** * The previous position is read at execution time, so a caller that previewed the drag must restore * the node before executing. */ export class MoveNodeCommand implements Command { readonly label = 'Move node'; private previous = { x: 0, y: 0 }; constructor( private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number, ) {} execute(project: Project): void { const node = requireNode(project, this.assetId, this.nodeId); this.previous = { x: node.x, y: node.y }; node.x = Math.round(this.x); node.y = Math.round(this.y); } undo(project: Project): void { const node = requireNode(project, this.assetId, this.nodeId); node.x = this.previous.x; node.y = this.previous.y; } } /** * Sets a node param or the inline value of an unconnected data input. A param can decide how many * ports the node has, so this takes the edges of ports the new value does away with: an edge on a * port that is gone draws as nothing but survives every save, and comes back to life if the port * ever returns. */ export class SetNodeParamCommand extends SetBagValueCommand { private orphans: Removed[] = []; constructor( private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue, ) { super(key, `Set ${key}`); } override execute(project: Project): void { super.execute(project); const graph = requireBehavior(project, this.assetId).graph; const node = requireNode(project, this.assetId, this.nodeId); const def = resolveDef(node, graph, behaviorLookup(project)); if (!def) return; const outputs = new Set(def.outputs.map((p) => p.key)); const inputs = new Set(def.inputs.map((p) => p.key)); this.orphans = removeWhere( graph.edges, (e) => (e.from.node === this.nodeId && !outputs.has(e.from.port)) || (e.to.node === this.nodeId && !inputs.has(e.to.port)), ); } override undo(project: Project): void { super.undo(project); restoreAt(requireBehavior(project, this.assetId).graph.edges, this.orphans); } protected override resolveTarget(project: Project): GraphNode { return requireNode(project, this.assetId, this.nodeId); } protected override bagOf(node: GraphNode): PropertyBag { return node.params; } protected override computeNext(node: GraphNode): PropertyValue { const param = requireNodeDef(node.type).params.find((p) => p.key === this.key); // Inline input values are stored as-is; declared params are coerced against their definition. return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value; } } /** A data input holds at most one edge, so an existing one is replaced (and restored on undo). */ export class ConnectCommand implements Command { readonly label = 'Connect'; readonly edge: GraphEdge; private replaced: Removed[] = []; constructor( private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }, ) { this.edge = { id: newId(), from: { ...from }, to: { ...to } }; } execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; const fromNode = findNode(graph, this.edge.from.node); const toNode = findNode(graph, this.edge.to.node); if (!fromNode || !toNode) throw new CommandError('Node not found'); if (fromNode === toNode) throw new CommandError('A node cannot connect to itself'); const lookup = behaviorLookup(project); const out = resolveDef(fromNode, graph, lookup)?.outputs.find((p) => p.key === this.edge.from.port); const inp = resolveDef(toNode, graph, lookup)?.inputs.find((p) => p.key === this.edge.to.port); if (!out || !inp) throw new CommandError('Port not found'); if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected'); const occupied = inp.type === 'exec' ? [] : edgesInto(graph, this.edge.to.node, this.edge.to.port); this.replaced = removeWhere(graph.edges, (e) => occupied.includes(e)); graph.edges.push(this.edge); } undo(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; graph.edges = removeById(graph.edges, this.edge.id); restoreAt(graph.edges, this.replaced); } } /** Folding is drawing only: the edge keeps carrying its value either way. */ export class FoldEdgeCommand implements Command { readonly label: string; private previous = false; constructor( private readonly assetId: string, private readonly edgeId: string, private readonly folded: boolean, ) { this.label = folded ? 'Hide link' : 'Show link'; } execute(project: Project): void { const edge = this.edge(project); this.previous = edge.folded === true; this.apply(edge, this.folded); } undo(project: Project): void { this.apply(this.edge(project), this.previous); } private edge(project: Project): GraphEdge { const edge = requireBehavior(project, this.assetId).graph.edges.find((e) => e.id === this.edgeId); if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`); return edge; } private apply(edge: GraphEdge, folded: boolean): void { if (folded) edge.folded = true; else delete edge.folded; } } export class DisconnectCommand implements Command { readonly label = 'Disconnect'; private removed: Removed[] = []; constructor( private readonly assetId: string, private readonly edgeId: string, ) {} execute(project: Project): void { const graph = requireBehavior(project, this.assetId).graph; this.removed = removeWhere(graph.edges, (e) => e.id === this.edgeId); if (this.removed.length === 0) throw new CommandError(`Edge ${this.edgeId} not found`); } undo(project: Project): void { restoreAt(requireBehavior(project, this.assetId).graph.edges, this.removed); } } function sameEnds(a: GraphEdge, b: GraphEdge): boolean { return ( a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port ); }