The Outputs setting is gone. A Sequence now keeps one free output below the last connected one: nothing plugged in is one output, connecting the spare grows a new one, unplugging the bottom edge shrinks it back. It counts the last *connected* port rather than the number of edges, so unplugging a middle one leaves a gap instead of silently dropping an edge. Removing the setting also removes a bug it caused. Typing in the Outputs field did nothing and the arrow keys did nothing, while dragging the value worked: onInput fires per keystroke, the preview writes into node.params, that emits 'project', and Inspector.refreshValues() sees the node's signature change -- the port list is part of it -- so it rebuilds and destroys the row being typed in. A drag survives because it keeps receiving globalpointermove on the old widget. refreshValues() has no focus guard where refreshLive() does; the trap is still there for the next typed field that changes a node's shape. Ports that depend on the edges around a node mean derivePorts needs the graph, not just the params, so resolveDef and portSignature take it too. And a node whose ports are derived cannot know how many it has from its type, so run is handed the definition resolved for it.
273 lines
9.4 KiB
TypeScript
273 lines
9.4 KiB
TypeScript
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<GraphNode>; edges: Removed<GraphEdge>[] };
|
|
|
|
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<GraphNode> {
|
|
private orphans: Removed<GraphEdge>[] = [];
|
|
|
|
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<GraphEdge>[] = [];
|
|
|
|
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<GraphEdge>[] = [];
|
|
|
|
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
|
|
);
|
|
}
|