From 0a12f4dfb825744900ccc53e97e021efe85b169e Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Tue, 25 Nov 2025 14:35:57 -0500 Subject: [PATCH] Add a RadialMenu component (#4096) --- .../src/components/RadialMenu.tsx | 233 ++++++++++++++++++ .../editor/edges/EdgeWithAddButton.tsx | 82 ++++-- .../nodes/NodeAdderNode/NodeAdderNode.tsx | 82 ++++-- 3 files changed, 359 insertions(+), 38 deletions(-) create mode 100644 skyvern-frontend/src/components/RadialMenu.tsx diff --git a/skyvern-frontend/src/components/RadialMenu.tsx b/skyvern-frontend/src/components/RadialMenu.tsx new file mode 100644 index 00000000..77e8b49b --- /dev/null +++ b/skyvern-frontend/src/components/RadialMenu.tsx @@ -0,0 +1,233 @@ +import { useState, useRef, useEffect, ReactNode } from "react"; + +export interface RadialMenuItem { + id: string; + enabled?: boolean; + hidden?: boolean; + text?: string; + icon: React.ReactNode; + onClick: () => void; +} + +interface RadialMenuProps { + items: RadialMenuItem[]; + children: ReactNode; + /** + * The size of the buttons in CSS pixel units (e.g., "40px"). If not provided, + * defaults to "40px". + */ + buttonSize?: string; + /** + * The gap between items in degrees. If not provided, items are evenly spaced + * around the circle. + */ + gap?: number; + /** + * The radius of the radial menu, in CSS pixel units (e.g., "100px"). + */ + radius?: string; + /** + * The starting angle offset in degrees for the first item. If not provided, + * defaults to 0 degrees (top of the circle). + */ + startAt?: number; + /** + * If true, rotates the text so its baseline runs parallel to the radial line. + */ + rotateText?: boolean; +} + +const proportionalAngle = ( + index: number, + numItems: number, + startAtDegrees: number = 0, +) => { + const normalizedStart = ((startAtDegrees % 360) + 360) % 360; + const startRadians = (normalizedStart * Math.PI) / 180; + + const angleStep = (2 * Math.PI) / numItems; + const angle = angleStep * index - Math.PI / 2 + startRadians; + + return angle; +}; + +const gappedAngle = ( + index: number, + gapDegrees: number, + startAtDegrees: number = 0, +) => { + const normalizedGap = ((gapDegrees % 360) + 360) % 360; + const normalizedStart = ((startAtDegrees % 360) + 360) % 360; + const gapRadians = (normalizedGap * Math.PI) / 180; + const startRadians = (normalizedStart * Math.PI) / 180; + + // each item is the previous item's angle + gap tart from top (-PI/2) + startAt offset, + // then add gap * index + const angle = -Math.PI / 2 + startRadians + index * gapRadians; + + return angle; +}; + +export function RadialMenu({ + items, + children, + buttonSize, + radius, + gap, + startAt, + rotateText, +}: RadialMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [calculatedRadius, setCalculatedRadius] = useState(100); + const wrapperRef = useRef(null); + const dom = { + root: useRef(null), + }; + + // effect to calculate radius based on wrapped component size if not provided + useEffect(() => { + if (!radius && wrapperRef.current) { + const { width, height } = wrapperRef.current.getBoundingClientRect(); + const minDimension = Math.min(width, height); + setCalculatedRadius(minDimension); + } else if (radius) { + const numericRadius = parseFloat(radius); + setCalculatedRadius(numericRadius); + } + }, [radius, children]); + + const radiusValue = radius || `${calculatedRadius}px`; + const numRadius = parseFloat(radiusValue); + const visibleItems = items.filter((item) => !item.hidden); + const padSize = numRadius * 2.75; + + return ( +
{ + // only close if we're leaving the entire component area + const rect = dom.root.current?.getBoundingClientRect(); + if (!rect) return; + + const { clientX, clientY } = e; + const padding = padSize / 2; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // if mouse is outside the pad area, we deactivate + const distance = Math.sqrt( + Math.pow(clientX - centerX, 2) + Math.pow(clientY - centerY, 2), + ); + + if (distance > padding) { + setIsOpen(false); + } + }} + > + {/* a pad (buffer) to increase the deactivation area when leaving the component */} +
+ +
{ + e.preventDefault(); + e.stopPropagation(); + setIsOpen(!isOpen); + }} + onMouseEnter={() => { + setIsOpen(true); + }} + > +
{children}
+
+ + {visibleItems.map((item, index) => { + const angle = + gap !== undefined + ? gappedAngle(index, gap, startAt) + : proportionalAngle(index, visibleItems.length, startAt); + const x = Math.cos(angle) * parseFloat(radiusValue); + const y = Math.sin(angle) * parseFloat(radiusValue); + const isEnabled = item.enabled !== false; + + // calculate text offset along the radial line + const textDistance = 0.375 * numRadius; + const textX = Math.cos(angle) * textDistance; + const textY = Math.sin(angle) * textDistance; + + // convert angle from radians to degrees for CSS rotation + const angleDegrees = (angle * 180) / Math.PI; + // normalize angle to 0-360 range + const normalizedAngle = ((angleDegrees % 360) + 360) % 360; + // flip text if it would be upside-down (between 90° and 270°) + const textTransform = + normalizedAngle > 90 && normalizedAngle < 270 + ? "scaleY(-1) scaleX(-1)" + : "scaleY(1)"; + + return ( + <> + + {item.text && ( +
+ + {item.text} + +
+ )} + + ); + })} +
+ ); +} diff --git a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx index 545f10af..4dfd57ff 100644 --- a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx @@ -1,6 +1,4 @@ -import { Button } from "@/components/ui/button"; -import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; -import { PlusIcon } from "@radix-ui/react-icons"; +import { SquareIcon, PlusIcon } from "@radix-ui/react-icons"; import { BaseEdge, EdgeLabelRenderer, @@ -8,6 +6,13 @@ import { getBezierPath, useNodes, } from "@xyflow/react"; + +import { Button } from "@/components/ui/button"; +import { RadialMenu } from "@/components/RadialMenu"; +import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser"; +import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import { useDebugStore } from "@/store/useDebugStore"; + import { REACT_FLOW_EDGE_Z_INDEX } from "../constants"; function EdgeWithAddButton({ @@ -22,6 +27,8 @@ function EdgeWithAddButton({ style = {}, markerEnd, }: EdgeProps) { + const debugStore = useDebugStore(); + const isSkyvernUser = useIsSkyvernUser(); const nodes = useNodes(); const [edgePath, labelX, labelY] = getBezierPath({ sourceX, @@ -36,6 +43,28 @@ function EdgeWithAddButton({ ); const sourceNode = nodes.find((node) => node.id === source); + const onAdd = () => { + setWorkflowPanelState({ + active: true, + content: "nodeLibrary", + data: { + previous: source, + next: target, + parent: sourceNode?.parentId, + }, + }); + }; + + const adder = ( + + ); + return ( <> @@ -52,23 +81,38 @@ function EdgeWithAddButton({ }} className="nodrag nopan" > - + { + id: "2", + icon: , + enabled: false, + text: "Record Browser", + onClick: () => { + console.log("Record"); + }, + }, + ]} + buttonSize="25px" + radius="50px" + startAt={72.5} + gap={35} + rotateText={true} + > + {adder} + + ) : ( + adder + )}
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx index 92407c99..a2d0e3cb 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx @@ -1,14 +1,46 @@ +import { SquareIcon, PlusIcon } from "@radix-ui/react-icons"; import { Handle, NodeProps, Position, useEdges } from "@xyflow/react"; -import type { NodeAdderNode } from "./types"; -import { PlusIcon } from "@radix-ui/react-icons"; + +import { RadialMenu } from "@/components/RadialMenu"; +import { useIsSkyvernUser } from "@/hooks/useIsSkyvernUser"; +import { useDebugStore } from "@/store/useDebugStore"; import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import type { NodeAdderNode } from "./types"; + function NodeAdderNode({ id, parentId }: NodeProps) { + const debugStore = useDebugStore(); + const isSkyvernUser = useIsSkyvernUser(); const edges = useEdges(); const setWorkflowPanelState = useWorkflowPanelStore( (state) => state.setWorkflowPanelState, ); + const onAdd = () => { + const previous = edges.find((edge) => edge.target === id)?.source; + setWorkflowPanelState({ + active: true, + content: "nodeLibrary", + data: { + previous: previous ?? null, + next: id, + parent: parentId, + connectingEdgeType: "default", + }, + }); + }; + + const adder = ( +
{ + onAdd(); + }} + > + +
+ ); + return (
) { id="b" className="opacity-0" /> -
{ - const previous = edges.find((edge) => edge.target === id)?.source; - setWorkflowPanelState({ - active: true, - content: "nodeLibrary", - data: { - previous: previous ?? null, - next: id, - parent: parentId, - connectingEdgeType: "default", + {isSkyvernUser && debugStore.isDebugMode ? ( + , + text: "Add Block", + onClick: () => { + onAdd(); + }, }, - }); - }} - > - -
+ { + id: "2", + icon: , + enabled: false, + text: "Record Browser", + onClick: () => { + console.log("Record"); + }, + }, + ]} + radius="80px" + startAt={90} + rotateText={true} + > + {adder} + + ) : ( + adder + )}
); }