Files
feuerwerk/src/core/commands/graphCommands.ts
T
rootandClaude Opus 5 4b0919a224 Let a link be hidden, and shown again by selecting either end
A link can be folded away from the canvas: instead of a curve running clear
across the graph, each end grows a short stub with a mark naming the node at
the far side. The curve comes back while either of its two nodes is selected,
so following a connection is a click rather than a search, and a link carrying
a live pulse is always drawn so the trace keeps its thread.

Marks are collected per port, not per link, because several hidden links on one
port used to stack stubs, dots and labels on identical pixels. A bundle reads
"Move +1" and its dot selects every node the port feeds, as one undo step.

The whole thing is one optional flag on the edge; the interpreter never sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:19:59 +02:00

245 lines
8.3 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. */
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
constructor(
private readonly assetId: string,
private readonly nodeId: string,
key: string,
private readonly value: PropertyValue,
) {
super(key, `Set ${key}`);
}
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, lookup)?.outputs.find((p) => p.key === this.edge.from.port);
const inp = resolveDef(toNode, 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
);
}