Add a RadialMenu component (#4096)

This commit is contained in:
Jonathan Dobson
2025-11-25 14:35:57 -05:00
committed by GitHub
parent 285694cefe
commit 0a12f4dfb8
3 changed files with 359 additions and 38 deletions

View File

@@ -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<number>(100);
const wrapperRef = useRef<HTMLDivElement>(null);
const dom = {
root: useRef<HTMLDivElement>(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 (
<div
ref={dom.root}
className="relative z-[1000000]"
onMouseLeave={(e) => {
// 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 */}
<div
className="absolute left-1/2 top-1/2 z-10"
style={{
width: `${padSize}px`,
height: `${padSize}px`,
transform: "translate(-50%, -50%)",
}}
/>
<div
ref={wrapperRef}
className="relative z-20"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsOpen(!isOpen);
}}
onMouseEnter={() => {
setIsOpen(true);
}}
>
<div className="pointer-events-none">{children}</div>
</div>
{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 (
<>
<button
key={item.id}
onClick={() => {
item.onClick();
setIsOpen(false);
}}
disabled={!isEnabled}
className="absolute left-1/2 top-1/2 z-30 flex items-center justify-center rounded-full bg-white shadow-lg transition-all duration-300 ease-out hover:bg-gray-50 disabled:cursor-not-allowed"
style={{
width: buttonSize ?? "40px",
height: buttonSize ?? "40px",
transform: isOpen
? `translate(-50%, -50%) translate(${x}px, ${y}px) scale(1)`
: "translate(-50%, -50%) translate(0, 0) scale(0)",
opacity: isOpen ? (isEnabled ? 1 : 0.5) : 0,
pointerEvents: isOpen ? "auto" : "none",
transitionDelay: isOpen ? `${index * 50}ms` : "0ms",
}}
>
<div className="text-gray-700">{item.icon}</div>
</button>
{item.text && (
<div
key={`${item.id}-text`}
className="absolute left-1/2 top-1/2 z-30 whitespace-nowrap rounded bg-white px-2 py-1 text-xs text-gray-700 shadow-md transition-all duration-300 ease-out"
style={{
transform: isOpen
? rotateText
? `translate(0%, -50%) translate(${x + textX}px, ${y + textY}px) rotate(${angleDegrees}deg) scale(1)`
: `translate(0%, -50%) translate(${x + textX}px, ${y + textY}px) scale(1)`
: "translate(0%, -50%) translate(0, 0) scale(0.5)",
opacity: isOpen ? (isEnabled ? 1 : 0.5) : 0,
transformOrigin: "left center",
pointerEvents: "none",
transitionDelay: isOpen ? `${index * 50}ms` : "0ms",
}}
>
<span
style={{
display: "inline-block",
transform: textTransform,
opacity: isEnabled ? 1 : 0.5,
cursor: isEnabled ? "default" : "not-allowed",
}}
>
{item.text}
</span>
</div>
)}
</>
);
})}
</div>
);
}

View File

@@ -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 = (
<Button
size="icon"
className="h-4 w-4 rounded-full transition-all hover:scale-150"
onClick={() => onAdd()}
>
<PlusIcon />
</Button>
);
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
@@ -52,23 +81,38 @@ function EdgeWithAddButton({
}}
className="nodrag nopan"
>
<Button
size="icon"
className="h-4 w-4 rounded-full transition-all hover:scale-150"
onClick={() => {
setWorkflowPanelState({
active: true,
content: "nodeLibrary",
data: {
previous: source,
next: target,
parent: sourceNode?.parentId,
{isSkyvernUser && debugStore.isDebugMode ? (
<RadialMenu
items={[
{
id: "1",
icon: <PlusIcon className="h-3 w-3" />,
text: "Add Block",
onClick: () => {
onAdd();
},
},
});
}}
>
<PlusIcon />
</Button>
{
id: "2",
icon: <SquareIcon className="h-3 w-3" />,
enabled: false,
text: "Record Browser",
onClick: () => {
console.log("Record");
},
},
]}
buttonSize="25px"
radius="50px"
startAt={72.5}
gap={35}
rotateText={true}
>
{adder}
</RadialMenu>
) : (
adder
)}
</div>
</EdgeLabelRenderer>
</>

View File

@@ -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<NodeAdderNode>) {
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 = (
<div
className="rounded-full bg-slate-50 p-2"
onClick={() => {
onAdd();
}}
>
<PlusIcon className="h-12 w-12 text-slate-950" />
</div>
);
return (
<div>
<Handle
@@ -23,24 +55,36 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
id="b"
className="opacity-0"
/>
<div
className="rounded-full bg-slate-50 p-2"
onClick={() => {
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 ? (
<RadialMenu
items={[
{
id: "1",
icon: <PlusIcon />,
text: "Add Block",
onClick: () => {
onAdd();
},
},
});
}}
>
<PlusIcon className="h-12 w-12 text-slate-950" />
</div>
{
id: "2",
icon: <SquareIcon />,
enabled: false,
text: "Record Browser",
onClick: () => {
console.log("Record");
},
},
]}
radius="80px"
startAt={90}
rotateText={true}
>
{adder}
</RadialMenu>
) : (
adder
)}
</div>
);
}