import { useEffect, useRef, useMemo, useState, useLayoutEffect, FC, } from "react"; import { createPortal } from "react-dom"; import "../../../../css/ContextMenu.css"; export interface ContextMenuItem { id: string; label?: string; icon?: string; shortcut?: string; action?: () => void; disabled?: boolean; separator?: boolean; section?: string; children?: ContextMenuItem[]; } interface ContextMenuProps { items: ContextMenuItem[]; position: { x: number; y: number }; onClose: () => void; isVisible: boolean; } // ─── Rendu d'un élément de menu (section, séparateur ou entrée cliquable) ─── interface ContextMenuItemRendererProps { item: ContextMenuItem; isHovered: boolean; onHover: (id: string) => void; onSubmenuOpen: (id: string | null) => void; onItemClick: (item: ContextMenuItem) => void; } const ContextMenuItemRenderer: FC = ({ item, isHovered, onHover, onSubmenuOpen, onItemClick, }) => { if (item.section) { return (
{item.section}
); } if (item.separator) { return (
); } const handleMouseEnter = () => { onHover(item.id); onSubmenuOpen(item.children?.length ? item.id : null); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") onItemClick(item); }; return (
onItemClick(item)} onKeyDown={handleKeyDown} onMouseEnter={handleMouseEnter} style={{ display: "flex", alignItems: "center", padding: "1px 4px", cursor: item.disabled ? "not-allowed" : "pointer", userSelect: "none", transition: "all 0.15s cubic-bezier(0.4, 0, 0.2, 1)", position: "relative", minHeight: "16px", border: "none", background: isHovered ? "#f1f5f9" : "transparent", color: item.disabled ? "#94a3b8" : "#334155", fontSize: "11px", fontWeight: "500", }} > {item.icon && ( {item.icon} )} {item.label && ( {item.label} )} {item.shortcut && ( {item.shortcut} )} {item.children && item.children.length > 0 && ( )}
); }; // ─── Composant principal ContextMenu ──────────────────────────────────────── export const ContextMenu: FC = ({ items, position, onClose, isVisible, }) => { const menuRef = useRef(null); const [hoveredItem, setHoveredItem] = useState(null); const [openSubmenu, setOpenSubmenu] = useState(null); const [submenuPosition, setSubmenuPosition] = useState<{ x: number; y: number; } | null>(null); const closeTimeoutRef = useRef | null>(null); // Calculer la position corrigée pour garder le menu à l'écran const adjustedPosition = useMemo(() => { let adjustedX = position.x; let adjustedY = position.y; if (globalThis.window !== undefined) { // Largeur et hauteur estimées du menu compact const menuWidth = 160; // Max width du menu compact // Calculer la hauteur réelle en fonction du type d'éléments let menuHeight = 0; items.forEach((item) => { if (item.section) { menuHeight += 14; // Hauteur des sections avec padding } else if (item.separator) { menuHeight += 3; // Hauteur des séparateurs avec margin } else { menuHeight += 18; // Hauteur des éléments normaux } }); menuHeight += 2; // Padding vertical du menu // Vérifier si le menu sort à droite if (adjustedX + menuWidth > globalThis.innerWidth) { adjustedX = globalThis.innerWidth - menuWidth - 10; } // Vérifier si le menu sort en bas if (adjustedY + menuHeight > globalThis.innerHeight) { adjustedY = globalThis.innerHeight - menuHeight - 10; } // Vérifier si le menu sort en haut (après ajustement vers le bas) if (adjustedY < 0) { adjustedY = 10; // Positionner en haut avec une marge } // Vérifier les limites à gauche if (adjustedX < 0) adjustedX = 10; } return { x: adjustedX, y: adjustedY }; }, [position, items]); // Nettoyer les timeouts quand le composant se démonte useEffect(() => { return () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); } }; }, []); // Calculer la position du sous-menu quand il s'ouvre useLayoutEffect(() => { if (openSubmenu && menuRef.current) { const menuElement = menuRef.current as HTMLElement; const parentItem = menuElement.querySelector( `[data-item-id="${openSubmenu}"]`, ) as HTMLElement | null; if (parentItem) { const menuRect = menuElement.getBoundingClientRect(); const parentRect = parentItem.getBoundingClientRect(); let submenuX = menuRect.right - 2; // Positionner à droite du menu parent let submenuY = parentRect.top; // Aligner avec l'élément parent // Vérifier si le sous-menu sort à droite de l'écran const submenuWidth = 160; if (submenuX + submenuWidth > globalThis.innerWidth) { submenuX = menuRect.left - submenuWidth + 2; // Positionner à gauche } // Vérifier si le sous-menu sort en bas de l'écran const submenuHeight = 200; // Estimation if (submenuY + submenuHeight > globalThis.innerHeight) { submenuY = window.innerHeight - submenuHeight - 10; } // Vérifier si le sous-menu sort en haut if (submenuY < 0) { submenuY = 10; } setSubmenuPosition((_prev) => ({ x: submenuX, y: submenuY })); } } else { setSubmenuPosition(null); } }, [openSubmenu]); useEffect(() => { if (!isVisible) return; const handleClickOutside = (event: Event) => { // Petite attente pour permettre au menu de s'ouvrir d'abord setTimeout(() => { if (!menuRef.current) return; const mouseEvent = event as unknown as { button?: number }; // Ne pas fermer si c'est un clic droit (pour éviter de fermer immédiatement) if (mouseEvent.button === 2) return; // Vérifier si le clic est en dehors du menu if ( !(menuRef.current as HTMLElement).contains( event.target as HTMLElement, ) ) { onClose(); } }, 10); }; const handleContextMenu = (event: Event) => { // Empêcher l'ouverture d'un nouveau menu contextuel sur le menu existant if ( menuRef.current && (menuRef.current as HTMLElement).contains(event.target as HTMLElement) ) { event.preventDefault(); } }; const handleEscape = (event: Event) => { const keyboardEvent = event as unknown as { key?: string }; if (keyboardEvent.key === "Escape") { onClose(); } }; // Délai minimal pour permettre au menu de se rendre const timer = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside, { passive: true, }); document.addEventListener("contextmenu", handleContextMenu, { passive: true, }); document.addEventListener("keydown", handleEscape, { passive: true }); }, 50); return () => { clearTimeout(timer); document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("contextmenu", handleContextMenu); document.removeEventListener("keydown", handleEscape); }; }, [isVisible, onClose]); if (!isVisible) { return null; } const handleItemClick = (item: ContextMenuItem) => { // Ne pas exécuter l'action si l'élément a des enfants (c'est un submenu) if (!item.disabled && !item.separator && item.action && !item.children?.length) { item.action(); onClose(); } }; const handleMenuMouseLeave = () => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = globalThis.setTimeout(() => { setOpenSubmenu(null); setHoveredItem(null); }, 300); }; const handleMenuMouseEnter = () => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } }; const menuStyle: React.CSSProperties = { position: "fixed", left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1, visibility: "visible", pointerEvents: "auto", zIndex: 999999, background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)", border: "1px solid #e2e8f0", WebkitBorderRadius: "8px", MozBorderRadius: "8px", borderRadius: "8px", WebkitBoxShadow: "0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)", MozBoxShadow: "0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04)", minWidth: "120px", maxWidth: "160px", padding: "1px 0", WebkitTransition: "opacity 0.15s ease-in-out", MozTransition: "opacity 0.15s ease-in-out", OTransition: "opacity 0.15s ease-in-out", transition: "opacity 0.15s ease-in-out", WebkitTransformOrigin: "top left", MozTransformOrigin: "top left", msTransformOrigin: "top left", OTransformOrigin: "top left", transformOrigin: "top left", }; const menuElement = (
{ if (e.key === "Escape") onClose(); }} onMouseLeave={handleMenuMouseLeave} onMouseEnter={handleMenuMouseEnter} style={menuStyle} > {items.map((item) => (
))}
); const renderSubmenus = () => { if (!openSubmenu || !submenuPosition) return null; const parentItem = items.find((item) => item.id === openSubmenu); if (!parentItem?.children) return null; return (
setOpenSubmenu(null)} isVisible={true} />
); }; // Utiliser un Portal pour rendre le menu au niveau du document body return createPortal( <> {menuElement} {renderSubmenus()} , document.body, ); };