import React, { useRef, useEffect, HTMLProps } from "react"; import { ReferenceObject } from "popper.js"; import { Bubble, BubbleContentProps, BubbleProps } from "../bubble"; import { TriggerProps } from "../popover"; import { isMobile } from "../_util/is-mobile"; /** * 使用 Tooltip 的内容需要使用容器包裹。 * * 默认会使用 span 包裹内容,如有需要,可以改为使用 div 包裹。 */ export interface TooltipProps { /** * Tooltip 放置位置 * @default "bottom-start" */ placement?: BubbleContentProps["placement"]; /** * 文本提示内容 */ title?: React.ReactNode; /** * 要提示的对象 */ children?: React.ReactNode; /** * 出现延迟时间(ms) * @default 600 */ openDelay?: number; /** * 距上次 Tooltip 出现 1s 内再次出现延迟时间(ms) * @default 300 */ openDelayWhenHasInstance?: number; /** * 挂载浮层组件的节点 * @default document.body * @since 2.5.4 */ popupContainer?: BubbleProps["popupContainer"]; } let visibleTooltipCount = 0; /** * 控制 Tooltip 的触发机制 * * Tooltip 跟普通的 Bubble 不一样: * * 1. 出现位置不参考元素,参考鼠标 * 2. 如果场上无 Tooltip 实例,则激活后 0.6s 后弹出 * 3. 如果场上有 Tooltip 实例,则激活后 0.3s 后弹出 * * 设计上面的规则,是为了对齐系统 Tooltip 的交互 */ const TooltipTrigger = ({ delay, openDelay = delay, closeDelay = 0, setVisible, render, openDelayWhenHasInstance, }: TriggerProps & { delay: number; openDelayWhenHasInstance: number }) => { // 当前鼠标的位置,用作 Tooltip 弹出位置的参考 const mouseElement = useRef< ReferenceObject & { scrollTop: number; scrollLeft: number } >(null); // Tooltip 确定要激活,在延时出来期间,需要监听鼠标新位置,这个引用保存监听的取消方法 const mouseWatching = useRef<() => void>(null); // 从鼠标事件获得的坐标信息,构造鼠标参考位置 const mark = useRef((evt: MouseEvent) => { const width = 0; const height = 0; const clientX = Math.round(evt.clientX); const clientY = Math.round(evt.clientY); mouseElement.current = { clientWidth: width, clientHeight: height, getBoundingClientRect: () => ({ left: clientX, top: clientY, right: clientX + width, bottom: clientY + height, width, height, }), // IE10 下 Popper 读取这两个值计算 scrollTop: 0, scrollLeft: 0, }; }); // 监听鼠标位置变化,更新位置 const watch = () => { document.addEventListener("mousemove", mark.current); mouseWatching.current = () => document.removeEventListener("mousemove", mark.current); return mouseWatching.current; }; // 取消当前的位置变化监听 const unwatch = () => { if (mouseWatching.current) { mouseWatching.current(); mouseWatching.current = null; } }; // 用户鼠标进入目标元素 const enter = async (evt: React.MouseEvent) => { // 记录鼠标位置并跟踪 mark.current(evt.nativeEvent); watch(); // Tooltip 出来后,更新实例数量,并且可以取消跟踪了(Tooltip 出来的时候,鼠标在哪儿,就在哪儿,无需跟随) await setVisible( true, visibleTooltipCount > 0 ? openDelayWhenHasInstance : openDelay ); visibleTooltipCount += 1; unwatch(); }; // 用户鼠标离开目标元素 const leave = async () => { // 取消原有的跟踪 unwatch(); // 完全隐藏后,延时 1 秒更新实例数量 // 这里延时 1 秒是给用户留足够的空间,不至于由于鼠标走的慢导致下次 Tooltip 又要等很久 await setVisible(false, closeDelay); // CD 1 秒 await new Promise(resolve => setTimeout(resolve, 1000)); visibleTooltipCount -= 1; }; // 组件 unmount 的时候,清理一切 useEffect( () => () => { mouseElement.current = null; // React 18 会触发卸载🤔,先取消这里 // mark.current = null; unwatch(); }, [] ); return render({ childrenProps: { onMouseEnter: enter, onMouseLeave: leave, }, overlayProps: {}, referenceElement: mouseElement.current, }); }; TooltipTrigger.displayName = "TooltipTrigger"; function TooltipMobileTrigger(props: TriggerProps) { const { setVisible, openDelay = 1000, closeDelay = 100, render } = props; const commonProps: HTMLProps = { onTouchStart: () => setVisible(true, openDelay), onTouchEnd: () => setVisible(false, closeDelay), }; return render({ overlayProps: commonProps, childrenProps: commonProps, }); } TooltipMobileTrigger.displayName = "TooltipTrigger"; export function Tooltip({ title, placement = "bottom-start", children, openDelay = 600, openDelayWhenHasInstance = 300, popupContainer, }: TooltipProps) { return ( {children} ); } Tooltip.displayName = "Tooltip";