Files
feuerwerk/src/core/commands/graphCommands.ts
T
root 3c76106fd2 Let a Sequence grow an output as you connect the last one
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.
2026-09-10 08:32:14 +02:00

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
);
}