38 lines
3.7 KiB
TypeScript
38 lines
3.7 KiB
TypeScript
/**
|
|
* 从节点连接点发起的分支类型选择菜单。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<HTMLDivElement>(null);
|
|
const reduce = useReducedMotion();
|
|
// 菜单打开后保存原焦点并聚焦首个类型按钮;点击外部关闭,Escape关闭且恢复原焦点。
|
|
// 仅在request存在时安装全局监听,变化/卸载时拆除;普通选择或外部关闭不在此强制恢复焦点。
|
|
useEffect(() => {
|
|
if (!request) return;
|
|
const focus = document.activeElement as HTMLElement | null;
|
|
ref.current?.querySelector<HTMLButtonElement>("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(<AnimatePresence>{request && <motion.div ref={ref} key={request.id + request.direction} initial={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : 4 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: reduce ? 1 : 0.96 }} transition={{ duration: reduce ? 0 : 0.14 }} role="dialog" aria-label="选择连接节点类型" className={styles.branchMenu} style={{ left: Math.max(12, Math.min(request.point.x + 12, window.innerWidth - 222)), top: Math.max(12, Math.min(request.point.y + 12, window.innerHeight - 208)) }}
|
|
onKeyDown={(event) => { event.stopPropagation(); if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const buttons = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button") || []); const index = buttons.indexOf(document.activeElement as HTMLButtonElement); buttons[(index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length]?.focus(); } }}>
|
|
<header><span>添加连接节点</span><button aria-label="关闭节点菜单" onClick={close}><X size={14} /></button></header>
|
|
{([["text", StickyNote, "文本"], ["image", ImagePlus, "图片"], ["script", FileText, "脚本"]] as const).map(([kind, Icon, label]) => <button data-choice key={kind} onClick={() => choose(kind)}><Icon size={17} /><span>{label}</span></button>)}
|
|
</motion.div>}</AnimatePresence>, document.body);
|
|
}
|