import React, { useState, useRef, useEffect, useCallback, createContext, useContext } from 'react' /** 菜单项配置 */ interface MenuItemConfig { /** 唯一标识 */ id: string /** 显示文本 */ label: string /** 图标 */ icon?: React.ReactNode /** 快捷键提示文本 */ shortcut?: string /** 子菜单 */ children?: MenuItemConfig[] /** 分隔线(在此项之前显示分隔线) */ divider?: boolean /** 危险操作(红色) */ danger?: boolean /** 是否禁用 */ disabled?: boolean /** 点击回调 */ onClick?: () => void } interface ContextMenuProps { /** 菜单项配置 */ items: MenuItemConfig[] /** 触发右键菜单的内容 */ children: React.ReactNode /** 附加类名 */ className?: string /** 自定义触发方式:'context'(右键)| 'click'(点击),默认 'context' */ trigger?: 'context' | 'click' } // ================================================================ // 嵌套子菜单上下文 // ================================================================ interface SubMenuContextValue { openSubMenuId: string | null setOpenSubMenuId: (id: string | null) => void closeAll: () => void } const SubMenuContext = createContext({ openSubMenuId: null, setOpenSubMenuId: () => {}, closeAll: () => {}, }) // ================================================================ // 菜单项组件(含子菜单递归) // ================================================================ function MenuItemComponent({ item, depth = 0, }: { item: MenuItemConfig depth?: number }) { const { openSubMenuId, setOpenSubMenuId, closeAll } = useContext(SubMenuContext) const hasChildren = item.children && item.children.length > 0 const isOpen = openSubMenuId === item.id const itemRef = useRef(null) const [subMenuPos, setSubMenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) const handleMouseEnter = useCallback(() => { if (hasChildren) { setOpenSubMenuId(item.id) } else { setOpenSubMenuId(null) } }, [hasChildren, item.id, setOpenSubMenuId]) const handleClick = useCallback(() => { if (item.disabled) return if (hasChildren) { // toggle 子菜单 setOpenSubMenuId(isOpen ? null : item.id) } else { item.onClick?.() closeAll() } }, [item, hasChildren, isOpen, setOpenSubMenuId, closeAll]) return ( <> {item.divider &&
} {hasChildren ? (
} className="relative"> {/* 子菜单 */} {isOpen && item.children && ( )}
) : ( )} ) } // ================================================================ // 子菜单列表(屏幕边缘溢出检测) // ================================================================ function SubMenuList({ items, depth, parentRect, }: { items: MenuItemConfig[] depth: number parentRect: DOMRect | null }) { const menuRef = useRef(null) const [adjustedStyle, setAdjustedStyle] = useState({}) useEffect(() => { if (!parentRect || !menuRef.current) return const menuRect = menuRef.current.getBoundingClientRect() const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight let style: React.CSSProperties = { position: 'fixed', left: parentRect.right + 4, top: parentRect.top, } // 右侧溢出检测 if (parentRect.right + menuRect.width + 8 > viewportWidth) { style.left = parentRect.left - menuRect.width - 4 } // 底部溢出检测 if (parentRect.top + menuRect.height > viewportHeight) { style.top = Math.max(4, viewportHeight - menuRect.height - 4) } setAdjustedStyle(style) }, [parentRect, items]) return (
{items.map((item) => ( ))}
) } // ================================================================ // 根菜单容器 // ================================================================ function MenuList({ items, style, }: { items: MenuItemConfig[] style: React.CSSProperties }) { const menuRef = useRef(null) const [adjustedStyle, setAdjustedStyle] = useState(style) useEffect(() => { if (!menuRef.current) return // 等菜单渲染后检查屏幕溢出 const raf = requestAnimationFrame(() => { if (!menuRef.current) return const rect = menuRef.current.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight const nextStyle = { ...style } // 右侧溢出 if (rect.right > vw) { nextStyle.left = Math.max(4, vw - rect.width - 4) } // 底部溢出 if (rect.bottom > vh) { nextStyle.top = Math.max(4, vh - rect.height - 4) } setAdjustedStyle(nextStyle) }) return () => cancelAnimationFrame(raf) }, [style]) return (
{items.map((item) => ( ))}
) } // ================================================================ // ContextMenu 根组件 // ================================================================ export default function ContextMenu({ items, children, className = '', trigger = 'context' }: ContextMenuProps) { const [open, setOpen] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const [openSubMenuId, setOpenSubMenuId] = useState(null) const menuRef = useRef(null) // 点击外部关闭 + 右键菜单外部关闭 useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setOpen(false) setOpenSubMenuId(null) } } if (open) { document.addEventListener('mousedown', handleClickOutside) } return () => document.removeEventListener('mousedown', handleClickOutside) }, [open]) // Escape 关闭 useEffect(() => { if (!open) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false) setOpenSubMenuId(null) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [open]) const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (trigger === 'click') return e.preventDefault() setPosition({ x: e.clientX, y: e.clientY }) setOpen(true) setOpenSubMenuId(null) }, [trigger] ) const handleClick = useCallback( (e: React.MouseEvent) => { if (trigger === 'click') { e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() setPosition({ x: rect.left, y: rect.bottom + 4 }) setOpen((prev) => !prev) setOpenSubMenuId(null) } }, [trigger] ) const closeAll = useCallback(() => { setOpen(false) setOpenSubMenuId(null) }, []) return (
{children}
{open && (
)}
) }