{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "smart-edge", "title": "Smart Edge", "description": "A React Flow v12 edge that auto-connects to the nearest sides of its source and target nodes.", "dependencies": ["@xyflow/react", "clsx", "tailwind-merge", "lucide-react"], "registryDependencies": ["edge-geometry", "base-node"], "files": [ { "path": "src/registry/thornberry/components/smart-edge.tsx", "content": "\"use client\";\n\nimport { getSmoothStepPath, useStore } from \"@xyflow/react\";\nimport { memo, useMemo } from \"react\";\n\nimport {\n NODE_MIN_HEIGHT,\n NODE_WIDTH,\n} from \"@/registry/thornberry/components/base-node\";\nimport {\n EDGE_OFFSET,\n getBestConnectionPoints,\n} from \"@/registry/thornberry/lib/edge-geometry\";\n\nimport type { EdgeProps, ReactFlowState } from \"@xyflow/react\";\nimport type { NodeRect } from \"@/registry/thornberry/lib/edge-geometry\";\n\n/** Edge styling constants */\nconst EDGE_STROKE_WIDTH = 3;\n\ninterface EdgeNodeRects {\n source: NodeRect | null;\n target: NodeRect | null;\n}\n\n/**\n * Build a memoized selector that reads the absolute rect of the source\n * and target nodes from the v12 store.\n *\n * In @xyflow/react v12 nodes live in `state.nodeLookup` (a Map of\n * `InternalNode`). Absolute position comes from\n * `internals.positionAbsolute` and measured size from `measured`,\n * falling back to the node's relative `position` and the default\n * dimensions when a node has not been measured yet.\n */\nconst createEdgePositionsSelector = (sourceId: string, targetId: string) => {\n let prevResult: EdgeNodeRects | null = null;\n\n return (state: ReactFlowState): EdgeNodeRects => {\n const sourceNode = state.nodeLookup.get(sourceId);\n const targetNode = state.nodeLookup.get(targetId);\n\n const source: NodeRect | null = sourceNode\n ? {\n x: sourceNode.internals.positionAbsolute.x,\n y: sourceNode.internals.positionAbsolute.y,\n width: sourceNode.measured.width ?? NODE_WIDTH,\n height: sourceNode.measured.height ?? NODE_MIN_HEIGHT,\n }\n : null;\n\n const target: NodeRect | null = targetNode\n ? {\n x: targetNode.internals.positionAbsolute.x,\n y: targetNode.internals.positionAbsolute.y,\n width: targetNode.measured.width ?? NODE_WIDTH,\n height: targetNode.measured.height ?? NODE_MIN_HEIGHT,\n }\n : null;\n\n // Preserve referential equality when positions are unchanged\n if (\n prevResult &&\n prevResult.source?.x === source?.x &&\n prevResult.source?.y === source?.y &&\n prevResult.target?.x === target?.x &&\n prevResult.target?.y === target?.y\n ) {\n return prevResult;\n }\n\n prevResult = { source, target };\n return prevResult;\n };\n};\n\n/**\n * SmartEdge automatically connects to the nearest sides of its source\n * and target nodes, routing with a smooth step path.\n */\nexport const SmartEdge = memo(\n ({\n id,\n source,\n target,\n sourceX: fallbackSourceX,\n sourceY: fallbackSourceY,\n targetX: fallbackTargetX,\n targetY: fallbackTargetY,\n sourcePosition: fallbackSourcePosition,\n targetPosition: fallbackTargetPosition,\n style = {},\n markerEnd,\n label,\n labelStyle,\n labelShowBg = true,\n labelBgStyle,\n selected,\n }: EdgeProps) => {\n const selector = useMemo(\n () => createEdgePositionsSelector(source, target),\n [source, target],\n );\n const { source: sourceRect, target: targetRect } = useStore(selector);\n\n let path: string;\n let labelX: number;\n let labelY: number;\n\n if (!sourceRect || !targetRect) {\n // Fall back to the coordinates React Flow provides\n [path, labelX, labelY] = getSmoothStepPath({\n sourceX: fallbackSourceX,\n sourceY: fallbackSourceY,\n sourcePosition: fallbackSourcePosition,\n targetX: fallbackTargetX,\n targetY: fallbackTargetY,\n targetPosition: fallbackTargetPosition,\n borderRadius: 0,\n offset: EDGE_OFFSET,\n });\n } else {\n const {\n sourceX,\n sourceY,\n targetX,\n targetY,\n sourcePosition,\n targetPosition,\n } = getBestConnectionPoints(sourceRect, targetRect);\n\n [path, labelX, labelY] = getSmoothStepPath({\n sourceX,\n sourceY,\n sourcePosition,\n targetX,\n targetY,\n targetPosition,\n borderRadius: 0,\n offset: EDGE_OFFSET,\n });\n }\n\n const strokeColor = (style.stroke as string) || \"currentColor\";\n\n return (\n \n {/* Invisible wider path for easier selection */}\n \n\n {/* Glow effect for the selected state */}\n {selected && (\n \n )}\n\n {/* Main edge path */}\n \n\n {/* Optional label */}\n {label && (\n \n \n \n \n {label}\n \n \n \n \n )}\n \n );\n },\n);\n\nSmartEdge.displayName = \"SmartEdge\";\n", "type": "registry:ui" }, { "path": "src/registry/thornberry/components/base-node.tsx", "content": "\"use client\";\n\nimport { Handle } from \"@xyflow/react\";\nimport { memo } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { resolveTheme } from \"@/registry/thornberry/lib/node-themes\";\n\nimport type { Node, NodeProps, Position } from \"@xyflow/react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport type {\n NodeTheme,\n NodeThemeKey,\n} from \"@/registry/thornberry/lib/node-themes\";\n\n/**\n * Standard node dimensions for consistent canvas layout. The width is an\n * even multiple of the default grid size for clean center alignment.\n */\nexport const NODE_WIDTH = 300;\nexport const NODE_MIN_HEIGHT = 120;\n\n/** Connection handle configuration */\ninterface NodeHandleConfig {\n type: \"source\" | \"target\";\n position: Position;\n id?: string;\n className?: string;\n style?: CSSProperties;\n}\n\n/**\n * Node data shape consumed by the `base` node type. React Flow supplies\n * `selected` on the node props, so it is not part of the data payload.\n * Consumers add nodes as `{ type: \"base\", data: { theme, label, ... } }`.\n */\nexport interface BaseNodeData {\n /** Theme key or fully custom theme object */\n theme: NodeThemeKey | NodeTheme;\n /** Optional icon rendered in the header badge */\n icon?: LucideIcon;\n /** Node title */\n label: string;\n /** Optional secondary description under the label */\n description?: string;\n /** Optional badge text rendered in the header */\n badge?: string;\n /** Connection handles to render */\n handles?: NodeHandleConfig[];\n /** Optional body content */\n children?: ReactNode;\n /** Optional footer content */\n footer?: ReactNode;\n /** Click handler for the node */\n onClick?: () => void;\n /** Delete handler; when set, a delete button is shown on hover */\n onDelete?: () => void;\n // Index signature so the shape satisfies React Flow's `Record`-based node data\n [key: string]: unknown;\n}\n\n/**\n * Generic base node providing a consistent header (icon, label, optional\n * description and badge), optional body and footer, selection state, and\n * connection handles. It is a proper React Flow node component: content\n * comes from `data` and selection from props, so it can be registered\n * directly as a `nodeType` without an adapter. Styling is driven by a\n * semantic {@link NodeTheme}.\n */\nexport const BaseNode = memo(\n ({ data, selected }: NodeProps>) => {\n const {\n theme,\n icon: Icon,\n label,\n description,\n badge,\n handles = [],\n children,\n footer,\n onClick,\n onDelete,\n } = data;\n const t = resolveTheme(theme);\n\n return (\n {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault();\n onClick();\n }\n }\n : undefined\n }\n >\n {onDelete && (\n {\n e.stopPropagation();\n onDelete();\n }}\n >\n x\n \n )}\n\n {/* Header */}\n
\n
\n {Icon && (\n \n \n
\n )}\n
\n {label}\n {description && (\n \n {description}\n \n )}\n
\n
\n {badge && (\n \n {badge}\n \n )}\n \n\n {/* Body */}\n {children &&
{children}
}\n\n {/* Footer */}\n {footer &&
{footer}
}\n\n {/* Handles */}\n {handles.map((handle, index) => (\n \n ))}\n \n );\n },\n);\n\nBaseNode.displayName = \"BaseNode\";\n", "type": "registry:component", "target": "" }, { "path": "src/registry/thornberry/lib/edge-geometry.ts", "content": "import { Position } from \"@xyflow/react\";\n\n/** Smooth step path offset for routed edges */\nexport const EDGE_OFFSET = 20;\n\n/** Penalty applied when a side pair aligns with the node layout direction */\nconst DIRECTION_BONUS = 50;\n\n/** A node rectangle expressed as top-left corner plus size */\nexport interface NodeRect {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\ninterface Point {\n x: number;\n y: number;\n}\n\n/** Center point of each side of a node rectangle */\nfunction getNodeSideCenters(rect: NodeRect) {\n return {\n top: { x: rect.x + rect.width / 2, y: rect.y },\n bottom: { x: rect.x + rect.width / 2, y: rect.y + rect.height },\n left: { x: rect.x, y: rect.y + rect.height / 2 },\n right: { x: rect.x + rect.width, y: rect.y + rect.height / 2 },\n };\n}\n\n/** Euclidean distance between two points */\nfunction distance(p1: Point, p2: Point): number {\n return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);\n}\n\n/** A connection point pair with full geometry */\ninterface ConnectionPoints {\n sourceX: number;\n sourceY: number;\n targetX: number;\n targetY: number;\n sourcePosition: Position;\n targetPosition: Position;\n}\n\n/**\n * Determine the best connection point coordinates between two nodes.\n *\n * Scores the four cardinal side pairs (Bottom->Top, Top->Bottom,\n * Right->Left, Left->Right) by distance, with a bonus when the pair\n * aligns with the relative layout direction of the two nodes, and\n * returns the lowest-scoring pair's coordinates.\n */\nexport function getBestConnectionPoints(\n sourceRect: NodeRect,\n targetRect: NodeRect,\n): ConnectionPoints {\n const sourceSides = getNodeSideCenters(sourceRect);\n const targetSides = getNodeSideCenters(targetRect);\n\n type PositionPair = {\n sourcePos: Position;\n targetPos: Position;\n source: Point;\n target: Point;\n };\n // a fixed 4-tuple so positionPairs[0] is provably defined without a non-null assertion\n const positionPairs: [\n PositionPair,\n PositionPair,\n PositionPair,\n PositionPair,\n ] = [\n {\n sourcePos: Position.Bottom,\n targetPos: Position.Top,\n source: sourceSides.bottom,\n target: targetSides.top,\n },\n {\n sourcePos: Position.Top,\n targetPos: Position.Bottom,\n source: sourceSides.top,\n target: targetSides.bottom,\n },\n {\n sourcePos: Position.Right,\n targetPos: Position.Left,\n source: sourceSides.right,\n target: targetSides.left,\n },\n {\n sourcePos: Position.Left,\n targetPos: Position.Right,\n source: sourceSides.left,\n target: targetSides.right,\n },\n ];\n\n let bestPair = positionPairs[0];\n let bestScore = Number.POSITIVE_INFINITY;\n\n for (const pair of positionPairs) {\n const dist = distance(pair.source, pair.target);\n let penalty = 0;\n\n if (\n pair.sourcePos === Position.Bottom &&\n pair.targetPos === Position.Top &&\n targetRect.y > sourceRect.y + sourceRect.height\n ) {\n penalty -= DIRECTION_BONUS;\n }\n if (\n pair.sourcePos === Position.Top &&\n pair.targetPos === Position.Bottom &&\n targetRect.y + targetRect.height < sourceRect.y\n ) {\n penalty -= DIRECTION_BONUS;\n }\n if (\n pair.sourcePos === Position.Right &&\n pair.targetPos === Position.Left &&\n targetRect.x > sourceRect.x + sourceRect.width\n ) {\n penalty -= DIRECTION_BONUS;\n }\n if (\n pair.sourcePos === Position.Left &&\n pair.targetPos === Position.Right &&\n targetRect.x + targetRect.width < sourceRect.x\n ) {\n penalty -= DIRECTION_BONUS;\n }\n\n const score = dist + penalty;\n if (score < bestScore) {\n bestScore = score;\n bestPair = pair;\n }\n }\n\n return {\n sourceX: bestPair.source.x,\n sourceY: bestPair.source.y,\n targetX: bestPair.target.x,\n targetY: bestPair.target.y,\n sourcePosition: bestPair.sourcePos,\n targetPosition: bestPair.targetPos,\n };\n}\n\n/**\n * Choose which sides of the source and target nodes an edge should\n * connect to, based purely on their geometry.\n */\nexport function chooseConnectionSides(\n source: NodeRect,\n target: NodeRect,\n): { sourcePosition: Position; targetPosition: Position } {\n const { sourcePosition, targetPosition } = getBestConnectionPoints(\n source,\n target,\n );\n return { sourcePosition, targetPosition };\n}\n", "type": "registry:lib", "target": "" }, { "path": "src/lib/utils.ts", "content": "import { clsx } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nimport type { ClassValue } from \"clsx\";\n\nexport const cn = (...inputs: ClassValue[]) => {\n return twMerge(clsx(inputs));\n};\n", "type": "registry:lib", "target": "" }, { "path": "src/registry/thornberry/lib/node-themes.ts", "content": "/**\n * Visual theme for a node, expressed as Tailwind utility class strings.\n *\n * Consumers either pick one of the semantic keys in {@link nodeThemes} or\n * pass a fully custom object of this shape.\n */\nexport interface NodeTheme {\n /** Border classes for the node container */\n border: string;\n /** Background classes for the node container */\n bg: string;\n /** Background classes for the icon badge */\n iconBg: string;\n /** Text/foreground classes for the icon */\n iconText: string;\n /** Accent text classes (labels, emphasis) */\n accent: string;\n /** Background classes applied to connection handles */\n handleColor: string;\n /** Optional box-shadow/glow classes */\n glow?: string;\n}\n\n/**\n * Small set of generic, semantic node themes driven by the design\n * system's CSS-variable tokens (card, primary, muted, destructive).\n *\n * `success` and `warning` are not part of the base token set, so they\n * fall back to fixed emerald/amber hues to keep their conventional\n * meaning.\n */\nexport const nodeThemes = {\n default: {\n border: \"border-border\",\n bg: \"bg-card\",\n iconBg: \"bg-secondary\",\n iconText: \"text-secondary-foreground\",\n accent: \"text-foreground\",\n handleColor: \"!bg-muted-foreground\",\n },\n accent: {\n border: \"border-primary\",\n bg: \"bg-primary/10\",\n iconBg: \"bg-primary\",\n iconText: \"text-primary-foreground\",\n accent: \"text-primary\",\n handleColor: \"!bg-primary\",\n glow: \"shadow-[0_0_15px_rgba(59,130,246,0.15)]\",\n },\n // success/warning have no design-system token; use fixed hues\n success: {\n border: \"border-emerald-300/60 dark:border-emerald-600/40\",\n bg: \"bg-emerald-50 dark:bg-emerald-950\",\n iconBg: \"bg-emerald-600 dark:bg-emerald-500\",\n iconText: \"text-white\",\n accent: \"text-emerald-600 dark:text-emerald-400\",\n handleColor: \"!bg-emerald-500\",\n glow: \"shadow-[0_0_15px_rgba(16,185,129,0.15)]\",\n },\n // success/warning have no design-system token; use fixed hues\n warning: {\n border: \"border-amber-300/60 dark:border-amber-600/40\",\n bg: \"bg-amber-50 dark:bg-amber-950\",\n iconBg: \"bg-amber-500 dark:bg-amber-600\",\n iconText: \"text-white\",\n accent: \"text-amber-600 dark:text-amber-400\",\n handleColor: \"!bg-amber-500\",\n glow: \"shadow-[0_0_15px_rgba(245,158,11,0.15)]\",\n },\n danger: {\n border: \"border-destructive\",\n bg: \"bg-destructive/10\",\n iconBg: \"bg-destructive\",\n iconText: \"text-white\",\n accent: \"text-destructive\",\n handleColor: \"!bg-destructive\",\n glow: \"shadow-[0_0_15px_rgba(239,68,68,0.15)]\",\n },\n muted: {\n border: \"border-border\",\n bg: \"bg-muted\",\n iconBg: \"bg-muted-foreground\",\n iconText: \"text-background\",\n accent: \"text-muted-foreground\",\n handleColor: \"!bg-muted-foreground\",\n },\n} as const satisfies Record;\n\n/** Union of the built-in semantic theme keys */\nexport type NodeThemeKey = keyof typeof nodeThemes;\n\n/**\n * Resolve a theme input to a concrete {@link NodeTheme}.\n *\n * A string key is looked up in {@link nodeThemes}; a custom object is\n * returned unchanged.\n */\nexport const resolveTheme = (theme: NodeThemeKey | NodeTheme): NodeTheme =>\n typeof theme === \"string\" ? nodeThemes[theme] : theme;\n", "type": "registry:lib", "target": "" } ], "type": "registry:ui" }