Flowchart
Controls & CanvasWorkflow trigger and condition steps on a dotted canvas.
Interactive Live Preview
components/ui/flowchart.tsxTypeScript · React 19
1"use client";23import { useEffect, useRef, useState } from "react";4import { useLayoutEffect } from "react";56/* ─────────────────────────────────────────────────────────7 * FLOWCHART — an agent workflow on a dotted editor canvas.8 * Two steps: a Trigger card and an If/Else condition card,9 * joined by a measured connector. Cards drag anywhere on10 * the canvas; the connector follows. Condition chips open11 * real dropdowns (same menu as the PromptBar model picker).12 * ───────────────────────────────────────────────────────── */1314const PURPLE = "#9a5cff";15const AMBER = "#f09a2f";1617const mix = (hue: string, pct: number, base = "var(--surface)") =>18 `color-mix(in srgb, ${hue} ${pct}%, ${base})`;1920/* ── layout constants ── */21const PAD_Y = 24;22const ROW_GAP = 64;23const PILL_OFFSET = 30; // kind pill + gap above a card2425type StepNode = {26 id: string;27 row: number;28 x: number; // 0–1 center of the node29 w: number;30 kind?: { label: string; hue: string };31 hue?: string;32 title?: string;33 caption?: string;34 condition?: boolean; // renders the if/else chip rows instead35};3637const NODES: StepNode[] = [38 {39 id: "trigger",40 row: 0,41 x: 0.5,42 w: 300,43 kind: { label: "Trigger", hue: PURPLE },44 hue: PURPLE,45 title: "New order created",46 caption: "Trigger when a new order is created",47 },48 {49 id: "cond",50 row: 1,51 x: 0.5,52 w: 356,53 kind: { label: "If / Else", hue: AMBER },54 condition: true,55 },56];5758const EDGES = [{ from: "trigger", to: "cond" }];5960/* estimated heights for the first paint; measured immediately after */61const EST_H: Record<string, number> = { trigger: 92, cond: 134 };6263const PROPERTIES = ["flavor", "topping", "size", "scoops"];64const FLAVORS = [65 { name: "Rocky Road", tag: "Classic" },66 { name: "Mint Chip", tag: "Classic" },67 { name: "Pistachio", tag: "Seasonal" },68 { name: "Bubblegum", tag: "Retro" },69];70const TOPPINGS = [71 { name: "Brown butter bourbon brittle crunch" },72 { name: "Rainbow sprinkles" },73 { name: "Hot fudge" },74 { name: "Candied pecans" },75];7677/* ── icons ── */78function ConeIcon({ size = 16 }: { size?: number }) {79 return (80 <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">81 <path d="m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11" />82 <path d="M17 7A5 5 0 0 0 7 7" />83 <path d="M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4" />84 </svg>85 );86}8788function Chevron() {89 return (90 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0 text-ink-3">91 <path d="m6 9 6 6 6-6" />92 </svg>93 );94}9596function Handle() {97 return (98 <svg width="10" height="16" viewBox="0 0 10 16" className="shrink-0 cursor-grab text-ink-3/70">99 {[3, 8, 13].flatMap((y) => [100 <circle key={`l${y}`} cx="3" cy={y} r="1.1" fill="currentColor" />,101 <circle key={`r${y}`} cx="7.5" cy={y} r="1.1" fill="currentColor" />,102 ])}103 </svg>104 );105}106107function CheckIcon() {108 return (109 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">110 <path d="M20 6L9 17l-5-5" />111 </svg>112 );113}114115/* ── dropdown menu — same pattern as the PromptBar model picker ── */116function Menu({117 items,118 value,119 width,120 align,121 onPick,122}: {123 items: { name: string; tag?: string }[];124 value: string;125 width: string;126 align: "left" | "right";127 onPick: (name: string) => void;128}) {129 const [hovered, setHovered] = useState<number | null>(null);130 const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);131 const [box, setBox] = useState<{ top: number; height: number } | null>(null);132133 const valueIndex = items.findIndex((item) => item.name === value);134 useLayoutEffect(() => {135 const row = rowRefs.current[hovered ?? valueIndex];136 if (row) setBox({ top: row.offsetTop, height: row.offsetHeight });137 }, [hovered, valueIndex]);138139 return (140 <div141 onMouseLeave={() => setHovered(null)}142 className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width}143 ${align === "right" ? "right-0" : "left-0"}`}144 style={{145 animation: "pop-in 180ms cubic-bezier(0.23,1,0.32,1) both",146 transformOrigin: align === "right" ? "bottom right" : "bottom left",147 }}148 >149 <span150 aria-hidden151 className="pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover"152 style={{153 top: box?.top ?? 0,154 height: box?.height ?? 0,155 opacity: box && hovered !== null ? 1 : 0,156 transition:157 "top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease",158 }}159 />160 {items.map((item, i) => (161 <button162 key={item.name}163 type="button"164 ref={(el) => {165 rowRefs.current[i] = el;166 }}167 onMouseEnter={() => setHovered(i)}168 onClick={() => onPick(item.name)}169 className="relative z-10 flex h-7.5 w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 text-left"170 >171 <span className="min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink">{item.name}</span>172 {item.tag && <span className="shrink-0 text-[11px] text-ink-3">{item.tag}</span>}173 <span className={`shrink-0 text-ink ${item.name === value ? "" : "invisible"}`}>174 <CheckIcon />175 </span>176 </button>177 ))}178 </div>179 );180}181182/* ── chips used inside the condition card ── */183function SourceChip() {184 return (185 <span186 data-ui187 className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] bg-surface px-1.5 text-[12px] font-medium text-ink shadow-btn"188 >189 <span className="text-ink-2">190 <ConeIcon size={12} />191 </span>192 order193 </span>194 );195}196197function SelectChip({198 id,199 value,200 dot,201 items,202 width,203 align = "left",204 open,205 onToggle,206 onPick,207}: {208 id: string;209 value: string;210 dot?: boolean;211 items: { name: string; tag?: string }[];212 width: string;213 align?: "left" | "right";214 open: boolean;215 onToggle: (id: string) => void;216 onPick: (id: string, name: string) => void;217}) {218 return (219 <span data-ui className="relative inline-flex min-w-0">220 <button221 type="button"222 aria-expanded={open}223 onClick={() => onToggle(id)}224 className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 rounded-[6px] px-1.5225 text-[12px] font-medium text-ink transition-colors duration-100226 ${open ? "bg-hover-2" : "bg-field hover:bg-hover-2"}`}227 >228 {dot && <span className="size-1.5 shrink-0 rounded-full" style={{ background: AMBER }} />}229 <span className="min-w-0 truncate">{value}</span>230 <Chevron />231 </button>232 {open && (233 <Menu234 items={items}235 value={value}236 width={width}237 align={align}238 onPick={(name) => onPick(id, name)}239 />240 )}241 </span>242 );243}244245function ConditionBody() {246 const [values, setValues] = useState<Record<string, string>>({247 prop1: "flavor",248 val1: "Rocky Road",249 prop2: "topping",250 val2: "Brown butter bourbon brittle crunch",251 });252 const [open, setOpen] = useState<string | null>(null);253254 /* click anywhere else closes the menu */255 useEffect(() => {256 if (!open) return;257 const close = (event: PointerEvent) => {258 if (!(event.target as Element).closest("[data-ui]")) setOpen(null);259 };260 document.addEventListener("pointerdown", close);261 return () => document.removeEventListener("pointerdown", close);262 }, [open]);263264 const toggle = (id: string) => setOpen((current) => (current === id ? null : id));265 const pick = (id: string, name: string) => {266 setValues((current) => ({ ...current, [id]: name }));267 setOpen(null);268 };269270 const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => (271 <SelectChip272 id={id}273 value={values[id]}274 items={items}275 width={width}276 open={open === id}277 onToggle={toggle}278 onPick={pick}279 {...extra}280 />281 );282283 return (284 <div className="flex flex-col gap-1.5 px-3 py-2.5">285 <div className="flex min-w-0 items-center gap-1.5">286 <Handle />287 <span className="w-7 text-[12.5px] text-ink-2">If</span>288 <SourceChip />289 {chip("prop1", PROPERTIES.map((name) => ({ name })), "w-36")}290 <span className="text-[12.5px] text-ink-2">is</span>291 {chip("val1", FLAVORS, "w-44", { dot: true, align: "right" })}292 </div>293 <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5">294 <Handle />295 <span className="w-7 text-[12.5px] text-ink-2">and</span>296 <SourceChip />297 {chip("prop2", PROPERTIES.map((name) => ({ name })), "w-36")}298 <span className="text-[12.5px] text-ink-2">is</span>299 <span className="max-w-full pl-[49px]">300 {chip("val2", TOPPINGS, "w-64", { dot: true })}301 </span>302 </div>303 </div>304 );305}306307function StepBody({ node }: { node: StepNode }) {308 return (309 <div className="flex items-center gap-2.5 px-3 py-2.5">310 <span311 className="flex size-9 shrink-0 items-center justify-center rounded-[8px]"312 style={{313 background: mix(node.hue!, 12),314 color: node.hue,315 boxShadow: `0 0 0 1px ${mix(node.hue!, 20)}`,316 }}317 >318 <ConeIcon />319 </span>320 <span className="min-w-0 text-left">321 <span className="block truncate text-[13px] font-semibold text-ink">{node.title}</span>322 <span className="mt-0.5 block text-[12px] leading-snug text-ink-2">{node.caption}</span>323 </span>324 </div>325 );326}327328/* ── the canvas ── */329export default function Flowchart() {330 const canvasRef = useRef<HTMLDivElement>(null);331 const nodeRefs = useRef(new Map<string, HTMLElement>());332 const [width, setWidth] = useState(0);333 const [heights, setHeights] = useState<Record<string, number>>(EST_H);334 const [selected, setSelected] = useState<string | null>(null);335 const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({});336 const drag = useRef<{337 id: string;338 startX: number;339 startY: number;340 baseDx: number;341 baseDy: number;342 moved: boolean;343 } | null>(null);344345 useLayoutEffect(() => {346 const canvas = canvasRef.current;347 if (!canvas) return;348349 const measure = () => {350 setWidth(canvas.clientWidth);351 setHeights((prev) => {352 const next = { ...prev };353 let changed = false;354 nodeRefs.current.forEach((el, id) => {355 const h = el.offsetHeight;356 if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) {357 next[id] = h;358 changed = true;359 }360 });361 return changed ? next : prev;362 });363 };364365 measure();366 const observer = new ResizeObserver(measure);367 observer.observe(canvas);368 nodeRefs.current.forEach((el) => observer.observe(el));369 return () => observer.disconnect();370 }, []);371372 /* rows → y offsets from measured node heights */373 const rows = [...new Set(NODES.map((n) => n.row))].sort((a, b) => a - b);374 const rowH = rows.map((r) =>375 Math.max(...NODES.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)),376 );377 const rowY: number[] = [];378 rows.forEach((_, i) => {379 rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;380 });381 const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y;382383 const cw = width || 480;384 const place = (n: StepNode) => {385 const w = Math.min(n.w, cw * 0.92);386 const off = offsets[n.id];387 return {388 w,389 cx: n.x * cw + (off?.dx ?? 0),390 top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0),391 };392 };393394 /* card anchor points (pills sit above the card, so offset the top) */395 const anchors = (n: StepNode) => {396 const { cx, top } = place(n);397 return {398 top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) },399 bottom: { x: cx, y: top + (heights[n.id] ?? 90) },400 };401 };402403 const bezier = (edge: { from: string; to: string }) => {404 const from = anchors(NODES.find((n) => n.id === edge.from)!).bottom;405 const to = anchors(NODES.find((n) => n.id === edge.to)!).top;406 const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);407 return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;408 };409410 /* ── dragging ── */411 const onPointerDown = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {412 if ((event.target as Element).closest("[data-ui]")) return;413 const off = offsets[node.id];414 drag.current = {415 id: node.id,416 startX: event.clientX,417 startY: event.clientY,418 baseDx: off?.dx ?? 0,419 baseDy: off?.dy ?? 0,420 moved: false,421 };422 (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);423 };424425 const onPointerMove = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {426 const d = drag.current;427 if (!d || d.id !== node.id) return;428 const dx = d.baseDx + event.clientX - d.startX;429 const dy = d.baseDy + event.clientY - d.startY;430 if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return;431 d.moved = true;432433 /* keep the card inside the canvas */434 const { w } = place(node);435 const h = heights[node.id] ?? 90;436 const baseCx = node.x * cw;437 const baseTop = rowY[rows.indexOf(node.row)];438 const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8);439 const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8);440 setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } }));441 };442443 const onPointerUp = (node: StepNode) => () => {444 const d = drag.current;445 if (d?.id === node.id) {446 /* a real drag shouldn't also toggle selection */447 if (d.moved) setTimeout(() => (drag.current = null), 0);448 else drag.current = null;449 }450 };451452 const wasDragged = () => drag.current?.moved === true;453454 const isLit = (edge: { from: string; to: string }) =>455 selected === edge.from || selected === edge.to;456457 return (458 <div459 ref={canvasRef}460 className="relative w-full select-none overflow-hidden rounded-card bg-page shadow-hairline"461 style={{462 height: canvasH,463 backgroundImage: "radial-gradient(var(--line-strong) 1px, transparent 1.25px)",464 backgroundSize: "22px 22px",465 backgroundPosition: "center",466 }}467 >468 {/* connectors */}469 <svg width={cw} height={canvasH} className="pointer-events-none absolute inset-0">470 {EDGES.map((edge) => (471 <path472 key={`${edge.from}-${edge.to}`}473 d={bezier(edge)}474 fill="none"475 stroke={isLit(edge) ? "var(--accent)" : "var(--line-strong)"}476 strokeWidth="1.25"477 className="transition-[stroke] duration-150"478 />479 ))}480 </svg>481482 {/* nodes */}483 {NODES.map((node) => {484 const { w, cx, top } = place(node);485 const active = selected === node.id;486 return (487 <div488 key={node.id}489 ref={(el) => {490 if (el) nodeRefs.current.set(node.id, el);491 else nodeRefs.current.delete(node.id);492 }}493 onPointerDown={onPointerDown(node)}494 onPointerMove={onPointerMove(node)}495 onPointerUp={onPointerUp(node)}496 className="absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5"497 style={{ left: cx, top, width: w, zIndex: drag.current?.id === node.id ? 2 : 1 }}498 >499 {node.kind && (500 <span501 className="inline-flex h-6 items-center rounded-[6px] px-2 text-[11.5px] font-medium"502 style={{503 background: mix(node.kind.hue, 14, "var(--page)"),504 color: mix(node.kind.hue, 80, "var(--ink)"),505 }}506 >507 {node.kind.label}508 </span>509 )}510 {node.condition ? (511 <div className="w-full rounded-card bg-surface shadow-card transition-shadow duration-150 hover:shadow-raised">512 <ConditionBody />513 </div>514 ) : (515 <button516 type="button"517 onClick={() => {518 if (wasDragged()) return;519 setSelected(active ? null : node.id);520 }}521 aria-pressed={active}522 className={`w-full cursor-pointer rounded-card bg-surface text-left outline-none523 transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--accent)]524 ${525 active526 ? "shadow-[0_0_0_1.5px_var(--accent),0_2px_10px_rgba(0,0,0,0.045)]"527 : "shadow-card hover:shadow-raised"528 }`}529 >530 <StepBody node={node} />531 </button>532 )}533 </div>534 );535 })}536 </div>537 );538}539
Props & Specification
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | "default" | Visual state and layout presentation mode. |
| className | string | "" | Optional Tailwind CSS wrapper class overrides. |