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:"). 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 = { 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."; "$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 "."); "" 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 = { 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) }; }