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>
785 lines
29 KiB
TypeScript
785 lines
29 KiB
TypeScript
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) };
|
||
}
|