UNPKG

react-global-context-menu

Version:

A lightweight global context menu for React apps

73 lines (72 loc) 3.58 kB
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))) }))] })); };