返回 DeepSeek-Reasonix
AnchoredPopover.tsx
根目录 / desktop / frontend / src / components / AnchoredPopover.tsx
1 import { useEffect, useLayoutEffect, useRef, useState } from "react";
2 import type { CSSProperties, ReactNode, RefObject } from "react";
3 import { createPortal } from "react-dom";
4 import {
5 elementLayoutSize,
6 isElementExplicitlyHidden,
7 MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES,
8 validAnchorRect,
9 } from "../lib/anchoredOverlay";
10
11 type PopoverPosition = {
12 left: number;
13 top: number;
14 };
15 type PopoverPhase = "closed" | "open" | "closing";
16
17 const EDGE_GAP = 8;
18 const DEFAULT_OFFSET = 8;
19 export const ANCHORED_POPOVER_CLOSE_MS = 140;
20
21 function clamp(value: number, min: number, max: number): number {
22 return Math.min(Math.max(value, min), max);
23 }
24
25 function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean {
26 return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5;
27 }
28
29 function calculatePosition(
30 anchor: DOMRect,
31 menu: { width: number; height: number },
32 align: "start" | "end",
33 offset: number,
34 placement: "auto" | "bottom",
35 ): PopoverPosition {
36 const viewportWidth = window.innerWidth;
37 const viewportHeight = window.innerHeight;
38 const preferredTop = anchor.top - menu.height - offset;
39 const fallbackTop = anchor.bottom + offset;
40 const top = placement === "bottom"
41 ? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP))
42 : preferredTop >= EDGE_GAP
43 ? preferredTop
44 : Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP));
45 const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left;
46 const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP));
47 return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) };
48 }
49
50 export function AnchoredPopover({
51 open,
52 anchorRef,
53 onClose,
54 className,
55 children,
56 align = "start",
57 offset = DEFAULT_OFFSET,
58 placement = "auto",
59 style,
60 closing = false,
61 }: {
62 open: boolean;
63 anchorRef: RefObject<HTMLElement | null>;
64 onClose: () => void;
65 className: string;
66 children: ReactNode;
67 align?: "start" | "end";
68 offset?: number;
69 placement?: "auto" | "bottom";
70 style?: CSSProperties;
71 closing?: boolean;
72 }) {
73 const [phase, setPhase] = useState<PopoverPhase>(open ? "open" : "closed");
74 const [position, setPosition] = useState<PopoverPosition | null>(null);
75 const popoverRef = useRef<HTMLDivElement>(null);
76 const phaseRef = useRef<PopoverPhase>(phase);
77 const positionRef = useRef<PopoverPosition | null>(position);
78 const onCloseRef = useRef(onClose);
79 const invalidCloseNotifiedRef = useRef(false);
80 onCloseRef.current = onClose;
81
82 useLayoutEffect(() => {
83 let id: number | undefined;
84 if (open) {
85 invalidCloseNotifiedRef.current = false;
86 phaseRef.current = "open";
87 setPhase("open");
88 return undefined;
89 }
90 if (phaseRef.current === "closed") return undefined;
91 phaseRef.current = "closing";
92 setPhase("closing");
93 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
94 id = window.setTimeout(() => {
95 phaseRef.current = "closed";
96 setPhase("closed");
97 positionRef.current = null;
98 setPosition(null);
99 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
100 return () => {
101 if (id !== undefined) window.clearTimeout(id);
102 };
103 }, [open]);
104
105 const rendered = closing || phase !== "closed";
106
107 useLayoutEffect(() => {
108 if (!rendered) {
109 positionRef.current = null;
110 setPosition(null);
111 return;
112 }
113 if (!open) return;
114 let frame: number | null = null;
115 let initialMeasurementRetries = 0;
116 const dismissInvalidAnchor = () => {
117 positionRef.current = null;
118 setPosition(null);
119 phaseRef.current = "closed";
120 setPhase("closed");
121 if (!invalidCloseNotifiedRef.current) {
122 invalidCloseNotifiedRef.current = true;
123 onCloseRef.current();
124 }
125 };
126 const updatePosition = () => {
127 frame = null;
128 const anchorElement = anchorRef.current;
129 const anchor = validAnchorRect(anchorElement);
130 const menuElement = popoverRef.current;
131 const menu = menuElement ? elementLayoutSize(menuElement) : null;
132 if (!anchor || !menu) {
133 const explicitlyHidden = !!anchorElement && (
134 !anchorElement.isConnected || isElementExplicitlyHidden(anchorElement)
135 );
136 if (positionRef.current === null && !explicitlyHidden && initialMeasurementRetries < MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES) {
137 initialMeasurementRetries += 1;
138 frame = window.requestAnimationFrame(updatePosition);
139 } else {
140 dismissInvalidAnchor();
141 }
142 return;
143 }
144 initialMeasurementRetries = 0;
145 const next = calculatePosition(anchor, menu, align, offset, placement);
146 if (!samePosition(positionRef.current, next)) {
147 positionRef.current = next;
148 setPosition(next);
149 }
150 frame = window.requestAnimationFrame(updatePosition);
151 };
152 updatePosition();
153
154 return () => {
155 if (frame !== null) window.cancelAnimationFrame(frame);
156 };
157 }, [rendered, open, anchorRef, align, offset, placement]);
158
159 useEffect(() => {
160 if (!open || closing) return;
161 const closeOnEscape = (event: KeyboardEvent) => {
162 if (event.key === "Escape") onClose();
163 };
164 const closeOnOutsideClick = (event: MouseEvent) => {
165 const target = event.target;
166 if (!(target instanceof Node)) return;
167 if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
168 onClose();
169 };
170 window.addEventListener("keydown", closeOnEscape);
171 document.addEventListener("click", closeOnOutsideClick);
172 return () => {
173 window.removeEventListener("keydown", closeOnEscape);
174 document.removeEventListener("click", closeOnOutsideClick);
175 };
176 }, [anchorRef, onClose, open]);
177
178 if (!rendered) return null;
179
180 return createPortal(
181 <div
182 ref={popoverRef}
183 data-app-overlay=""
184 data-anchored-popover="active"
185 data-ready={position ? "true" : "false"}
186 data-state={closing || phase === "closing" ? "closing" : "open"}
187 aria-hidden={closing || phase === "closing" ? true : undefined}
188 className={`anchored-popover ${className}`}
189 style={{
190 ...style,
191 left: position?.left ?? 0,
192 top: position?.top ?? 0,
193 visibility: position ? "visible" : "hidden",
194 pointerEvents: position ? undefined : "none",
195 }}
196 onMouseDown={(event) => {
197 event.stopPropagation();
198 }}
199 onClick={(event) => {
200 event.stopPropagation();
201 }}
202 >
203 {children}
204 </div>,
205 document.body,
206 );
207 }
208
208 lines Plain Text