返回 DeepSeek-Reasonix
Tooltip.tsx
根目录 / desktop / frontend / src / components / Tooltip.tsx
1 import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2 import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } 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 TooltipSide = "top" | "bottom" | "left" | "right";
12 type TooltipPosition = { left: number; top: number; side: TooltipSide; arrowX: number; arrowY: number };
13
14 const GAP = 8;
15 const EDGE_PAD = 8;
16 const ARROW_SIZE = 7;
17 const ARROW_PAD = 12;
18
19 function clamp(value: number, min: number, max: number): number {
20 return Math.min(max, Math.max(min, value));
21 }
22
23 function oppositeSide(side: TooltipSide): TooltipSide {
24 if (side === "top") return "bottom";
25 if (side === "bottom") return "top";
26 if (side === "left") return "right";
27 return "left";
28 }
29
30 function samePosition(
31 current: TooltipPosition | null,
32 next: TooltipPosition,
33 ): boolean {
34 return !!current && (
35 current.side === next.side &&
36 Math.abs(current.left - next.left) < 0.5 &&
37 Math.abs(current.top - next.top) < 0.5 &&
38 Math.abs(current.arrowX - next.arrowX) < 0.5 &&
39 Math.abs(current.arrowY - next.arrowY) < 0.5
40 );
41 }
42
43 export function Tooltip({
44 label,
45 children,
46 side = "top",
47 fill = false,
48 block = false,
49 disabled = false,
50 className,
51 delay = 180,
52 }: {
53 label?: ReactNode;
54 children: ReactNode;
55 side?: TooltipSide;
56 fill?: boolean;
57 block?: boolean;
58 disabled?: boolean;
59 className?: string;
60 delay?: number;
61 }) {
62 const id = useId();
63 const triggerRef = useRef<HTMLElement | null>(null);
64 const tooltipRef = useRef<HTMLDivElement>(null);
65 const showTimerRef = useRef<number | null>(null);
66 const [open, setOpen] = useState(false);
67 const [position, setPosition] = useState<TooltipPosition | null>(null);
68 const positionRef = useRef<TooltipPosition | null>(null);
69 const active = !disabled && label !== undefined && label !== null && label !== "";
70
71 const clearTimer = useCallback(() => {
72 if (showTimerRef.current === null) return;
73 window.clearTimeout(showTimerRef.current);
74 showTimerRef.current = null;
75 }, []);
76
77 const show = (delay = 180) => {
78 if (!active) return;
79 clearTimer();
80 showTimerRef.current = window.setTimeout(() => {
81 showTimerRef.current = null;
82 if (validAnchorRect(triggerRef.current)) setOpen(true);
83 }, delay);
84 };
85
86 const hide = useCallback(() => {
87 clearTimer();
88 setOpen(false);
89 positionRef.current = null;
90 setPosition(null);
91 }, [clearTimer]);
92
93 useLayoutEffect(() => {
94 if (!open || !active) return;
95 let frame: number | null = null;
96 let measured = false;
97 let initialMeasurementRetries = 0;
98 const updatePosition = () => {
99 frame = null;
100 const trigger = triggerRef.current;
101 const rect = validAnchorRect(trigger);
102 const tip = tooltipRef.current;
103 const tipSize = tip ? elementLayoutSize(tip) : null;
104 if (!rect || !tipSize) {
105 const explicitlyHidden = !!trigger && (!trigger.isConnected || isElementExplicitlyHidden(trigger));
106 if (!measured && !explicitlyHidden && initialMeasurementRetries < MAX_INITIAL_OVERLAY_MEASUREMENT_FRAMES) {
107 initialMeasurementRetries += 1;
108 frame = window.requestAnimationFrame(updatePosition);
109 } else {
110 hide();
111 }
112 return;
113 }
114 measured = true;
115 initialMeasurementRetries = 0;
116 const space = {
117 top: rect.top - EDGE_PAD,
118 bottom: window.innerHeight - rect.bottom - EDGE_PAD,
119 left: rect.left - EDGE_PAD,
120 right: window.innerWidth - rect.right - EDGE_PAD,
121 };
122 let actualSide = side;
123 if ((side === "top" || side === "bottom") && space[side] < tipSize.height + GAP + ARROW_SIZE) {
124 const opposite = oppositeSide(side);
125 if (space[opposite] > space[side]) actualSide = opposite;
126 } else if ((side === "left" || side === "right") && space[side] < tipSize.width + GAP + ARROW_SIZE) {
127 const opposite = oppositeSide(side);
128 if (space[opposite] > space[side]) actualSide = opposite;
129 }
130
131 let left =
132 actualSide === "left"
133 ? rect.left - tipSize.width - GAP - ARROW_SIZE
134 : actualSide === "right"
135 ? rect.right + GAP + ARROW_SIZE
136 : rect.left + rect.width / 2 - tipSize.width / 2;
137 let top =
138 actualSide === "top"
139 ? rect.top - tipSize.height - GAP - ARROW_SIZE
140 : actualSide === "bottom"
141 ? rect.bottom + GAP + ARROW_SIZE
142 : rect.top + rect.height / 2 - tipSize.height / 2;
143
144 left = clamp(left, EDGE_PAD, window.innerWidth - tipSize.width - EDGE_PAD);
145 top = clamp(top, EDGE_PAD, window.innerHeight - tipSize.height - EDGE_PAD);
146 const arrowX = clamp(rect.left + rect.width / 2 - left, ARROW_PAD, tipSize.width - ARROW_PAD);
147 const arrowY = clamp(rect.top + rect.height / 2 - top, ARROW_PAD, tipSize.height - ARROW_PAD);
148
149 const next = { left, top, side: actualSide, arrowX, arrowY };
150 if (!samePosition(positionRef.current, next)) {
151 positionRef.current = next;
152 setPosition(next);
153 }
154 frame = window.requestAnimationFrame(updatePosition);
155 };
156 updatePosition();
157 return () => {
158 if (frame !== null) window.cancelAnimationFrame(frame);
159 };
160 }, [active, hide, open, side]);
161
162 useEffect(() => {
163 if (!active) hide();
164 return () => clearTimer();
165 }, [active, clearTimer, hide]);
166
167 const triggerClass = `tooltip-trigger${fill ? " tooltip-trigger--fill" : ""}${block ? " tooltip-trigger--block" : ""}${className ? ` ${className}` : ""}`;
168 const setTriggerRef = (node: HTMLElement | null) => {
169 triggerRef.current = node;
170 };
171 const triggerProps = {
172 className: triggerClass,
173 "aria-describedby": open ? id : undefined,
174 onMouseEnter: () => show(delay),
175 onMouseLeave: hide,
176 onPointerDownCapture: hide,
177 onFocus: () => show(0),
178 onBlur: hide,
179 onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => {
180 if (event.key === "Escape" || event.key === "Enter" || event.key === " ") hide();
181 },
182 };
183
184 return (
185 <>
186 {block ? <div ref={setTriggerRef} {...triggerProps}>{children}</div> : <span ref={setTriggerRef} {...triggerProps}>{children}</span>}
187 {open &&
188 active &&
189 createPortal(
190 <div
191 id={id}
192 ref={tooltipRef}
193 className={`tooltip tooltip--${position?.side ?? side}`}
194 role="tooltip"
195 style={{
196 left: position?.left ?? 0,
197 top: position?.top ?? 0,
198 visibility: position ? "visible" : "hidden",
199 pointerEvents: position ? undefined : "none",
200 "--tooltip-arrow-x": `${position?.arrowX ?? 0}px`,
201 "--tooltip-arrow-y": `${position?.arrowY ?? 0}px`,
202 } as CSSProperties}
203 >
204 {label}
205 </div>,
206 document.body,
207 )}
208 </>
209 );
210 }
211
211 lines Plain Text