import { edgesInto, edgesOfNode, findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import type { GraphLookup } from '../graph/lookup'; import { isTerminalType, resolveDef, terminalNode } from '../graph/nested'; import { nextTransitionName, parallelTransitions } from '../graph/stateMachine'; import { nodeDef, portsMeet, takesManyEdges } from '../graph/NodeRegistry'; import { graphLookup } from '../model/graphLookup'; 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 { requireEdge, requireGraph, requireNode, requireNodeDef } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export class AddNodeCommand implements Command { readonly label: string; readonly node: GraphNode; /** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */ readonly closing: GraphNode | null; 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 }, }; const opens = def?.opens; this.closing = opens ? { id: newId(), type: opens.type, x: this.node.x + opens.dx, y: this.node.y, params: defaultsOf(nodeDef(opens.type)?.params ?? []), } : null; this.label = def ? `Add ${def.label}` : `Add ${type}`; } execute(project: Project): void { requireNodeDef(this.node.type); const graph = requireGraph(project, this.assetId); // 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 graph already has a ${nodeDef(this.node.type)?.label} node`); graph.nodes.push(this.node); if (this.closing) graph.nodes.push(this.closing); } undo(project: Project): void { const graph = requireGraph(project, this.assetId); graph.nodes = removeById(graph.nodes, this.node.id); if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.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 = requireGraph(project, this.assetId); 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 = requireGraph(project, this.assetId); 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 { const graph = requireGraph(project, this.assetId); const lookup = graphLookup(project); // A param can reshape another node too – a Find's mode decides its Match's outputs (#259). const held = new Set(graph.edges.filter((e) => edgeHolds(graph, e, lookup))); super.execute(project); this.orphans = removeWhere( graph.edges, (e) => (held.has(e) || e.from.node === this.nodeId || e.to.node === this.nodeId) && !edgeHolds(graph, e, lookup), ); } override undo(project: Project): void { super.undo(project); restoreAt(requireGraph(project, this.assetId).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; } } /** Whether both ends of an edge name a port their node has; a node of unknown type is not judged. */ function edgeHolds(graph: Graph, edge: GraphEdge, lookup: GraphLookup): boolean { const has = (end: GraphEdge['from'], side: 'inputs' | 'outputs') => { const node = findNode(graph, end.node); const def = node && resolveDef(node, graph, lookup); return !def || def[side].some((p) => p.key === end.port); }; return has(edge.from, 'outputs') && has(edge.to, 'inputs'); } /** 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 = requireGraph(project, this.assetId); 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'); const lookup = graphLookup(project); const fromDef = resolveDef(fromNode, graph, lookup); // A transition back into its own state re-enters it; any other edge onto its own node is a loop. if (fromNode === toNode && fromDef?.transitionEdges !== true) throw new CommandError('A node cannot connect to itself'); const out = fromDef?.outputs.find((p) => p.key === this.edge.from.port); const toDef = resolveDef(toNode, graph, lookup); const inp = toDef?.inputs.find((p) => p.key === this.edge.to.port); if (!out || !inp) throw new CommandError('Port not found'); if (!portsMeet(out, inp)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`); if (graph.edges.some((e) => sameEnds(e, this.edge))) { // Transitions are told apart by name, so a further one between the same states gets its own. if (fromDef?.transitionEdges !== true || toDef?.transitionEdges !== true) throw new CommandError('Already connected'); this.edge.label = nextTransitionName(graph, this.edge); } const occupied = takesManyEdges(inp) ? [] : 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 = requireGraph(project, this.assetId); 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 = requireEdge(project, this.assetId, this.edgeId); this.previous = edge.folded === true; this.apply(edge, this.folded); } undo(project: Project): void { this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous); } private apply(edge: GraphEdge, folded: boolean): void { if (folded) edge.folded = true; else delete edge.folded; } } /** The name a transition answers to. Blank clears it, since an unnamed transition is the normal one. */ export class NameTransitionCommand implements Command { readonly label = 'Name transition'; private previous = ''; constructor( private readonly assetId: string, private readonly edgeId: string, private readonly name: string, ) {} execute(project: Project): void { const edge = requireEdge(project, this.assetId, this.edgeId); const name = this.name.trim(); // Between the same two states the name is all that tells transitions apart. if (parallelTransitions(requireGraph(project, this.assetId), edge).some((e) => (e.label ?? '') === name)) throw new CommandError( name ? `Another transition between these states is already called "${name}"` : 'Another transition between these states is already unnamed', ); this.previous = edge.label ?? ''; this.apply(edge, name); } undo(project: Project): void { this.apply(requireEdge(project, this.assetId, this.edgeId), this.previous); } private apply(edge: GraphEdge, name: string): void { if (name) edge.label = name; else delete edge.label; } } /** One undo step for the whole selection; every node is found before any of them changes. */ export class CollapseNodesCommand implements Command { readonly label: string; private previous: boolean[] = []; constructor( private readonly assetId: string, private readonly nodeIds: readonly string[], private readonly collapsed: boolean, ) { const noun = nodeIds.length === 1 ? 'node' : 'nodes'; this.label = collapsed ? `Collapse ${noun}` : `Expand ${noun}`; } execute(project: Project): void { const nodes = this.nodeIds.map((id) => requireNode(project, this.assetId, id)); this.previous = nodes.map((n) => n.collapsed === true); for (const node of nodes) setCollapsed(node, this.collapsed); } undo(project: Project): void { this.nodeIds.forEach((id, i) => setCollapsed(requireNode(project, this.assetId, id), this.previous[i]!)); } } function setCollapsed(node: GraphNode, collapsed: boolean): void { if (collapsed) node.collapsed = true; else delete node.collapsed; } 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 = requireGraph(project, this.assetId); 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(requireGraph(project, this.assetId).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 ); }