Files
feuerwerk/src/editor/agent/catalog/graphs.ts
T
adminandClaude Opus 5.5 fb981b5b0f Let the agent copy nodes, also into another graph (#434)
The agent built every part again that already stood in a graph,
node by node, while the editor has Ctrl+C and Ctrl+V for it. The new
command `copy_nodes` in the area graphs copies nodes into the same
graph or another one, in one undo step with the rest of the call.

- The links between the copied nodes come along, and so do groups
  and comments that are only about copied nodes. A node belongs to a
  group by its middle, as when it is dragged. `boxesAbout` in
  annotationFollow.ts decides this.
- `made` names each copy by the id of its original, so later steps
  link to it with `$copy.<id>`. `$copy` alone is the first one.
- Without x and y the copies are arranged at the end of the call like
  new nodes. With them they keep their layout, top left corner there.
- Nodes the target graph does not take are refused by name, not left
  out as Ctrl+V does. The rule is the graph tab's: `fitsGraph` and
  `fittingPart` moved from clipboard.ts to paletteEntries.ts.
- A copied Variables node takes the variables of its behavior along
  into the target behavior (`CarryVariablesCommand`), with default,
  value, Keep and Private. A variable of that name of another kind
  there is refused.
- The relay's instructions name `copy_nodes`.

Found on the way:
- Groups made by the agent can take in a node of another group once
  the nodes are arranged. lint_graph does not count that.
- Undo after a paste leaves an empty `groups: []`, as
  AddAnnotationCommand does. It is the same as no list.

Tests:
- agent-copy (new, 3 tests) with the agent, relay, clipboard and
  palette tests: 118/118.
- Counter-probes: 6 bendings, all compile, all red.
- e2e 71 (atx): ALL PASSED, 26 checks.
- Gate (atx): CHECK EXIT 0, 327 files, 5024 tests.
- Live in the MCP Test Project: 5 nodes of Arrow keys walk into a new
  behavior, 6 links, group, comment and speed (300) along, lint_graph
  0 everywhere, taken back with undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:39:37 +02:00

785 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AddAnnotationCommand,
RemoveAnnotationCommand,
SetAnnotationBoxCommand,
SetAnnotationColorCommand,
SetAnnotationTextCommand,
} from '../../../core/commands/annotationCommands';
import { CommandError } from '../../../core/commands/Command';
import { CompoundCommand } from '../../../core/commands/CompoundCommand';
import {
AddNodeCommand,
CollapseNodesCommand,
ConnectCommand,
DisconnectCommand,
FoldEdgeCommand,
MoveNodeCommand,
NameTransitionCommand,
RemoveNodeCommand,
ResizeNodeCommand,
SetNodeParamCommand,
} from '../../../core/commands/graphCommands';
import { PasteGraphCommand } from '../../../core/commands/pasteCommands';
import {
AddPortCommand,
MovePortCommand,
RemovePortCommand,
RenamePortCommand,
RepointPortCommand,
SetPortDefaultCommand,
SetPortMachineCommand,
SetPortWriteCommand,
} from '../../../core/commands/portCommands';
import {
SetListNodeTypeCommand,
SetStructTypeCommand,
SplitOutputCommand,
} from '../../../core/commands/structTypeCommands';
import { CarryVariablesCommand } from '../../../core/commands/variableCommands';
import {
type AnnotationKind,
type DeclaredPort,
declaresVariablePorts,
findAnnotation,
findNode,
type Graph,
type GraphNode,
nodeDef,
portKindOf,
variablesSourceOf,
} from '../../../core/graph';
import { copyGraph } from '../../../core/model/clipboard';
import type { PropertyBag, PropertyValue } from '../../../core/model/Properties';
import { findGraphAsset, type GraphHolder, type Project } from '../../../core/model/Project';
import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds';
import { coerceValue } from '../../../core/properties/PropertySchema';
import { freeNoteBox, groupBoxAround } from '../../graph/annotationDrag';
import { boxesAbout } from '../../graph/annotationFollow';
import { arrangeCommand, layoutOf } from '../../graph/graphLayout';
import { fittingPart, type PaletteEntry, paletteEntries } from '../../graph/paletteEntries';
import { pickedPortChoices } from '../../graph/portChoices';
import {
type Args,
type BuildContext,
type Built,
type CatalogCommand,
flag,
GRAPH,
hexColor,
id,
ids,
listOf,
NODE,
number,
numberOf,
object,
oneOf,
text,
theGraph,
theNode,
} from './shared';
const PORT_END = object({ node: NODE, port: id('Key of the port, as get_graph lists it.') }, [
'node',
'port',
]);
const ANNOTATION = id('Id of the group or comment, as get_graph lists it.');
const PORT = id('Key of the declared port.');
const COLOR = {
type: 'string' as const,
description: 'A color as "#rrggbb"; "" for the default.',
};
const PLACE = (description: string) => ({ type: 'integer' as const, minimum: 0, description });
const theNodes = (args: Args) => ({
kind: 'nodes' as const,
asset: text(args, 'asset'),
ids: listOf(args, 'nodes'),
});
export const GRAPH_COMMANDS: readonly CatalogCommand[] = [
{
name: 'add_node',
description:
"Adds a node to a graph. `node` is an entry of catalog topic 'nodes' for that graph (its id, e.g. " +
'"OnKeyHeld", "Move", or "RunBehavior:<id>"). Leave x and y out: when the call has run, the editor ' +
'arranges the nodes added without a place, flow left to right, next to what they link to and into its ' +
'group. The others stay where they are, but for those right of new nodes put between: they move right ' +
'to make room.',
args: object(
{
asset: GRAPH,
node: id('The entry id from the catalog.'),
params: { type: 'object', description: 'Settings and typed-in input values by key.' },
x: number('Only to place it by hand.'),
y: number('Only to place it by hand.'),
},
['asset', 'node'],
),
build: (args, context) => {
const assetId = text(args, 'asset');
const entry = paletteEntry(context.project, assetId, text(args, 'node'));
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const spot = placed ? { x: args.x as number, y: args.y as number } : context.spot(assetId);
const params = { ...entry.params, ...coercedParams(entry, (args.params ?? {}) as PropertyBag) };
const command = new AddNodeCommand(assetId, entry.def.type, spot.x, spot.y, params);
const made: Record<string, string> = { id: command.node.id };
if (command.closing) made.closing = command.closing.id;
return placed
? { command, made }
: { command, made, unplaced: { asset: assetId, nodes: Object.values(made) } };
},
shows: (args, made) => ({ kind: 'nodes', asset: text(args, 'asset'), ids: Object.values(made) }),
},
{
name: 'copy_nodes',
description:
'Copies nodes into the same graph or another one, as Ctrl+C and Ctrl+V do: with the links between them ' +
'and the groups and comments about them alone. made names each copy by the id of its original ' +
'("$copy.<id>"; "$copy" is the copy of the first node given), so later steps can link it. Leave x and y ' +
'out: the copies are then arranged like new nodes when the call has run. With x and y the copy keeps ' +
'its layout, its top left there. Nodes the other graph cannot hold are refused by name; the variables ' +
'a copied Variables node reads from its behavior come along into the other behavior.',
args: object(
{
from: id('Id of the asset holding the nodes.'),
nodes: ids('The nodes to copy.'),
to: id('Id of the asset to copy them into; leave out for the same one.'),
x: number('Only to place the copy by hand: its left.'),
y: number('Only to place the copy by hand: its top.'),
},
['from', 'nodes'],
),
build: copyNodes,
shows: (args, made) => ({
kind: 'nodes',
asset: (args.to as string | undefined) ?? text(args, 'from'),
ids: Object.values(made),
}),
},
{
name: 'arrange_graph',
description:
'Arranges nodes of a graph as the Arrange button does: flow left to right, the order that crosses the ' +
'fewest links, no overlaps; groups fit round their nodes again and comments go with them. Without nodes ' +
'it arranges the whole graph – moving what the user placed, so only do that when asked.',
args: object({ asset: GRAPH, nodes: ids('The nodes to arrange; the rest stay.') }, ['asset']),
build: (args, context) => {
const assetId = text(args, 'asset');
const nodes = (args.nodes as string[] | undefined) ?? null;
requireNodes(context.project, assetId, nodes ?? []);
const command = arrangeCommand(context.project, assetId, nodes, context.measure(assetId));
return { command: command ?? new CompoundCommand('Arrange nodes', []) };
},
shows: (args) => ({
kind: 'nodes',
asset: text(args, 'asset'),
ids: (args.nodes as string[] | undefined) ?? [],
}),
},
{
name: 'remove_node',
description: 'Removes a node and every edge on it.',
args: object({ asset: GRAPH, node: NODE }, ['asset', 'node']),
build: (args) => ({ command: new RemoveNodeCommand(text(args, 'asset'), text(args, 'node')) }),
shows: theGraph,
},
{
name: 'move_node',
description: 'Moves a node in its graph.',
args: object({ asset: GRAPH, node: NODE, x: number('x'), y: number('y') }, ['asset', 'node', 'x', 'y']),
build: (args) => ({
command: new MoveNodeCommand(
text(args, 'asset'),
text(args, 'node'),
numberOf(args, 'x'),
numberOf(args, 'y'),
),
}),
shows: theNode,
},
{
name: 'set_node_param',
description: 'Sets a setting of a node, or the value typed into one of its inputs that has no edge.',
args: object(
{
asset: GRAPH,
node: NODE,
key: id('Key of the setting or input.'),
value: { description: 'Any JSON value.' },
},
['asset', 'node', 'key', 'value'],
),
build: (args) => ({
command: new SetNodeParamCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'key'),
args.value as PropertyValue,
),
}),
shows: theNode,
},
{
name: 'collapse_nodes',
description: 'Draws nodes as their title bar alone, or in full again; it changes nothing they do.',
args: object({ asset: GRAPH, nodes: ids('Ids of the nodes.'), collapsed: flag('Collapse them.') }, [
'asset',
'nodes',
'collapsed',
]),
build: (args) => ({
command: new CollapseNodesCommand(
text(args, 'asset'),
listOf(args, 'nodes'),
args.collapsed as boolean,
),
}),
shows: theNodes,
},
{
name: 'resize_node',
description: 'Sets the size of a node whose face can be dragged bigger (a preview, for one).',
args: object({ asset: GRAPH, node: NODE, w: number('Width.'), h: number('Height.') }, [
'asset',
'node',
'w',
'h',
]),
build: (args) => ({
command: new ResizeNodeCommand(text(args, 'asset'), text(args, 'node'), {
w: numberOf(args, 'w'),
h: numberOf(args, 'h'),
}),
}),
shows: theNode,
},
{
name: 'connect',
description:
'Draws an edge from an output to an input. Exec ports carry the flow, the others values; an input ' +
'that holds one value loses the edge it had.',
args: object({ asset: GRAPH, from: PORT_END, to: PORT_END }, ['asset', 'from', 'to']),
build: (args) => {
const end = (key: string) => args[key] as { node: string; port: string };
const command = new ConnectCommand(text(args, 'asset'), end('from'), end('to'));
return { command, made: { id: command.edge.id } };
},
shows: (args) => ({
kind: 'nodes',
asset: text(args, 'asset'),
ids: (['from', 'to'] as const).map((key) => (args[key] as { node: string }).node),
}),
},
{
name: 'disconnect',
description: 'Removes an edge.',
args: object({ asset: GRAPH, edge: id('Id of the edge.') }, ['asset', 'edge']),
build: (args) => ({ command: new DisconnectCommand(text(args, 'asset'), text(args, 'edge')) }),
shows: theGraph,
},
{
name: 'fold_edge',
description:
'Hides an edge as a short stub at both ends, or shows it again – for a link that would cross the whole ' +
'graph. It keeps carrying its value either way.',
args: object({ asset: GRAPH, edge: id('Id of the edge.'), folded: flag('Hide it.') }, [
'asset',
'edge',
'folded',
]),
build: (args) => ({
command: new FoldEdgeCommand(text(args, 'asset'), text(args, 'edge'), args.folded as boolean),
}),
shows: theGraph,
},
{
name: 'name_transition',
description:
'Names a transition of a state machine (an edge between states), which a Go to node can then take; "" clears it.',
args: object({ asset: GRAPH, edge: id('Id of the edge.'), name: { type: 'string' } }, [
'asset',
'edge',
'name',
]),
build: (args) => ({
command: new NameTransitionCommand(text(args, 'asset'), text(args, 'edge'), text(args, 'name')),
}),
shows: theGraph,
},
{
name: 'add_group',
description:
'Draws a titled group around nodes that belong together; it fits them once the call has arranged its ' +
'nodes. Moving the group moves what is in it.',
args: object(
{ asset: GRAPH, nodes: ids('The nodes it holds.'), title: { type: 'string' }, color: COLOR },
['asset', 'nodes', 'title'],
),
build: (args, context) => annotationAt('group', args, context),
shows: theNodes,
},
{
name: 'add_comment',
description:
'Puts a comment just above nodes, saying what they do – or below them where a node or a group stands ' +
'above; it follows them as the call arranges them.',
args: object(
{ asset: GRAPH, nodes: ids('The nodes it is about.'), text: { type: 'string' }, color: COLOR },
['asset', 'nodes', 'text'],
),
build: (args, context) => annotationAt('note', args, context),
shows: theNodes,
},
{
name: 'fit_group',
description:
'Fits a group to the nodes given, or puts a comment back above them (below where that is taken).',
args: object({ asset: GRAPH, id: ANNOTATION, nodes: ids('The nodes.') }, ['asset', 'id', 'nodes']),
build: (args, context) => {
const assetId = text(args, 'asset');
const { kind } = requireAnnotationOf(context.project, assetId, text(args, 'id'));
const box = boxAt(kind, context, assetId, listOf(args, 'nodes'), text(args, 'id'));
return { command: new SetAnnotationBoxCommand(assetId, kind, text(args, 'id'), box) };
},
shows: theNodes,
},
{
name: 'edit_annotation',
description: "Changes a group's title or a comment's text, its color, or its box.",
args: object(
{
asset: GRAPH,
id: ANNOTATION,
text: { type: 'string', description: 'The title of a group, the text of a comment.' },
color: COLOR,
x: number('Box left.'),
y: number('Box top.'),
w: number('Box width.'),
h: number('Box height.'),
},
['asset', 'id'],
),
build: (args, { project }) => {
const assetId = text(args, 'asset');
const annotationId = text(args, 'id');
const { kind, annotation } = requireAnnotationOf(project, assetId, annotationId);
const steps = [];
if (typeof args.text === 'string')
steps.push(new SetAnnotationTextCommand(assetId, kind, annotationId, args.text));
if (typeof args.color === 'string')
steps.push(new SetAnnotationColorCommand(assetId, kind, annotationId, colorOf(args.color)));
const box = (['x', 'y', 'w', 'h'] as const).filter((key) => typeof args[key] === 'number');
if (box.length > 0) {
const next = { x: annotation.x, y: annotation.y, w: annotation.w, h: annotation.h };
for (const key of box) next[key] = numberOf(args, key);
steps.push(new SetAnnotationBoxCommand(assetId, kind, annotationId, next));
}
if (steps.length === 0) throw new CommandError('Name what to change: text, color or the box');
return { command: new CompoundCommand('Edit annotation', steps) };
},
shows: theGraph,
},
{
name: 'remove_annotation',
description: 'Removes a group or comment; the nodes it framed stay.',
args: object({ asset: GRAPH, id: ANNOTATION }, ['asset', 'id']),
build: (args, { project }) => {
const assetId = text(args, 'asset');
const { kind } = requireAnnotationOf(project, assetId, text(args, 'id'));
return { command: new RemoveAnnotationCommand(assetId, kind, text(args, 'id')) };
},
shows: theGraph,
},
{
name: 'add_port',
description:
'Adds a declared port to a node that has them: a Variables node (key = the variable it reads or writes), a ' +
'Properties node (key = the property), Sound FX parameters (key = the parameter) – their kind comes from ' +
'what they name; a start or end node of a behavior or function, a struct Pack/Expand, a sound FX Input – ' +
'named freely with a kind. get_graph lists the ports a node has under ports.',
args: object(
{
asset: GRAPH,
node: NODE,
key: id('Name of the port.'),
kind: oneOf(DECLARED_PORT_KINDS, 'Its kind; leave out where it comes from what the port names.'),
},
['asset', 'node', 'key'],
),
build: (args, { project }) => {
const assetId = text(args, 'asset');
const port = declaredPort(
project,
assetId,
text(args, 'node'),
text(args, 'key'),
args.kind as DeclaredPortKind,
);
return { command: new AddPortCommand(assetId, text(args, 'node'), port) };
},
shows: theNode,
},
{
name: 'repoint_port',
description:
'Points a declared port of a Variables, Properties or Sound FX parameters node at another variable, property ' +
'or parameter; its edges come along.',
args: object({ asset: GRAPH, node: NODE, from: PORT, to: id('What it names now.') }, [
'asset',
'node',
'from',
'to',
]),
build: (args, { project }) => {
const assetId = text(args, 'asset');
const port = declaredPort(project, assetId, text(args, 'node'), text(args, 'to'));
return { command: new RepointPortCommand(assetId, text(args, 'node'), text(args, 'from'), port) };
},
shows: theNode,
},
{
name: 'remove_port',
description:
'Removes a declared port of a node, with the edges on it (also on the nodes calling the behavior).',
args: object({ asset: GRAPH, node: NODE, key: PORT }, ['asset', 'node', 'key']),
build: (args) => ({
command: new RemovePortCommand(text(args, 'asset'), text(args, 'node'), text(args, 'key')),
}),
shows: theNode,
},
{
name: 'rename_port',
description: 'Renames a declared port named freely (start, end, struct field); edges and callers follow.',
args: object({ asset: GRAPH, node: NODE, from: PORT, to: id('Its new name.') }, [
'asset',
'node',
'from',
'to',
]),
build: (args) => ({
command: new RenamePortCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'from'),
text(args, 'to'),
),
}),
shows: theNode,
},
{
name: 'move_port',
description: "Moves a declared port to another place in the node's list; edges stay on it.",
args: object(
{ asset: GRAPH, node: NODE, from: PLACE('Its place now, from 0.'), to: PLACE('Its new place.') },
['asset', 'node', 'from', 'to'],
),
build: (args) => ({
command: new MovePortCommand(
text(args, 'asset'),
text(args, 'node'),
numberOf(args, 'from'),
numberOf(args, 'to'),
),
}),
shows: theNode,
},
{
name: 'set_port_write',
description:
'On a Variables or Properties node: whether it writes this port too (it then gets an input and flow ports), or only reads it.',
args: object({ asset: GRAPH, node: NODE, key: PORT, write: flag('Write it.') }, [
'asset',
'node',
'key',
'write',
]),
build: (args) => ({
command: new SetPortWriteCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'key'),
args.write as boolean,
),
}),
shows: theNode,
},
{
name: 'set_port_machine',
description:
'Which state machine a port of kind statemachine carries (on a start node or a Variables node), so the state nodes behind it offer its states; "" for none.',
args: object(
{
asset: GRAPH,
node: NODE,
key: PORT,
machine: { type: 'string', description: 'Id of the state machine, or "".' },
},
['asset', 'node', 'key', 'machine'],
),
build: (args) => ({
command: new SetPortMachineCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'key'),
text(args, 'machine'),
),
}),
shows: theNode,
},
{
name: 'set_port_default',
description: 'The default of a parameter of a sound FX Input or an instrument Parameters node.',
args: object({ asset: GRAPH, node: NODE, key: PORT, value: number('The default.') }, [
'asset',
'node',
'key',
'value',
]),
build: (args) => ({
command: new SetPortDefaultCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'key'),
numberOf(args, 'value'),
),
}),
shows: theNode,
},
{
name: 'set_node_struct_type',
description:
'Gives a struct Pack or Expand node the fields of a Struct asset as its ports; "" gives it fields of its own again.',
args: object(
{ asset: GRAPH, node: NODE, type: { type: 'string', description: 'Id of the Struct asset, or "".' } },
['asset', 'node', 'type'],
),
build: (args) => ({
command: new SetStructTypeCommand(
{ kind: 'node', assetId: text(args, 'asset'), nodeId: text(args, 'node') },
text(args, 'type'),
),
}),
shows: theNode,
},
{
name: 'split_output',
description:
'Takes a struct output apart into one output per field of a Struct asset (ports "<output>.<field>"); "" puts it together again.',
args: object(
{
asset: GRAPH,
node: NODE,
port: id('Key of the output.'),
type: { type: 'string', description: 'Id of the Struct asset, or "".' },
},
['asset', 'node', 'port', 'type'],
),
build: (args) => ({
command: new SplitOutputCommand(
text(args, 'asset'),
text(args, 'node'),
text(args, 'port'),
text(args, 'type'),
),
}),
shows: theNode,
},
{
name: 'set_list_node_type',
description: 'The item type of a List node, a Struct asset; "" makes it a mixed list.',
args: object(
{ asset: GRAPH, node: NODE, type: { type: 'string', description: 'Id of the Struct asset, or "".' } },
['asset', 'node', 'type'],
),
build: (args) => ({
command: new SetListNodeTypeCommand(text(args, 'asset'), text(args, 'node'), text(args, 'type')),
}),
shows: theNode,
},
];
/** The palette entry a node is added from: by its id, else by its label when only one has it. */
export function paletteEntry(project: Project, assetId: string, wanted: string): PaletteEntry {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
const entries = paletteEntries(project, holder);
const byId = entries.find((entry) => entry.id === wanted);
if (byId) return byId;
const byLabel = entries.filter((entry) => entry.def.label.toLowerCase() === wanted.toLowerCase());
if (byLabel.length === 1) return byLabel[0]!;
throw new CommandError(
`A ${holder.type} graph has no node "${wanted}" – look it up with catalog topic "nodes" and this asset`,
);
}
/** Settings are held to their kind, as the inspector holds them; a typed-in input value stays as given. */
function coercedParams(entry: PaletteEntry, params: PropertyBag): PropertyBag {
const coerced: PropertyBag = {};
for (const [key, value] of Object.entries(params)) {
const param = entry.def.params.find((p) => p.key === key);
coerced[key] = param && param.kind !== 'enum' ? coerceValue(param, value) : value;
}
return coerced;
}
/** `copy_nodes` (#434): the copies, where they go, and the variables that go along with them. */
function copyNodes(args: Args, context: BuildContext): Built {
const { project } = context;
const fromId = text(args, 'from');
const toId = (args.to as string | undefined) ?? fromId;
const nodeIds = listOf(args, 'nodes');
requireNodes(project, fromId, nodeIds);
const source = findGraphAsset(project, fromId)!;
const target = findGraphAsset(project, toId);
if (!target) throw new CommandError(`There is no graph asset ${toId}`);
const layout = layoutOf(project, fromId, context.measure(fromId))!;
const clip = copyGraph(source.graph, nodeIds, boxesAbout(layout, new Set(nodeIds)))!;
const { skipped } = fittingPart(project, target, clip);
if (skipped.length > 0) {
const named = skipped.map((node) => `${nodeDef(node.type)?.label ?? node.type} (${node.id})`);
throw new CommandError(`${target.name} cannot hold ${named.join(', ')} – leave them out`);
}
const carried = carriedVariables(source, target, clip.nodes);
const placed = typeof args.x === 'number' && typeof args.y === 'number';
const at = placed ? { x: args.x as number, y: args.y as number } : context.spot(toId);
const corners = [...clip.nodes, ...clip.annotations.map((a) => a.annotation)];
const shift = {
x: at.x - Math.min(...corners.map((c) => c.x)),
y: at.y - Math.min(...corners.map((c) => c.y)),
};
const paste = new PasteGraphCommand(toId, clip, shift);
const command =
carried.length > 0
? new CompoundCommand(paste.label, [new CarryVariablesCommand(toId, fromId, carried), paste])
: paste;
const copies = paste.nodeIds;
const made: Record<string, string> = { id: copies[clip.nodes.findIndex((n) => n.id === nodeIds[0])]! };
clip.nodes.forEach((node, i) => (made[node.id] = copies[i]!));
clip.annotations.forEach(({ annotation }, i) => (made[annotation.id] = paste.annotationIds[i]!));
return placed ? { command, made } : { command, made, unplaced: { asset: toId, nodes: copies } };
}
/**
* The variables of `source` that copied Variables nodes read from it, for a copy into another
* behavior that lacks them; one declared there in another kind is refused.
*/
function carriedVariables(source: GraphHolder, target: GraphHolder, nodes: readonly GraphNode[]): string[] {
if (source.type !== 'behavior' || target.type !== 'behavior' || source.id === target.id) return [];
const named = nodes.flatMap((node) =>
declaresVariablePorts(node.type) && !variablesSourceOf(node.params)
? (node.ports ?? []).map((port) => port.key)
: [],
);
const carried: string[] = [];
for (const key of new Set(named)) {
const def = source.variables.find((v) => v.key === key);
const there = target.variables.find((v) => v.key === key);
if (!def) continue;
if (!there) carried.push(key);
else if (there.kind !== def.kind)
throw new CommandError(
`"${key}" is a ${def.kind} in ${source.name} but a ${there.kind} in ${target.name} – rename one first`,
);
}
return carried;
}
function requireGraphOf(project: Project, assetId: string): Graph {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
return holder.graph;
}
function requireNodes(project: Project, assetId: string, nodeIds: readonly string[]): void {
const holder = findGraphAsset(project, assetId);
if (!holder) throw new CommandError(`There is no graph asset ${assetId}`);
const unknown = nodeIds.find((nodeId) => !findNode(holder.graph, nodeId));
if (unknown !== undefined) throw new CommandError(`${holder.name} has no node ${unknown}`);
}
function requireAnnotationOf(project: Project, assetId: string, annotationId: string) {
const found = findAnnotation(requireGraphOf(project, assetId), annotationId);
if (!found) throw new CommandError(`The graph has no group or comment ${annotationId}`);
return found;
}
function colorOf(value: string): number | null {
return value === '' ? null : hexColor(value);
}
/**
* Where a group or comment on these nodes goes, by the nodes as they stand now: a comment where it
* lies on nothing else. `self` is the one being fitted again.
*/
export function boxAt(
kind: AnnotationKind,
context: BuildContext,
assetId: string,
nodeIds: readonly string[],
self?: string,
) {
requireNodes(context.project, assetId, nodeIds);
const wanted = new Set(nodeIds);
const layout = layoutOf(context.project, assetId, context.measure(assetId));
const boxes = (layout?.nodes ?? []).filter((node) => wanted.has(node.id));
const others = (layout?.boxes ?? []).filter((box) => box.id !== self);
const box =
kind === 'group'
? groupBoxAround(boxes)
: freeNoteBox(boxes, {
nodes: (layout?.nodes ?? []).filter((node) => !wanted.has(node.id)),
groups: others.filter((other) => other.kind === 'group'),
notes: others.filter((other) => other.kind === 'note'),
});
if (!box) throw new CommandError('None of those nodes is drawn');
return box;
}
function annotationAt(kind: AnnotationKind, args: Args, context: BuildContext) {
const assetId = text(args, 'asset');
const nodes = listOf(args, 'nodes');
const words = text(args, kind === 'group' ? 'title' : 'text');
const add = new AddAnnotationCommand(assetId, kind, boxAt(kind, context, assetId, nodes), words);
const color = typeof args.color === 'string' ? colorOf(args.color) : null;
const command =
color === null
? add
: new CompoundCommand(add.label, [
add,
new SetAnnotationColorCommand(assetId, kind, add.annotation.id, color),
]);
return {
command,
made: { id: add.annotation.id },
frames: { asset: assetId, kind, id: add.annotation.id, nodes },
};
}
/** A port as a node takes it: picked ones in the kind of what they name, the others in the kind given. */
function declaredPort(
project: Project,
assetId: string,
nodeId: string,
key: string,
kind?: DeclaredPortKind,
): DeclaredPort {
const node = findNode(requireGraphOf(project, assetId), nodeId);
if (!node) throw new CommandError(`The graph has no node ${nodeId}`);
const choices = pickedPortChoices(project, assetId, node);
if (choices === null) {
if (!kind) throw new CommandError('Give the port a kind');
return { key, kind };
}
const def = choices.find((choice) => choice.key === key);
if (!def) {
const offered = choices.map((choice) => choice.key);
throw new CommandError(
offered.length > 0
? `"${key}" is none of what this node can name: ${offered.join(', ')}`
: 'This node has nothing to name yet: give its owner variables, or pick its source in its settings first',
);
}
return { key, kind: portKindOf(def) };
}