"use client"; import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode, } from "react"; import { useReducedMotion } from "../motion/Preferences.tsx"; import { Plane, PlaneItem, type PlaneHandle } from "./Plane.tsx"; import { useAnnouncer, useControllable, useTween } from "./internal.ts"; export type FlowStatus = "idle" | "running" | "success" | "error" | "skipped"; export type FlowNode = { id: string; x: number; y: number; label: string; /** A short caption such as TRIGGER or ACTION. */ kind?: string; description?: string; /** Input port names. Defaults to one input, "in". Use [] for a source. */ inputs?: readonly string[]; /** Output port names. Defaults to one output, "out". Use [] for a sink. */ outputs?: readonly string[]; status?: FlowStatus; }; export type FlowEdge = { id: string; source: string; target: string; sourcePort?: string; targetPort?: string; label?: string; }; const HEAD = 76, ROW = 28, FOOT = 10, GRID = 8; const ins = (n: FlowNode) => n.inputs ?? ["in"]; const outs = (n: FlowNode) => n.outputs ?? ["out"]; const rows = (n: FlowNode) => Math.max(1, ins(n).length, outs(n).length); function port( n: FlowNode, side: "in" | "out", name: string | undefined, width: number, ) { const list = side === "in" ? ins(n) : outs(n); const i = Math.max(0, name === undefined ? 0 : list.indexOf(name)); return { x: n.x + (side === "out" ? width : 0), y: n.y + HEAD + i * ROW + ROW / 2, }; } function curve(a: { x: number; y: number }, b: { x: number; y: number }) { const dx = Math.max(56, Math.abs(b.x - a.x) * 0.5); return `M${a.x},${a.y}C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`; } /** Layered layout: longest path from the sources, then two barycentre passes to untangle. */ export function tidyFlow( nodes: readonly FlowNode[], edges: readonly FlowEdge[], width = 232, ) { const incoming = new Map(nodes.map((n) => [n.id, [] as string[]])); const outgoing = new Map(nodes.map((n) => [n.id, [] as string[]])); for (const e of edges) if ( incoming.has(e.target) && outgoing.has(e.source) && e.source !== e.target ) { incoming.get(e.target)!.push(e.source); outgoing.get(e.source)!.push(e.target); } const layer = new Map(); const visiting = new Set(); const depth = (id: string): number => { if (layer.has(id)) return layer.get(id)!; if (visiting.has(id)) return 0; visiting.add(id); const d = Math.max( -1, ...incoming .get(id)! .filter((p) => !visiting.has(p)) .map(depth), ) + 1; visiting.delete(id); layer.set(id, d); return d; }; nodes.forEach((n) => depth(n.id)); const layers: FlowNode[][] = []; for (const n of [...nodes].sort((a, b) => a.y - b.y)) (layers[layer.get(n.id)!] ??= []).push(n); for (let pass = 0; pass < 2; pass++) for (let l = 1; l < layers.length; l++) { const previous = new Map((layers[l - 1] ?? []).map((n, i) => [n.id, i])); const score = (n: FlowNode) => { const parents = incoming.get(n.id)!.filter((p) => previous.has(p)); return parents.length ? parents.reduce((s, p) => s + previous.get(p)!, 0) / parents.length : Infinity; }; layers[l] = [...(layers[l] ?? [])].sort((a, b) => score(a) - score(b)); } const placed = new Map(); layers.forEach((column, l) => { const heights = column.map((n) => HEAD + rows(n) * ROW + FOOT); const total = heights.reduce((s, h) => s + h, 0) + (column.length - 1) * 40; let y = -total / 2; column.forEach((n, i) => { placed.set(n.id, { x: l * (width + 112), y: Math.round(y / GRID) * GRID, }); y += heights[i]! + 40; }); }); return nodes.map((n) => ({ ...n, ...placed.get(n.id) })); } /** * A node-and-wire editor on a Plane: drag nodes, wire outputs to inputs with * magnetic snapping or the keyboard, watch run status flow along the wires, * and tidy the whole graph into layers. */ export function FlowGraph({ label, nodes: nodesProp, defaultNodes = [], onNodesChange, edges: edgesProp, defaultEdges = [], onEdgesChange, readOnly = false, selected, defaultSelected = null, onSelectedChange, validateConnection, renderNode, nodeWidth = 232, tools, className = "", style, }: { label: string; nodes?: readonly FlowNode[]; defaultNodes?: readonly FlowNode[]; /** Receives moved, tidied and deleted nodes. */ onNodesChange?: (nodes: FlowNode[]) => void; edges?: readonly FlowEdge[]; defaultEdges?: readonly FlowEdge[]; /** Receives new and deleted connections. */ onEdgesChange?: (edges: FlowEdge[]) => void; /** Watch only: no dragging, wiring, tidying or deleting. */ readOnly?: boolean; selected?: string | null; defaultSelected?: string | null; onSelectedChange?: (id: string | null) => void; /** Return false or a reason to refuse a connection. */ validateConnection?: (edge: FlowEdge) => boolean | string; /** Replaces the default caption, title and description of a node. */ renderNode?: (node: FlowNode) => ReactNode; nodeWidth?: number; tools?: ReactNode; className?: string; style?: CSSProperties; }) { const id = useId(); const reduce = useReducedMotion(); const plane = useRef(null); const [message, announce] = useAnnouncer(); const [tween] = useTween(); const [nodes, setNodes] = useControllable( nodesProp, defaultNodes, onNodesChange && ((next) => onNodesChange(next as FlowNode[])), ); const [edges, setEdges] = useControllable( edgesProp, defaultEdges, onEdgesChange && ((next) => onEdgesChange(next as FlowEdge[])), ); const [choice, setChoice] = useControllable( selected, defaultSelected, onSelectedChange, ); const [override, setOverride] = useState | null>(null); const [wire, setWire] = useState<{ node: string; port: string; to?: { x: number; y: number }; snap?: string; } | null>(null); const [refusal, setRefusal] = useState(null); const refusalTimer = useRef(0); useEffect(() => () => window.clearTimeout(refusalTimer.current), []); const shown = useMemo( () => override ? nodes.map((n) => ({ ...n, ...override.get(n.id) })) : nodes, [nodes, override], ); const byId = useMemo(() => new Map(shown.map((n) => [n.id, n])), [shown]); const editable = !readOnly; const refuse = (reason: string) => { setRefusal(reason); window.clearTimeout(refusalTimer.current); refusalTimer.current = window.setTimeout(() => setRefusal(null), 2400); }; const connect = ( source: { node: string; port: string }, target: { node: string; port: string }, ) => { setWire(null); if (!editable) return; const edge: FlowEdge = { id: `${source.node}.${source.port}->${target.node}.${target.port}`, source: source.node, sourcePort: source.port, target: target.node, targetPort: target.port, }; if (source.node === target.node) return refuse("A node cannot connect to itself."); if ( edges.some( (e) => e.source === edge.source && (e.sourcePort ?? outs(byId.get(e.source)!)[0]) === edge.sourcePort && e.target === edge.target && (e.targetPort ?? ins(byId.get(e.target)!)[0]) === edge.targetPort, ) ) return refuse("Those ports are already connected."); const verdict = validateConnection?.(edge) ?? true; if (verdict !== true) return refuse( typeof verdict === "string" ? verdict : "That connection is not allowed.", ); setEdges([...edges, edge]); const from = byId.get(source.node)!, to = byId.get(target.node)!; announce(`Connected ${from.label} to ${to.label}.`); }; const remove = (target: string) => { if (!editable) return; if (byId.has(target)) { const node = byId.get(target)!; setNodes(nodes.filter((n) => n.id !== target)); setEdges(edges.filter((e) => e.source !== target && e.target !== target)); announce(`Removed ${node.label}.`); } else { setEdges(edges.filter((e) => e.id !== target)); announce("Connection removed."); } setChoice(null); }; const tidy = () => { if (!editable) return; const next = tidyFlow(nodes, edges, nodeWidth); const from = new Map(nodes.map((n) => [n.id, { x: n.x, y: n.y }])); tween( reduce ? 0 : 720, (t) => setOverride( new Map( next.map((n) => { const a = from.get(n.id)!; return [ n.id, { x: a.x + (n.x - a.x) * t, y: a.y + (n.y - a.y) * t }, ]; }), ), ), () => { setOverride(null); setNodes(next); requestAnimationFrame(() => plane.current?.fit()); }, ); announce("Arranged into layers."); }; // Pointer wiring: follow the pointer and snap to the nearest compatible input within reach. const startWire = (node: string, portName: string, e: React.PointerEvent) => { if (!editable || e.button !== 0) return; e.stopPropagation(); (e.target as HTMLElement).setPointerCapture(e.pointerId); setWire({ node, port: portName, to: plane.current?.toWorld(e.clientX, e.clientY), }); }; const moveWire = (e: React.PointerEvent) => { if (!wire?.to) return; const to = plane.current!.toWorld(e.clientX, e.clientY); let snap: string | undefined, best = 28; for (const n of shown) { if (n.id === wire.node) continue; for (const p of ins(n)) { const at = port(n, "in", p, nodeWidth); const d = Math.hypot(at.x - to.x, at.y - to.y); if (d < best) { best = d; snap = `${n.id}\u0000${p}`; } } } setWire({ ...wire, to, snap }); }; const endWire = () => { if (!wire?.to) return; if (wire.snap) { const [node, portName] = wire.snap.split("\u0000") as [string, string]; connect({ node: wire.node, port: wire.port }, { node, port: portName }); } else setWire(null); }; const onGraphKey = (e: KeyboardEvent) => { if (e.key === "Escape" && wire) { e.preventDefault(); setWire(null); announce("Connection cancelled."); } else if ( (e.key === "Delete" || e.key === "Backspace") && choice && !(e.target as HTMLElement).closest("input, textarea") ) { e.preventDefault(); remove(choice); } }; const wireFrom = wire && byId.get(wire.node); const snapTarget = wire?.snap?.split("\u0000"); const wirePath = wireFrom && wire.to && curve( port(wireFrom, "out", wire.port, nodeWidth), snapTarget ? port(byId.get(snapTarget[0]!)!, "in", snapTarget[1], nodeWidth) : wire.to, ); return (
{ setChoice(null); setWire(null); }} tools={ <> {editable && ( )} {tools} } > {edges.map((edge) => { const a = byId.get(edge.source), b = byId.get(edge.target); if (!a || !b) return null; const d = curve( port(a, "out", edge.sourcePort, nodeWidth), port(b, "in", edge.targetPort, nodeWidth), ); const mid = { x: (a.x + nodeWidth + b.x) / 2, y: (port(a, "out", edge.sourcePort, nodeWidth).y + port(b, "in", edge.targetPort, nodeWidth).y) / 2, }; return ( { e.stopPropagation(); setChoice(edge.id); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setChoice(choice === edge.id ? null : edge.id); } }} > {a.status === "running" && ( )} {edge.label && ( {edge.label} )} ); })} {wirePath && ( )} {shown.map((node) => { const inputs = ins(node), outputs = outs(node); const upstream = edges .filter((e) => e.target === node.id) .map((e) => byId.get(e.source)?.label) .filter(Boolean); const downstream = edges .filter((e) => e.source === node.id) .map((e) => byId.get(e.target)?.label) .filter(Boolean); return ( setChoice(node.id)} onFocus={() => { if (!wire) setChoice(node.id); }} onMove={ editable ? (x, y) => setNodes( nodes.map((n) => n.id === node.id ? { ...n, x: Math.round(x / GRID) * GRID, y: Math.round(y / GRID) * GRID, } : n, ), ) : undefined } >
{renderNode ? ( renderNode(node) ) : ( <>

{node.kind ?? "Step"} {node.status && node.status !== "idle" && ( {node.status} )}

{node.label}

{node.description && (

{node.description}

)} )}
{Array.from({ length: rows(node) }, (_, i) => (
{inputs[i] !== undefined ? (