/** * 从节点连接点发起的分支类型选择菜单。Portal挂在body,使用屏幕坐标定位;选择结果交给choose创建内容与连线。 */ "use client"; import { useEffect, useRef } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { createPortal } from "react-dom"; import { FileText, ImagePlus, StickyNote, X } from "lucide-react"; import type { XYPosition } from "@xyflow/react"; import type { NodeDirection } from "@/lib/canvas"; import styles from "./canvas.module.css"; /** id/direction标识发起节点和边,point是菜单屏幕位置,at可由调用方保存拟创建位置;菜单不解释at。 */ export type BranchRequest = { id: string; direction: NodeDirection; point: XYPosition; at?: XYPosition }; /** request为空时不显示,类型固定为文本/图片/脚本;这里只派发选择,不执行上传或文档修改。 */ export function BranchMenu({ request, close, choose }: { request: BranchRequest | null; close: () => void; choose: (kind: "text" | "image" | "script") => void }) { const ref = useRef(null); const reduce = useReducedMotion(); // 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。 // 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。 useEffect(() => { if (!request) return; const focus = document.activeElement as HTMLElement | null; ref.current?.querySelector("button[data-choice]")?.focus(); const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) close(); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(); focus?.focus(); } }; window.addEventListener("pointerdown", outside); window.addEventListener("keydown", escape, true); return () => { window.removeEventListener("pointerdown", outside); window.removeEventListener("keydown", escape, true); }; }, [request, close]); // 无document时不创建Portal。菜单位置按预设宽高限制在视口边距内;上下键循环所有按钮(含关闭)。 // 减少动态效果偏好禁用位移/缩放动画;退出动画仍通过AnimatePresence管理。 if (typeof document === "undefined") return null; return createPortal({request && { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
添加连接节点
{([["text", StickyNote, "文本"], ["image", ImagePlus, "图片"], ["script", FileText, "脚本"]] as const).map(([kind, Icon, label]) => )}
}
, document.body); }