Add a RadialMenu component (#4096)
This commit is contained in:
233
skyvern-frontend/src/components/RadialMenu.tsx
Normal file
233
skyvern-frontend/src/components/RadialMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
|
||||||
import { PlusIcon } from "@radix-ui/react-icons";
|
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
@@ -8,6 +6,13 @@ import {
|
|||||||
getBezierPath,
|
getBezierPath,
|
||||||
useNodes,
|
useNodes,
|
||||||
} from "@xyflow/react";
|
} 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";
|
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
|
||||||
|
|
||||||
function EdgeWithAddButton({
|
function EdgeWithAddButton({
|
||||||
@@ -22,6 +27,8 @@ function EdgeWithAddButton({
|
|||||||
style = {},
|
style = {},
|
||||||
markerEnd,
|
markerEnd,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
|
const debugStore = useDebugStore();
|
||||||
|
const isSkyvernUser = useIsSkyvernUser();
|
||||||
const nodes = useNodes();
|
const nodes = useNodes();
|
||||||
const [edgePath, labelX, labelY] = getBezierPath({
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
@@ -36,6 +43,28 @@ function EdgeWithAddButton({
|
|||||||
);
|
);
|
||||||
const sourceNode = nodes.find((node) => node.id === source);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||||
@@ -52,23 +81,38 @@ function EdgeWithAddButton({
|
|||||||
}}
|
}}
|
||||||
className="nodrag nopan"
|
className="nodrag nopan"
|
||||||
>
|
>
|
||||||
<Button
|
{isSkyvernUser && debugStore.isDebugMode ? (
|
||||||
size="icon"
|
<RadialMenu
|
||||||
className="h-4 w-4 rounded-full transition-all hover:scale-150"
|
items={[
|
||||||
onClick={() => {
|
{
|
||||||
setWorkflowPanelState({
|
id: "1",
|
||||||
active: true,
|
icon: <PlusIcon className="h-3 w-3" />,
|
||||||
content: "nodeLibrary",
|
text: "Add Block",
|
||||||
data: {
|
onClick: () => {
|
||||||
previous: source,
|
onAdd();
|
||||||
next: target,
|
},
|
||||||
parent: sourceNode?.parentId,
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
}}
|
id: "2",
|
||||||
>
|
icon: <SquareIcon className="h-3 w-3" />,
|
||||||
<PlusIcon />
|
enabled: false,
|
||||||
</Button>
|
text: "Record Browser",
|
||||||
|
onClick: () => {
|
||||||
|
console.log("Record");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
buttonSize="25px"
|
||||||
|
radius="50px"
|
||||||
|
startAt={72.5}
|
||||||
|
gap={35}
|
||||||
|
rotateText={true}
|
||||||
|
>
|
||||||
|
{adder}
|
||||||
|
</RadialMenu>
|
||||||
|
) : (
|
||||||
|
adder
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</EdgeLabelRenderer>
|
</EdgeLabelRenderer>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,46 @@
|
|||||||
|
import { SquareIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||||
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
|
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 { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||||
|
|
||||||
|
import type { NodeAdderNode } from "./types";
|
||||||
|
|
||||||
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||||
|
const debugStore = useDebugStore();
|
||||||
|
const isSkyvernUser = useIsSkyvernUser();
|
||||||
const edges = useEdges();
|
const edges = useEdges();
|
||||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||||
(state) => state.setWorkflowPanelState,
|
(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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Handle
|
<Handle
|
||||||
@@ -23,24 +55,36 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
|||||||
id="b"
|
id="b"
|
||||||
className="opacity-0"
|
className="opacity-0"
|
||||||
/>
|
/>
|
||||||
<div
|
{isSkyvernUser && debugStore.isDebugMode ? (
|
||||||
className="rounded-full bg-slate-50 p-2"
|
<RadialMenu
|
||||||
onClick={() => {
|
items={[
|
||||||
const previous = edges.find((edge) => edge.target === id)?.source;
|
{
|
||||||
setWorkflowPanelState({
|
id: "1",
|
||||||
active: true,
|
icon: <PlusIcon />,
|
||||||
content: "nodeLibrary",
|
text: "Add Block",
|
||||||
data: {
|
onClick: () => {
|
||||||
previous: previous ?? null,
|
onAdd();
|
||||||
next: id,
|
},
|
||||||
parent: parentId,
|
|
||||||
connectingEdgeType: "default",
|
|
||||||
},
|
},
|
||||||
});
|
{
|
||||||
}}
|
id: "2",
|
||||||
>
|
icon: <SquareIcon />,
|
||||||
<PlusIcon className="h-12 w-12 text-slate-950" />
|
enabled: false,
|
||||||
</div>
|
text: "Record Browser",
|
||||||
|
onClick: () => {
|
||||||
|
console.log("Record");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
radius="80px"
|
||||||
|
startAt={90}
|
||||||
|
rotateText={true}
|
||||||
|
>
|
||||||
|
{adder}
|
||||||
|
</RadialMenu>
|
||||||
|
) : (
|
||||||
|
adder
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user