"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"; import { FOOT, GRID, HEAD, ROW, ins, outs, rows, tidyFlow, type FlowEdge, type FlowNode, } from "./flow.ts"; export { tidyFlow, type FlowStatus, type FlowNode, type FlowEdge, } from "./flow.ts"; 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}`; } 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[]; onNodesChange?: (nodes: FlowNode[]) => void; edges?: readonly FlowEdge[]; defaultEdges?: readonly FlowEdge[]; onEdgesChange?: (edges: FlowEdge[]) => void; 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; 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."); }; 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 ? (