Download vetcopilot-app/src/components/ContextMenu.tsx from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 9.93 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/ContextMenu.tsx
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-app/src/components/ContextMenu.tsx
-
curl -L -o ContextMenu.tsx https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-app/src/components/ContextMenu.tsx
9.93 kB
| 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<SubMenuContextValue>({ | |
| 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<HTMLButtonElement | HTMLDivElement>(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 && <div className="h-px bg-gray-100 my-1" />} | |
| {hasChildren ? ( | |
| <div ref={itemRef as React.RefObject<HTMLDivElement>} className="relative"> | |
| <button | |
| onClick={handleClick} | |
| onMouseEnter={handleMouseEnter} | |
| disabled={item.disabled} | |
| className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-left transition-colors | |
| ${item.disabled ? 'text-gray-300 cursor-not-allowed' : ''} | |
| ${item.danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'} | |
| ${isOpen ? 'bg-gray-50' : ''} | |
| `} | |
| > | |
| <span className="flex items-center gap-2"> | |
| {item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>} | |
| {item.label} | |
| </span> | |
| <svg className="w-3 h-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /> | |
| </svg> | |
| </button> | |
| {/* 子菜单 */} | |
| {isOpen && item.children && ( | |
| <SubMenuList items={item.children} depth={depth + 1} parentRect={itemRef.current?.getBoundingClientRect() || null} /> | |
| )} | |
| </div> | |
| ) : ( | |
| <button | |
| ref={itemRef as React.RefObject<HTMLButtonElement>} | |
| onClick={handleClick} | |
| onMouseEnter={handleMouseEnter} | |
| disabled={item.disabled} | |
| className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-left transition-colors | |
| ${item.disabled ? 'text-gray-300 cursor-not-allowed' : ''} | |
| ${item.danger ? 'text-red-600 hover:bg-red-50' : 'text-gray-700 hover:bg-gray-50'} | |
| `} | |
| > | |
| <span className="flex items-center gap-2"> | |
| {item.icon && <span className="w-4 h-4 flex-shrink-0">{item.icon}</span>} | |
| {item.label} | |
| </span> | |
| {item.shortcut && ( | |
| <span className="text-xs text-gray-400 ml-4">{item.shortcut}</span> | |
| )} | |
| </button> | |
| )} | |
| </> | |
| ) | |
| } | |
| // ================================================================ | |
| // 子菜单列表(屏幕边缘溢出检测) | |
| // ================================================================ | |
| function SubMenuList({ | |
| items, | |
| depth, | |
| parentRect, | |
| }: { | |
| items: MenuItemConfig[] | |
| depth: number | |
| parentRect: DOMRect | null | |
| }) { | |
| const menuRef = useRef<HTMLDivElement>(null) | |
| const [adjustedStyle, setAdjustedStyle] = useState<React.CSSProperties>({}) | |
| 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 ( | |
| <div | |
| ref={menuRef} | |
| className="fixed z-[110] bg-white border border-gray-200 rounded-xl shadow-xl py-1 min-w-[160px]" | |
| style={adjustedStyle} | |
| > | |
| {items.map((item) => ( | |
| <MenuItemComponent key={item.id} item={item} depth={depth} /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| // ================================================================ | |
| // 根菜单容器 | |
| // ================================================================ | |
| function MenuList({ | |
| items, | |
| style, | |
| }: { | |
| items: MenuItemConfig[] | |
| style: React.CSSProperties | |
| }) { | |
| const menuRef = useRef<HTMLDivElement>(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 ( | |
| <div | |
| ref={menuRef} | |
| className="fixed z-[100] bg-white border border-gray-200 rounded-xl shadow-xl py-1 min-w-[160px] dropdown-enter" | |
| style={adjustedStyle} | |
| > | |
| {items.map((item) => ( | |
| <MenuItemComponent key={item.id} item={item} depth={0} /> | |
| ))} | |
| </div> | |
| ) | |
| } | |
| // ================================================================ | |
| // 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<string | null>(null) | |
| const menuRef = useRef<HTMLDivElement>(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 ( | |
| <SubMenuContext.Provider value={{ openSubMenuId, setOpenSubMenuId, closeAll }}> | |
| <div | |
| onContextMenu={handleContextMenu} | |
| onClick={handleClick} | |
| className={className} | |
| > | |
| {children} | |
| </div> | |
| {open && ( | |
| <div ref={menuRef}> | |
| <MenuList items={items} style={{ left: position.x, top: position.y }} /> | |
| </div> | |
| )} | |
| </SubMenuContext.Provider> | |
| ) | |
| } | |