import type { LucideIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn } from "~/lib/utils.ts"; export type MenuItem = | { key: string; label: string; icon?: LucideIcon; /** 快捷键提示 */ hint?: string; /** 禁用原因(显示为 title,并在行内小字说明) */ disabled?: string | null; destructive?: boolean; onSelect: () => void; } | { key: string; separator: true }; export type MenuAnchor = { x: number; y: number }; /** * 右键菜单 / 行 ⋯ 菜单共用的浮层(pg-float)。按坐标定位,越界自动翻转; * 点外面、Esc、滚动、失焦都会关闭;↑↓ 在可用项间移动,Enter 执行。 */ export function ContextMenu({ anchor, items, onClose, label = "操作", }: { anchor: MenuAnchor | null; items: MenuItem[]; onClose: () => void; label?: string; }) { const ref = useRef(null); const [pos, setPos] = useState<{ left: number; top: number } | null>(null); const [active, setActive] = useState(-1); useLayoutEffect(() => { if (!anchor || !ref.current) return; const r = ref.current.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const left = anchor.x + r.width > vw - 8 ? Math.max(8, anchor.x - r.width) : anchor.x; const top = anchor.y + r.height > vh - 8 ? Math.max(8, anchor.y - r.height) : anchor.y; setPos({ left, top }); ref.current.focus(); }, [anchor]); useEffect(() => { if (!anchor) return; const close = (e: Event) => { if (e.type === "mousedown" && ref.current?.contains(e.target as Node)) return; onClose(); }; window.addEventListener("mousedown", close); window.addEventListener("resize", close); window.addEventListener("blur", close); document.addEventListener("scroll", close, true); return () => { window.removeEventListener("mousedown", close); window.removeEventListener("resize", close); window.removeEventListener("blur", close); document.removeEventListener("scroll", close, true); }; }, [anchor, onClose]); useEffect(() => { if (!anchor) { setPos(null); setActive(-1); } }, [anchor]); if (!anchor || typeof document === "undefined") return null; const actionable = items .map((it, i) => ({ it, i })) .filter(({ it }) => !("separator" in it) && !it.disabled) .map(({ i }) => i); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!actionable.length) return; const cur = actionable.indexOf(active); const next = e.key === "ArrowDown" ? actionable[(cur + 1) % actionable.length] : actionable[(cur - 1 + actionable.length) % actionable.length]; setActive(next ?? -1); } else if (e.key === "Enter" && active >= 0) { e.preventDefault(); const it = items[active]; if (it && !("separator" in it) && !it.disabled) { onClose(); it.onSelect(); } } }; return createPortal(
e.preventDefault()} style={{ left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden" }} className="pg-float fixed z-50 min-w-52 max-w-72 animate-pg-pop rounded-2xl p-1.5 outline-none" > {items.map((it, i) => "separator" in it ? (
) : ( ), )}
, document.body, ); }