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