126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { Check } from "lucide-react";
|
|
|
|
export interface ContextMenuItem {
|
|
label: string;
|
|
checked?: boolean;
|
|
onToggle?: () => void;
|
|
onClick?: () => void;
|
|
divider?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface ContextMenuProps {
|
|
x: number;
|
|
y: number;
|
|
items: ContextMenuItem[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handleClick = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
const handleScroll = () => onClose();
|
|
|
|
document.addEventListener("mousedown", handleClick);
|
|
document.addEventListener("keydown", handleKey);
|
|
window.addEventListener("scroll", handleScroll, true);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClick);
|
|
document.removeEventListener("keydown", handleKey);
|
|
window.removeEventListener("scroll", handleScroll, true);
|
|
};
|
|
}, [onClose]);
|
|
|
|
// Clamp position to viewport
|
|
const style: React.CSSProperties = {
|
|
position: "fixed",
|
|
left: Math.min(x, window.innerWidth - 220),
|
|
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
|
|
zIndex: 9999,
|
|
backgroundColor: "var(--ctp-surface0)",
|
|
border: "1px solid var(--ctp-surface1)",
|
|
borderRadius: "0.5rem",
|
|
padding: "0.25rem 0",
|
|
minWidth: 200,
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
};
|
|
|
|
return (
|
|
<div ref={ref} style={style}>
|
|
{items.map((item, i) =>
|
|
item.divider ? (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
borderTop: "1px solid var(--ctp-surface1)",
|
|
margin: "0.25rem 0",
|
|
}}
|
|
/>
|
|
) : (
|
|
<button
|
|
key={i}
|
|
onClick={() => {
|
|
if (item.onToggle) item.onToggle();
|
|
else if (item.onClick) {
|
|
item.onClick();
|
|
onClose();
|
|
}
|
|
}}
|
|
disabled={item.disabled}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
width: "100%",
|
|
padding: "0.35rem 0.75rem",
|
|
background: "none",
|
|
border: "none",
|
|
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
|
|
fontSize: "var(--font-body)",
|
|
cursor: item.disabled ? "default" : "pointer",
|
|
textAlign: "left",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!item.disabled)
|
|
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
>
|
|
{item.checked !== undefined && (
|
|
<span
|
|
style={{
|
|
width: 16,
|
|
height: 16,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
border: "1px solid var(--ctp-overlay0)",
|
|
borderRadius: 3,
|
|
backgroundColor: item.checked
|
|
? "var(--ctp-mauve)"
|
|
: "transparent",
|
|
color: item.checked ? "var(--ctp-crust)" : "transparent",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{item.checked ? <Check size={14} /> : ""}
|
|
</span>
|
|
)}
|
|
{item.label}
|
|
</button>
|
|
),
|
|
)}
|
|
</div>
|
|
);
|
|
}
|