import type { Graphics } from 'pixi.js'; import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { ConnectCommand } from '../../core/commands/graphCommands'; import { canConnect, defAcceptsFrom, dropTargetPort, firstPortFor, type Graph, type PortSide, type PortDef, portOf, STATE_IN, STATE_OUT, } from '../../core/graph'; import { graphLookup } from '../../core/model/graphLookup'; import { Theme, ui } from '../../ui'; import { nodeSelection } from '../state'; import { arrowHead, bezier, loopPath } from './EdgeLayer'; import type { GraphCanvasHost } from './graphHost'; import { NodePalette } from './NodePalette'; import type { PaletteEntry } from './paletteEntries'; import { arrowShaft, type Point, selfLoop, transitionPreview } from './transitions'; /** * Making edges: a drag from a pin, the palette a drag into empty space opens, and a transition * armed from a state's menu that waits for the click naming its target. */ export class GraphWiring { private connecting: { from: PortSide; pointer: Point } | null = null; /** A finished drag whose palette is still open – drawn like the running one, so the connection * being made stays visible while the node type is chosen. */ private pendingDrop: { from: PortSide; pointer: Point } | null = null; /** A transition begun from a state's menu, waiting for the click that names its target. It takes * the shape of a drag because it is one in everything but the held button – that way the running * preview draws it without a second code path. `release` takes the Escape handler back off. */ private armed: { from: PortSide; pointer: Point; release: () => void } | null = null; /** Where the running drag would land, recomputed on every move so the drop holds no surprise. */ private dropTarget: PortSide | null = null; /** Whether the last redraw actually stroked the dangling edge. */ private tempEdgeShown = false; constructor(private readonly host: GraphCanvasHost) {} /** A transition is waiting for its target. */ get isArmed(): boolean { return this.armed !== null; } /** The port a running edge drag would connect to, or null when the drop would do nothing. */ get target(): PortSide | null { return this.dropTarget; } /** Whether the dangling edge is on screen right now (running drag, or a drop awaiting its node). */ get edgeShown(): boolean { return this.tempEdgeShown; } /** A press on a pin: the edge follows the pointer until the button comes up. */ startDrag(from: PortSide, global: Point): void { this.connecting = { from, pointer: this.host.toGraph(global) }; this.dropTarget = null; this.host.redraw(); } /** Follows the pointer with a drag or an armed transition; false when there is neither. */ move(global: Point): boolean { const g = this.host.toGraph(global); if (this.connecting) { this.connecting.pointer = g; this.dropTarget = this.resolveDrop(global); } else if (this.armed) { this.armed.pointer = g; this.dropTarget = this.armedTarget(global); } else return false; this.host.redraw(); return true; } /** Ends a drag: onto a port it connects, into empty space it offers the palette. */ release(global: Point): void { const connecting = this.connecting; const target = connecting ? (this.dropTarget ?? this.resolveDrop(global)) : null; this.connecting = null; this.dropTarget = null; if (!connecting || !this.host.assetId()) return; if (target) this.connect(connecting.from, target); else if (!this.host.nodeUnder(global)) this.openPaletteForDrop(connecting, global); } private connect(from: PortSide, to: PortSide): void { const assetId = this.host.assetId(); if (assetId) this.host.state.execute(connectSides(assetId, from, to)); } /** * Dropped into empty space: offer the node types this edge could reach, then wire the new node * up the same way a drop on its body would. */ private openPaletteForDrop(drag: { from: PortSide; pointer: Point }, global: Point): void { const graph = this.host.asset()?.graph; const from = drag.from; const fromPort = graph && portOf(graph, from, graphLookup(this.host.state.project)); if (!graph || fromPort === undefined || this.host.entries().length === 0) return; const at = this.host.toGraph(global); this.pendingDrop = drag; const palette = new NodePalette( this.host.entries(), (entry) => this.addConnectedNode(entry, from, fromPort, at), (def) => defAcceptsFrom(def, fromPort, from.isOutput), ); palette.onClose = () => { this.pendingDrop = null; this.host.redraw(); }; palette.open(global.x, global.y); } /** Adding and wiring go in as one command, so a single undo takes the whole gesture back. */ private addConnectedNode(entry: PaletteEntry, from: PortSide, fromPort: PortDef, at: Point): void { const assetId = this.host.assetId(); if (!assetId) return; const add = this.host.newNode(entry, at); const port = firstPortFor(entry.def, fromPort, from.isOutput); const commands: Command[] = [add]; if (port) commands.push(connectSides(assetId, from, { nodeId: add.node.id, port, isOutput: !from.isOutput })); if (this.host.state.execute(oneStep(add.label, commands))) this.host.state.select(nodeSelection(assetId, [add.node.id])); } /** * Where the running drag would connect: the port under the pointer, or – dropped on a node's * body – the port that node picks for it. Null means the drop does nothing. */ private resolveDrop(global: Point): PortSide | null { const graph = this.host.asset()?.graph; const from = this.connecting?.from; if (!graph || !from) return null; const lookup = graphLookup(this.host.state.project); const hit = this.portUnder(global); if (hit) return canConnect(graph, from, hit, lookup) ? hit : null; const node = this.host.nodeUnder(global); if (!node || this.host.views().get(node)?.collapsed) return null; return dropTargetPort(graph, from, node, lookup); } private portUnder(global: Point): PortSide | null { for (const view of this.host.topDownViews()) { const local = view.toLocal(global); const hit = view.portAt(local.x, local.y); // A ghost is no target, and it must not shadow the node body underneath it either. if (hit && !hit.port.ghost) return { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }; } return null; } /** Begin a transition out of this state; the next click on another one completes it. */ arm(nodeId: string, pointer: Point): void { this.cancel(); const handlers = ui().focus.globalHandlers; const onKey = (event: KeyboardEvent): boolean => { if (event.key !== 'Escape' || !this.armed) return false; this.cancel(); this.host.redraw(); return true; }; handlers.push(onKey); this.armed = { from: { nodeId, port: STATE_OUT, isOutput: true }, pointer, release: () => { const at = handlers.indexOf(onKey); if (at >= 0) handlers.splice(at, 1); }, }; this.dropTarget = null; this.host.redraw(); } cancel(): void { this.armed?.release(); this.armed = null; } /** Where an armed transition would land: the state under the pointer, its own source included. */ private armedTarget(global: Point): PortSide | null { if (!this.armed) return null; const node = this.host.nodeUnder(global); if (!node) return null; const view = this.host.views().get(node); return view?.def.transitionEdges === true ? { nodeId: node, port: STATE_IN, isOutput: false } : null; } /** Finish or abandon an armed transition. True when the click belonged to it. */ finish(global: Point, connect: boolean): boolean { const armed = this.armed; if (!armed) return false; const target = connect ? this.armedTarget(global) : null; this.cancel(); this.dropTarget = null; if (target) this.connect(armed.from, target); this.host.redraw(); return true; } /** The edge being made, from its pin to the pointer – or to the port it would land on. */ draw(g: Graphics, graph: Graph): void { const c = Theme.colors; const views = this.host.views(); const target = this.dropTarget; g.clear(); this.tempEdgeShown = false; const drag = this.connecting ?? this.pendingDrop ?? this.armed; const a = drag && this.anchorPoint(drag.from); const home = drag && target?.nodeId === drag.from.nodeId ? views.get(target.nodeId) : undefined; if (home?.def.transitionEdges === true) { const ring = graph.edges.filter( (e) => e.from.node === home.node.id && e.to.node === home.node.id, ).length; const loop = selfLoop(home, ring); loopPath(g, loop); g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); arrowHead(g, loop.c2, loop.end, c.textMuted); this.tempEdgeShown = true; } else if (drag && a) { // Snapping to the resolved port makes the prediction the ring shows unmistakable. const b = this.anchorPoint(target) ?? drag.pointer; // Dragged off a state, the line already looks like the transition it is about to become. const preview = this.transitionPreviewOf(drag.from, target, drag.pointer); const ends = preview ? [preview.start, preview.end] : [a, b]; const [from, to] = (drag.from.isOutput ? ends : [ends[1], ends[0]]) as [Point, Point]; if (preview) { const shaft = arrowShaft(from, to); g.moveTo(from.x, from.y).lineTo(shaft.x, shaft.y); } else bezier(g, from.x, from.y, to.x, to.y); g.stroke({ width: 2, color: c.textMuted, alpha: 0.9 }); if (preview) arrowHead(g, from, to, c.textMuted); this.tempEdgeShown = true; } } /** Null unless the drag started on a state: only those draw as a straight arrow. */ private transitionPreviewOf( from: PortSide, target: PortSide | null, pointer: Point, ): { start: Point; end: Point } | null { const views = this.host.views(); const source = views.get(from.nodeId); if (source?.def.transitionEdges !== true) return null; const to = target ? views.get(target.nodeId) : undefined; return transitionPreview(source, to?.def.transitionEdges === true ? to : null, pointer); } /** A port's centre in graph coordinates. */ private anchorPoint(side: PortSide | null): Point | null { if (!side) return null; const view = this.host.views().get(side.nodeId); const p = view?.portPosition(side.port, side.isOutput); return view && p ? { x: view.x + p.x, y: view.y + p.y } : null; } } /** A drag may start at either end; the command wants the output first. */ function connectSides(assetId: string, a: PortSide, b: PortSide): ConnectCommand { const [out, inp] = a.isOutput ? [a, b] : [b, a]; return new ConnectCommand( assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }, ); }