18 lines
18 KiB
JSON
18 lines
18 KiB
JSON
{
|
||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||
"name": "flowchart",
|
||
"type": "registry:component",
|
||
"title": "Flowchart",
|
||
"description": "Workflow trigger and condition steps on a dotted canvas.",
|
||
"registryDependencies": [
|
||
"https://www.beautifului.dev/r/foundation.json"
|
||
],
|
||
"files": [
|
||
{
|
||
"path": "components/primitives/Flowchart.tsx",
|
||
"content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { useLayoutEffect } from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FLOWCHART — an agent workflow on a dotted editor canvas.\n * Two steps: a Trigger card and an If/Else condition card,\n * joined by a measured connector. Cards drag anywhere on\n * the canvas; the connector follows. Condition chips open\n * real dropdowns (same menu as the PromptBar model picker).\n * ───────────────────────────────────────────────────────── */\n\nconst PURPLE = \"#9a5cff\";\nconst AMBER = \"#f09a2f\";\n\nconst mix = (hue: string, pct: number, base = \"var(--surface)\") =>\n `color-mix(in srgb, ${hue} ${pct}%, ${base})`;\n\n/* ── layout constants ── */\nconst PAD_Y = 24;\nconst ROW_GAP = 64;\nconst PILL_OFFSET = 30; // kind pill + gap above a card\n\nexport type StepNode = {\n id: string;\n row: number;\n x: number; // 0–1 center of the node\n w: number;\n kind?: { label: string; hue: string };\n hue?: string;\n title?: string;\n caption?: string;\n condition?: boolean; // renders the if/else chip rows instead\n};\n\nconst NODES: StepNode[] = [\n {\n id: \"trigger\",\n row: 0,\n x: 0.5,\n w: 300,\n kind: { label: \"Trigger\", hue: PURPLE },\n hue: PURPLE,\n title: \"New order created\",\n caption: \"Trigger when a new order is created\",\n },\n {\n id: \"cond\",\n row: 1,\n x: 0.5,\n w: 356,\n kind: { label: \"If / Else\", hue: AMBER },\n condition: true,\n },\n];\n\nconst EDGES = [{ from: \"trigger\", to: \"cond\" }];\n\n/* estimated heights for the first paint; measured immediately after */\nconst EST_H: Record<string, number> = { trigger: 92, cond: 134 };\n\nconst PROPERTIES = [\"flavor\", \"topping\", \"size\", \"scoops\"];\nconst FLAVORS = [\n { name: \"Rocky Road\", tag: \"Classic\" },\n { name: \"Mint Chip\", tag: \"Classic\" },\n { name: \"Pistachio\", tag: \"Seasonal\" },\n { name: \"Bubblegum\", tag: \"Retro\" },\n];\nconst TOPPINGS = [\n { name: \"Brown butter bourbon brittle crunch\" },\n { name: \"Rainbow sprinkles\" },\n { name: \"Hot fudge\" },\n { name: \"Candied pecans\" },\n];\n\n/* ── icons ── */\nfunction ConeIcon({ size = 16 }: { size?: number }) {\n return (\n <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"m7 11 4.08 10.35a1 1 0 0 0 1.84 0L17 11\" />\n <path d=\"M17 7A5 5 0 0 0 7 7\" />\n <path d=\"M17 7a2 2 0 0 1 0 4H7a2 2 0 0 1 0-4\" />\n </svg>\n );\n}\n\nfunction Chevron() {\n return (\n <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\">\n <path d=\"m6 9 6 6 6-6\" />\n </svg>\n );\n}\n\nfunction Handle() {\n return (\n <svg width=\"10\" height=\"16\" viewBox=\"0 0 10 16\" className=\"shrink-0 cursor-grab text-ink-3/70\">\n {[3, 8, 13].flatMap((y) => [\n <circle key={`l${y}`} cx=\"3\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n <circle key={`r${y}`} cx=\"7.5\" cy={y} r=\"1.1\" fill=\"currentColor\" />,\n ])}\n </svg>\n );\n}\n\nfunction CheckIcon() {\n return (\n <svg width=\"13\" height=\"13\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n <path d=\"M20 6L9 17l-5-5\" />\n </svg>\n );\n}\n\n/* ── dropdown menu — same pattern as the PromptBar model picker ── */\nfunction Menu({\n items,\n value,\n width,\n align,\n onPick,\n}: {\n items: { name: string; tag?: string }[];\n value: string;\n width: string;\n align: \"left\" | \"right\";\n onPick: (name: string) => void;\n}) {\n const [hovered, setHovered] = useState<number | null>(null);\n const rowRefs = useRef<(HTMLButtonElement | null)[]>([]);\n const [box, setBox] = useState<{ top: number; height: number } | null>(null);\n\n const valueIndex = items.findIndex((item) => item.name === value);\n useLayoutEffect(() => {\n const row = rowRefs.current[hovered ?? valueIndex];\n if (row) setBox({ top: row.offsetTop, height: row.offsetHeight });\n }, [hovered, valueIndex]);\n\n return (\n <div\n onMouseLeave={() => setHovered(null)}\n className={`absolute bottom-full z-20 mb-1.5 rounded-[10px] bg-surface p-1 shadow-raised ${width}\n ${align === \"right\" ? \"right-0\" : \"left-0\"}`}\n style={{\n animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\",\n transformOrigin: align === \"right\" ? \"bottom right\" : \"bottom left\",\n }}\n >\n <span\n aria-hidden\n className=\"pointer-events-none absolute inset-x-1 rounded-[6px] bg-hover\"\n style={{\n top: box?.top ?? 0,\n height: box?.height ?? 0,\n opacity: box && hovered !== null ? 1 : 0,\n transition:\n \"top 220ms cubic-bezier(0.23,1,0.32,1), height 220ms cubic-bezier(0.23,1,0.32,1), opacity 150ms ease\",\n }}\n />\n {items.map((item, i) => (\n <button\n key={item.name}\n type=\"button\"\n ref={(el) => {\n rowRefs.current[i] = el;\n }}\n onMouseEnter={() => setHovered(i)}\n onClick={() => onPick(item.name)}\n className=\"relative z-10 flex h-7.5 w-full cursor-pointer items-center gap-2 rounded-[6px] px-2 text-left\"\n >\n <span className=\"min-w-0 flex-1 truncate text-[12.5px] font-medium text-ink\">{item.name}</span>\n {item.tag && <span className=\"shrink-0 text-[11px] text-ink-3\">{item.tag}</span>}\n <span className={`shrink-0 text-ink ${item.name === value ? \"\" : \"invisible\"}`}>\n <CheckIcon />\n </span>\n </button>\n ))}\n </div>\n );\n}\n\n/* ── chips used inside the condition card ── */\nfunction SourceChip() {\n return (\n <span\n data-ui\n 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\"\n >\n <span className=\"text-ink-2\">\n <ConeIcon size={12} />\n </span>\n order\n </span>\n );\n}\n\nfunction SelectChip({\n id,\n value,\n dot,\n items,\n width,\n align = \"left\",\n open,\n onToggle,\n onPick,\n}: {\n id: string;\n value: string;\n dot?: boolean;\n items: { name: string; tag?: string }[];\n width: string;\n align?: \"left\" | \"right\";\n open: boolean;\n onToggle: (id: string) => void;\n onPick: (id: string, name: string) => void;\n}) {\n return (\n <span data-ui className=\"relative inline-flex min-w-0\">\n <button\n type=\"button\"\n aria-expanded={open}\n onClick={() => onToggle(id)}\n className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 rounded-[6px] px-1.5\n text-[12px] font-medium text-ink transition-colors duration-100\n ${open ? \"bg-hover-2\" : \"bg-field hover:bg-hover-2\"}`}\n >\n {dot && <span className=\"size-1.5 shrink-0 rounded-full\" style={{ background: AMBER }} />}\n <span className=\"min-w-0 truncate\">{value}</span>\n <Chevron />\n </button>\n {open && (\n <Menu\n items={items}\n value={value}\n width={width}\n align={align}\n onPick={(name) => onPick(id, name)}\n />\n )}\n </span>\n );\n}\n\nfunction ConditionBody() {\n const [values, setValues] = useState<Record<string, string>>({\n prop1: \"flavor\",\n val1: \"Rocky Road\",\n prop2: \"topping\",\n val2: \"Brown butter bourbon brittle crunch\",\n });\n const [open, setOpen] = useState<string | null>(null);\n\n /* click anywhere else closes the menu */\n useEffect(() => {\n if (!open) return;\n const close = (event: PointerEvent) => {\n if (!(event.target as Element).closest(\"[data-ui]\")) setOpen(null);\n };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, [open]);\n\n const toggle = (id: string) => setOpen((current) => (current === id ? null : id));\n const pick = (id: string, name: string) => {\n setValues((current) => ({ ...current, [id]: name }));\n setOpen(null);\n };\n\n const chip = (id: string, items: { name: string; tag?: string }[], width: string, extra?: object) => (\n <SelectChip\n id={id}\n value={values[id]}\n items={items}\n width={width}\n open={open === id}\n onToggle={toggle}\n onPick={pick}\n {...extra}\n />\n );\n\n return (\n <div className=\"flex flex-col gap-1.5 px-3 py-2.5\">\n <div className=\"flex min-w-0 items-center gap-1.5\">\n <Handle />\n <span className=\"w-7 text-[12.5px] text-ink-2\">If</span>\n <SourceChip />\n {chip(\"prop1\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n <span className=\"text-[12.5px] text-ink-2\">is</span>\n {chip(\"val1\", FLAVORS, \"w-44\", { dot: true, align: \"right\" })}\n </div>\n <div className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5\">\n <Handle />\n <span className=\"w-7 text-[12.5px] text-ink-2\">and</span>\n <SourceChip />\n {chip(\"prop2\", PROPERTIES.map((name) => ({ name })), \"w-36\")}\n <span className=\"text-[12.5px] text-ink-2\">is</span>\n <span className=\"max-w-full pl-[49px]\">\n {chip(\"val2\", TOPPINGS, \"w-64\", { dot: true })}\n </span>\n </div>\n </div>\n );\n}\n\nfunction StepBody({ node }: { node: StepNode }) {\n return (\n <div className=\"flex items-center gap-2.5 p-2.5\">\n <span\n className=\"flex size-9 shrink-0 items-center justify-center rounded-[8px]\"\n style={{\n background: mix(node.hue!, 12),\n color: node.hue,\n boxShadow: `0 0 0 1px ${mix(node.hue!, 20)}`,\n }}\n >\n <ConeIcon />\n </span>\n <span className=\"min-w-0 text-left\">\n <span className=\"block truncate text-[13px] font-semibold leading-tight text-ink\">{node.title}</span>\n <span className=\"mt-0.5 block text-[12px] leading-snug text-ink-2\">{node.caption}</span>\n </span>\n </div>\n );\n}\n\n/* ── the canvas ── */\nexport default function Flowchart({ steps = NODES }: { steps?: StepNode[]; variant?: string } = {}) {\n const canvasRef = useRef<HTMLDivElement>(null);\n const nodeRefs = useRef(new Map<string, HTMLElement>());\n const [width, setWidth] = useState(0);\n const [heights, setHeights] = useState<Record<string, number>>(EST_H);\n const [selected, setSelected] = useState<string | null>(null);\n const [offsets, setOffsets] = useState<Record<string, { dx: number; dy: number }>>({});\n const drag = useRef<{\n id: string;\n startX: number;\n startY: number;\n baseDx: number;\n baseDy: number;\n moved: boolean;\n } | null>(null);\n\n useLayoutEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const measure = () => {\n setWidth(canvas.clientWidth);\n setHeights((prev) => {\n const next = { ...prev };\n let changed = false;\n nodeRefs.current.forEach((el, id) => {\n const h = el.offsetHeight;\n if (h && Math.abs(h - (next[id] ?? 0)) > 0.5) {\n next[id] = h;\n changed = true;\n }\n });\n return changed ? next : prev;\n });\n };\n\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(canvas);\n nodeRefs.current.forEach((el) => observer.observe(el));\n return () => observer.disconnect();\n }, []);\n\n /* rows → y offsets from measured node heights */\n const rows = [...new Set(steps.map((n) => n.row))].sort((a, b) => a - b);\n const rowH = rows.map((r) =>\n Math.max(...steps.filter((n) => n.row === r).map((n) => heights[n.id] ?? 90)),\n );\n const rowY: number[] = [];\n rows.forEach((_, i) => {\n rowY[i] = i === 0 ? PAD_Y : rowY[i - 1] + rowH[i - 1] + ROW_GAP;\n });\n const canvasH = rowY[rows.length - 1] + rowH[rows.length - 1] + PAD_Y;\n\n const cw = width || 480;\n const place = (n: StepNode) => {\n const w = Math.min(n.w, cw * 0.92);\n const off = offsets[n.id];\n return {\n w,\n cx: n.x * cw + (off?.dx ?? 0),\n top: rowY[rows.indexOf(n.row)] + (off?.dy ?? 0),\n };\n };\n\n /* card anchor points (pills sit above the card, so offset the top) */\n const anchors = (n: StepNode) => {\n const { cx, top } = place(n);\n return {\n top: { x: cx, y: top + (n.kind ? PILL_OFFSET : 0) },\n bottom: { x: cx, y: top + (heights[n.id] ?? 90) },\n };\n };\n\n const bezier = (edge: { from: string; to: string }) => {\n const from = anchors(steps.find((n) => n.id === edge.from)!).bottom;\n const to = anchors(steps.find((n) => n.id === edge.to)!).top;\n const k = Math.min(Math.max(Math.abs(to.y - from.y) * 0.55, 24), 84);\n return `M ${from.x} ${from.y} C ${from.x} ${from.y + k}, ${to.x} ${to.y - k}, ${to.x} ${to.y}`;\n };\n\n /* ── dragging ── */\n const onPointerDown = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {\n if ((event.target as Element).closest(\"[data-ui]\")) return;\n const off = offsets[node.id];\n drag.current = {\n id: node.id,\n startX: event.clientX,\n startY: event.clientY,\n baseDx: off?.dx ?? 0,\n baseDy: off?.dy ?? 0,\n moved: false,\n };\n (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);\n };\n\n const onPointerMove = (node: StepNode) => (event: React.PointerEvent<HTMLDivElement>) => {\n const d = drag.current;\n if (!d || d.id !== node.id) return;\n const dx = d.baseDx + event.clientX - d.startX;\n const dy = d.baseDy + event.clientY - d.startY;\n if (!d.moved && Math.hypot(dx - d.baseDx, dy - d.baseDy) < 3) return;\n d.moved = true;\n\n /* keep the card inside the canvas */\n const { w } = place(node);\n const h = heights[node.id] ?? 90;\n const baseCx = node.x * cw;\n const baseTop = rowY[rows.indexOf(node.row)];\n const cx = Math.min(Math.max(baseCx + dx, w / 2 + 8), cw - w / 2 - 8);\n const top = Math.min(Math.max(baseTop + dy, 8), canvasH - h - 8);\n setOffsets((current) => ({ ...current, [node.id]: { dx: cx - baseCx, dy: top - baseTop } }));\n };\n\n const onPointerUp = (node: StepNode) => () => {\n const d = drag.current;\n if (d?.id === node.id) {\n /* a real drag shouldn't also toggle selection */\n if (d.moved) setTimeout(() => (drag.current = null), 0);\n else drag.current = null;\n }\n };\n\n const wasDragged = () => drag.current?.moved === true;\n\n const isLit = (edge: { from: string; to: string }) =>\n selected === edge.from || selected === edge.to;\n\n return (\n <div\n ref={canvasRef}\n className=\"relative w-full select-none overflow-hidden rounded-card bg-page shadow-hairline\"\n style={{\n height: canvasH,\n backgroundImage: \"radial-gradient(var(--line-strong) 1px, transparent 1.25px)\",\n backgroundSize: \"22px 22px\",\n backgroundPosition: \"center\",\n }}\n >\n {/* connectors */}\n <svg width={cw} height={canvasH} className=\"pointer-events-none absolute inset-0\">\n {EDGES.map((edge) => (\n <path\n key={`${edge.from}-${edge.to}`}\n d={bezier(edge)}\n fill=\"none\"\n stroke={isLit(edge) ? \"var(--accent)\" : \"var(--line-strong)\"}\n strokeWidth=\"1.25\"\n className=\"transition-[stroke] duration-150\"\n />\n ))}\n </svg>\n\n {/* nodes */}\n {steps.map((node) => {\n const { w, cx, top } = place(node);\n const active = selected === node.id;\n return (\n <div\n key={node.id}\n ref={(el) => {\n if (el) nodeRefs.current.set(node.id, el);\n else nodeRefs.current.delete(node.id);\n }}\n onPointerDown={onPointerDown(node)}\n onPointerMove={onPointerMove(node)}\n onPointerUp={onPointerUp(node)}\n className=\"absolute flex -translate-x-1/2 touch-none flex-col items-start gap-1.5\"\n style={{ left: cx, top, width: w, zIndex: drag.current?.id === node.id ? 2 : 1 }}\n >\n {node.kind && (\n <span\n className=\"inline-flex h-6 items-center rounded-[6px] px-2 text-[11.5px] font-medium\"\n style={{\n background: mix(node.kind.hue, 14, \"var(--page)\"),\n color: mix(node.kind.hue, 80, \"var(--ink)\"),\n }}\n >\n {node.kind.label}\n </span>\n )}\n {node.condition ? (\n <div className=\"w-full rounded-[18px] bg-surface shadow-card transition-shadow duration-150 hover:shadow-raised\">\n <ConditionBody />\n </div>\n ) : (\n <button\n type=\"button\"\n onClick={() => {\n if (wasDragged()) return;\n setSelected(active ? null : node.id);\n }}\n aria-pressed={active}\n className={`w-full cursor-pointer rounded-[18px] bg-surface text-left outline-none\n transition-shadow duration-150 focus-visible:shadow-[0_0_0_1.5px_var(--accent)]\n ${\n active\n ? \"shadow-[0_0_0_1.5px_var(--accent),0_2px_10px_rgba(0,0,0,0.045)]\"\n : \"shadow-card hover:shadow-raised\"\n }`}\n >\n <StepBody node={node} />\n </button>\n )}\n </div>\n );\n })}\n </div>\n );\n}\n",
|
||
"type": "registry:component",
|
||
"target": "components/primitives/Flowchart.tsx"
|
||
}
|
||
]
|
||
} |