import React, { useEffect, useRef, useState } from "react"; import ReactDOM from "react-dom"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // helpers import { cn } from "../../../helpers"; // hooks import { usePlatformOS } from "../../hooks/use-platform-os"; // components import { ContextMenuItem } from "./item"; export type TContextMenuItem = { key: string; customContent?: React.ReactNode; title?: string; description?: string; icon?: React.FC; action: () => void; shouldRender?: boolean; closeOnClick?: boolean; disabled?: boolean; className?: string; iconClassName?: string; nestedMenuItems?: TContextMenuItem[]; }; // Portal component for nested menus interface PortalProps { children: React.ReactNode; container?: Element | null; } export const Portal: React.FC = ({ children, container }) => { const [mounted, setMounted] = React.useState(false); React.useEffect(() => { setMounted(true); return () => setMounted(false); }, []); if (!mounted) { return null; } const targetContainer = container || document.body; return ReactDOM.createPortal(children, targetContainer); }; // Context for managing nested menus export const ContextMenuContext = React.createContext<{ closeAllSubmenus: () => void; registerSubmenu: (closeSubmenu: () => void) => () => void; portalContainer?: Element | null; } | null>(null); type ContextMenuProps = { parentRef: React.RefObject; items: TContextMenuItem[]; portalContainer?: Element | null; }; const ContextMenuWithoutPortal: React.FC = (props) => { const { parentRef, items, portalContainer } = props; // states const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0, }); const [activeItemIndex, setActiveItemIndex] = useState(0); // refs const contextMenuRef = useRef(null); const submenuClosersRef = useRef void>>(new Set()); // derived values const renderedItems = items.filter((item) => item.shouldRender !== false); const { isMobile } = usePlatformOS(); const closeAllSubmenus = React.useCallback(() => { submenuClosersRef.current.forEach((closeSubmenu) => closeSubmenu()); }, []); const registerSubmenu = React.useCallback((closeSubmenu: () => void) => { submenuClosersRef.current.add(closeSubmenu); return () => { submenuClosersRef.current.delete(closeSubmenu); }; }, []); const handleClose = () => { closeAllSubmenus(); setIsOpen(false); setActiveItemIndex(0); }; // calculate position of context menu useEffect(() => { const parentElement = parentRef.current; const contextMenu = contextMenuRef.current; if (!parentElement || !contextMenu) return; const handleContextMenu = (e: MouseEvent) => { if (isMobile) return; e.preventDefault(); e.stopPropagation(); const contextMenuWidth = contextMenu.clientWidth; const contextMenuHeight = contextMenu.clientHeight; const clickX = e?.pageX || 0; const clickY = e?.pageY || 0; // check if there's enough space at the bottom, otherwise show at the top let top = clickY; if (clickY + contextMenuHeight > window.innerHeight) top = clickY - contextMenuHeight; // check if there's enough space on the right, otherwise show on the left let left = clickX; if (clickX + contextMenuWidth > window.innerWidth) left = clickX - contextMenuWidth; setPosition({ x: left, y: top }); setIsOpen(true); }; const hideContextMenu = (e: KeyboardEvent) => { if (isOpen && e.key === "Escape") handleClose(); }; parentElement.addEventListener("contextmenu", handleContextMenu); window.addEventListener("keydown", hideContextMenu); return () => { parentElement.removeEventListener("contextmenu", handleContextMenu); window.removeEventListener("keydown", hideContextMenu); }; }, [contextMenuRef, isMobile, isOpen, parentRef, setIsOpen, setPosition]); // handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen) return; if (e.key === "ArrowDown") { e.preventDefault(); setActiveItemIndex((prev) => (prev + 1) % renderedItems.length); } if (e.key === "ArrowUp") { e.preventDefault(); setActiveItemIndex((prev) => (prev - 1 + renderedItems.length) % renderedItems.length); } if (e.key === "Enter") { e.preventDefault(); const item = renderedItems[activeItemIndex]; if (!item.disabled) { renderedItems[activeItemIndex].action(); if (item.closeOnClick !== false) handleClose(); } } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [activeItemIndex, isOpen, renderedItems, setIsOpen]); // Custom handler for nested menu portal clicks React.useEffect(() => { const handleDocumentClick = (event: MouseEvent) => { const target = event.target as HTMLElement; // Check if the click is on a nested menu element const isNestedMenuClick = target.closest('[data-context-submenu="true"]'); const isMainMenuClick = contextMenuRef.current?.contains(target); // Also check if the target itself has the data attribute const isNestedMenuElement = target.hasAttribute("data-context-submenu"); // If it's a nested menu click, main menu click, or nested menu element, don't close if (isNestedMenuClick || isMainMenuClick || isNestedMenuElement) { return; } // If menu is open and it's an outside click, close it if (isOpen) { handleClose(); } }; if (isOpen) { // Use capture phase to ensure we handle the event before other handlers document.addEventListener("mousedown", handleDocumentClick, true); return () => { document.removeEventListener("mousedown", handleDocumentClick, true); }; } }, [isOpen, handleClose]); return (
{renderedItems.map((item, index) => ( setActiveItemIndex(index)} handleClose={handleClose} isActive={index === activeItemIndex} item={item} /> ))}
); }; export const ContextMenu: React.FC = (props) => { let contextMenu = ; const portal = document.querySelector("#context-menu-portal"); if (portal) contextMenu = ReactDOM.createPortal(contextMenu, portal); return contextMenu; };