返回 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
5 type PopoverPosition = {
6 left: number;
7 top: number;
8 };
9 type PopoverPhase = "closed" | "open" | "closing";
10
11 const EDGE_GAP = 8;
12 const DEFAULT_OFFSET = 8;
13 const MAX_INITIAL_POSITION_RETRY_FRAMES = 8;
14 export const ANCHORED_POPOVER_CLOSE_MS = 140;
15
16 function clamp(value: number, min: number, max: number): number {
17 return Math.min(Math.max(value, min), max);
18 }
19
20 function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean {
21 return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5;
22 }
23
24 function calculatePosition(
25 anchor: DOMRect,
26 menu: DOMRect,
27 align: "start" | "end",
28 offset: number,
29 placement: "auto" | "bottom",
30 ): PopoverPosition {
31 const viewportWidth = window.innerWidth;
32 const viewportHeight = window.innerHeight;
33 const preferredTop = anchor.top - menu.height - offset;
34 const fallbackTop = anchor.bottom + offset;
35 const top = placement === "bottom"
36 ? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP))
37 : preferredTop >= EDGE_GAP
38 ? preferredTop
39 : Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP));
40 const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left;
41 const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP));
42 return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) };
43 }
44
45 export function AnchoredPopover({
46 open,
47 anchorRef,
48 onClose,
49 className,
50 children,
51 align = "start",
52 offset = DEFAULT_OFFSET,
53 placement = "auto",
54 style,
55 closing = false,
56 }: {
57 open: boolean;
58 anchorRef: RefObject<HTMLElement | null>;
59 onClose: () => void;
60 className: string;
61 children: ReactNode;
62 align?: "start" | "end";
63 offset?: number;
64 placement?: "auto" | "bottom";
65 style?: CSSProperties;
66 closing?: boolean;
67 }) {
68 const [phase, setPhase] = useState<PopoverPhase>(open ? "open" : "closed");
69 const [position, setPosition] = useState<PopoverPosition | null>(null);
70 const popoverRef = useRef<HTMLDivElement>(null);
71 const phaseRef = useRef<PopoverPhase>(phase);
72 const positionRef = useRef<PopoverPosition | null>(position);
73
74 useLayoutEffect(() => {
75 let id: number | undefined;
76 if (open) {
77 phaseRef.current = "open";
78 setPhase("open");
79 return undefined;
80 }
81 if (phaseRef.current === "closed") return undefined;
82 phaseRef.current = "closing";
83 setPhase("closing");
84 const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
85 id = window.setTimeout(() => {
86 phaseRef.current = "closed";
87 setPhase("closed");
88 positionRef.current = null;
89 setPosition(null);
90 }, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
91 return () => {
92 if (id !== undefined) window.clearTimeout(id);
93 };
94 }, [open]);
95
96 const rendered = closing || phase !== "closed";
97
98 useLayoutEffect(() => {
99 if (!rendered) {
100 positionRef.current = null;
101 setPosition(null);
102 return;
103 }
104 let frame: number | null = null;
105 let initialMeasurementRetries = 0;
106 const scheduleUpdate = () => {
107 if (frame !== null) return;
108 frame = window.requestAnimationFrame(updatePosition);
109 };
110 const updatePosition = () => {
111 frame = null;
112 const anchor = anchorRef.current?.getBoundingClientRect();
113 const menu = popoverRef.current?.getBoundingClientRect();
114 // Keep an existing valid position while either element is temporarily
115 // unavailable. Before the first measurement, the render fallback below
116 // keeps the popover visible without pretending layout is ready. Retry a
117 // bounded number of frames so a late anchor can still become measurable.
118 if (!anchor || !menu) {
119 if (
120 positionRef.current === null &&
121 initialMeasurementRetries < MAX_INITIAL_POSITION_RETRY_FRAMES
122 ) {
123 initialMeasurementRetries += 1;
124 scheduleUpdate();
125 }
126 return;
127 }
128 initialMeasurementRetries = 0;
129 const next = calculatePosition(anchor, menu, align, offset, placement);
130 positionRef.current = next;
131 setPosition((current) => (samePosition(current, next) ? current : next));
132 };
133 updatePosition();
134 scheduleUpdate();
135
136 const anchor = anchorRef.current;
137 const menu = popoverRef.current;
138 let observer: ResizeObserver | null = null;
139 if (typeof ResizeObserver !== "undefined") {
140 observer = new ResizeObserver(scheduleUpdate);
141 if (anchor) observer.observe(anchor);
142 if (menu) observer.observe(menu);
143 }
144 // Portaled popovers use viewport coordinates; scrollable ancestors move the anchor.
145 window.addEventListener("scroll", scheduleUpdate, true);
146 window.visualViewport?.addEventListener("scroll", scheduleUpdate);
147 window.visualViewport?.addEventListener("resize", scheduleUpdate);
148
149 return () => {
150 if (frame !== null) window.cancelAnimationFrame(frame);
151 observer?.disconnect();
152 window.removeEventListener("scroll", scheduleUpdate, true);
153 window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
154 window.visualViewport?.removeEventListener("resize", scheduleUpdate);
155 };
156 }, [rendered, anchorRef, align, offset, placement]);
157
158 useEffect(() => {
159 if (!open || closing) return;
160 const closeOnEscape = (event: KeyboardEvent) => {
161 if (event.key === "Escape") onClose();
162 };
163 const closeOnOutsideClick = (event: MouseEvent) => {
164 const target = event.target;
165 if (!(target instanceof Node)) return;
166 if (popoverRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
167 onClose();
168 };
169 const closeOnViewportChange = () => onClose();
170 window.addEventListener("keydown", closeOnEscape);
171 document.addEventListener("click", closeOnOutsideClick);
172 window.addEventListener("resize", closeOnViewportChange);
173 return () => {
174 window.removeEventListener("keydown", closeOnEscape);
175 document.removeEventListener("click", closeOnOutsideClick);
176 window.removeEventListener("resize", closeOnViewportChange);
177 };
178 }, [anchorRef, onClose, open]);
179
180 if (!rendered) return null;
181
182 return createPortal(
183 <div
184 ref={popoverRef}
185 data-anchored-popover="active"
186 data-ready={position ? "true" : "false"}
187 data-state={closing || phase === "closing" ? "closing" : "open"}
188 aria-hidden={closing || phase === "closing" ? true : undefined}
189 className={`anchored-popover ${className}`}
190 style={{
191 ...style,
192 left: position?.left ?? EDGE_GAP,
193 top: position?.top ?? EDGE_GAP,
194 visibility: "visible",
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