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 { 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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user