react-global-context-menu
Version:
A lightweight global context menu for React apps
73 lines (72 loc) • 3.58 kB
JavaScript
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { createContext, useContext, useState, useRef, useLayoutEffect, useEffect } from "react";
import { Separator } from "./Separator";
import { cn } from "./utils/cn";
const ContextMenuContext = createContext(null);
export const useGlobalContextMenu = () => {
const ctx = useContext(ContextMenuContext);
if (!ctx)
throw new Error("useGlobalContextMenu must be used within ContextMenuProvider");
return ctx;
};
export const ContextMenuProvider = ({ children, }) => {
const [state, setState] = useState({
visible: false,
x: 0,
y: 0,
menus: [],
});
const menuRef = useRef(null);
const openContextMenu = (x, y, menus) => {
setState({ visible: true, x, y, menus });
};
const closeContextMenu = () => {
setState((prev) => (Object.assign(Object.assign({}, prev), { visible: false })));
};
// 防止菜单溢出窗口
useLayoutEffect(() => {
if (!state.visible || !menuRef.current)
return;
const { innerWidth, innerHeight } = window;
const menuEl = menuRef.current;
const rect = menuEl.getBoundingClientRect();
let newX = state.x;
let newY = state.y;
if (state.x + rect.width > innerWidth) {
newX = innerWidth - rect.width - 4;
}
if (state.y + rect.height > innerHeight) {
newY = innerHeight - rect.height - 4;
}
if (newX !== state.x || newY !== state.y) {
setState((prev) => (Object.assign(Object.assign({}, prev), { x: newX, y: newY })));
}
}, [state.visible, state.x, state.y]);
// 点击空白处关闭菜单
useEffect(() => {
const handleClickOutside = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
closeContextMenu();
}
};
if (state.visible) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [state.visible]);
return (_jsxs(ContextMenuContext.Provider, { value: { openContextMenu, closeContextMenu }, children: [children, state.visible && (_jsx("div", { ref: menuRef, className: "fixed z-[9999] bg-white border rounded shadow-md p-1 flex flex-col transition-opacity duration-150 ease-out animate-in fade-in-50 zoom-in-95", style: {
top: state.y,
left: state.x,
maxWidth: "220px",
maxHeight: "calc(100vh - 10px)",
}, children: state.menus.map((menu) => menu.type === "menu" ? (_jsxs("button", { className: cn("text-left py-1 pl-3 pr-2 text-sm h-8 w-52 rounded flex justify-between items-center", menu.disabled
? "text-gray-400 cursor-not-allowed"
: "hover:bg-sky-100 active:bg-sky-200", menu.className), onClick: () => {
if (!menu.disabled && menu.onClick) {
menu.onClick();
closeContextMenu();
}
}, disabled: menu.disabled, children: [_jsxs("div", { className: "flex items-center gap-2", children: [menu.icon, menu.name] }), menu.shortcut && (_jsx("span", { className: "text-xs text-gray-400", children: menu.shortcut }))] }, menu.key)) : (_jsx(Separator, { orientation: "horizontal", className: "h-px bg-gray-200 my-1" }, menu.key))) }))] }));
};