返回 DeepSeek-Reasonix
FloatingMenu.tsx
根目录 / desktop / frontend / src / components / FloatingMenu.tsx
1 import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
2 import { useMemo } from "react";
3 import { createPortal } from "react-dom";
4
5 const FLOATING_MENU_MARGIN = 8;
6
7 export type FloatingMenuItem =
8 | {
9 separator: true;
10 key?: string;
11 }
12 | {
13 icon?: ReactNode;
14 label: ReactNode;
15 onSelect: () => void;
16 disabled?: boolean;
17 separator?: false;
18 };
19
20 function clampFloatingMenuPosition(x: number, y: number, width: number, height: number): { left: number; top: number } {
21 if (typeof window === "undefined") return { left: x, top: y };
22 const maxLeft = Math.max(FLOATING_MENU_MARGIN, window.innerWidth - width - FLOATING_MENU_MARGIN);
23 const maxTop = Math.max(FLOATING_MENU_MARGIN, window.innerHeight - height - FLOATING_MENU_MARGIN);
24 return {
25 left: Math.min(maxLeft, Math.max(FLOATING_MENU_MARGIN, x)),
26 top: Math.min(maxTop, Math.max(FLOATING_MENU_MARGIN, y)),
27 };
28 }
29
30 export function FloatingMenu({
31 x,
32 y,
33 width = 240,
34 estimatedHeight,
35 className = "",
36 children,
37 }: {
38 x: number;
39 y: number;
40 width?: number;
41 estimatedHeight: number;
42 className?: string;
43 children: ReactNode;
44 }) {
45 const pos = useMemo(() => clampFloatingMenuPosition(x, y, width, estimatedHeight), [estimatedHeight, width, x, y]);
46 if (typeof document === "undefined") return null;
47 // Portal to <body>: the menu is position:fixed, but a transformed ancestor
48 // (e.g. .workspace-preview carries a residual transform) would otherwise
49 // become its containing block and push the fixed coordinates off-screen.
50 // Rendering at the body root keeps fixed positioning relative to the viewport.
51 return createPortal(
52 <div
53 data-app-overlay=""
54 className={`floating-menu${className ? ` ${className}` : ""}`}
55 style={{ left: pos.left, top: pos.top }}
56 onMouseDown={(e) => {
57 e.preventDefault();
58 e.stopPropagation();
59 }}
60 onClick={(e) => e.stopPropagation()}
61 >
62 {children}
63 </div>,
64 document.body,
65 );
66 }
67
68 export function FloatingMenuItems({ items }: { items: FloatingMenuItem[] }) {
69 return (
70 <>
71 {items.map((item, index) =>
72 item.separator ? (
73 <div key={item.key ?? `sep-${index}`} className="floating-menu__separator" role="separator" />
74 ) : (
75 <button
76 key={index}
77 type="button"
78 disabled={item.disabled}
79 onClick={(event: ReactMouseEvent<HTMLButtonElement>) => {
80 event.stopPropagation();
81 if (!item.disabled) item.onSelect();
82 }}
83 >
84 {item.icon}
85 <span>{item.label}</span>
86 </button>
87 ),
88 )}
89 </>
90 );
91 }
92
92 lines Plain Text