import { useState, useEffect, useRef, ReactNode } from 'react'; import { createPortal } from 'react-dom'; import '../../styles/components/TooltipStyles.css'; // 提示框主题 export type TooltipTheme = 'dark' | 'light' | 'primary' | 'success' | 'warning' | 'error' | 'info'; // 提示框位置 export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'; // 提示框属性 export interface TooltipProps { children: ReactNode; // 触发元素 content: ReactNode; // 提示内容 theme?: TooltipTheme; // 主题样式 placement?: TooltipPlacement; // 显示位置 rich?: boolean; // 是否为富文本提示 header?: ReactNode; // 富文本提示标题 footer?: ReactNode; // 富文本提示页脚 trigger?: 'hover' | 'click'; // 触发方式 visible?: boolean; // 是否显示(受控模式) showArrow?: boolean; // 是否显示箭头 maxWidth?: number | string; // 最大宽度 className?: string; // 自定义类名 onVisibleChange?: (visible: boolean) => void; // 显示状态变化回调 fixedPlacement?: boolean; // 是否固定显示位置,不自动切换 maxHeight?: number | string; // 最大高度,超出将显示滚动条 scrollable?: boolean; // 是否可滚动 } /** * Tooltip 组件 * * 提供增强的悬浮提示功能,支持多种主题、位置和富文本模式 */ export function Tooltip({ children, content, theme = 'dark', placement = 'top', rich = false, header, footer, trigger = 'hover', visible: controlledVisible, showArrow = true, maxWidth = 320, className = '', onVisibleChange, fixedPlacement = false, // 默认不固定位置 maxHeight = 300, // 默认最大高度300px scrollable = true // 默认允许滚动 }: TooltipProps) { // 使用内部状态管理提示框显示状态(非受控模式) const [visible, setVisible] = useState(false); // 判断是否为受控组件 const isControlled = controlledVisible !== undefined; // 获取实际显示状态 const isVisible = isControlled ? controlledVisible : visible; // 添加一个状态来控制实际渲染,解决位置跳跃问题 const [readyToShow, setReadyToShow] = useState(false); // 引用DOM元素 const triggerRef = useRef(null); const tooltipRef = useRef(null); const contentRef = useRef(null); // 保存当前实际显示位置 const [actualPlacement, setActualPlacement] = useState(placement); // 处理显示状态变化 const handleVisibleChange = (newVisible: boolean) => { if (!isControlled) { setVisible(newVisible); } // 当显示状态变为false时,立即隐藏 if (!newVisible) { setReadyToShow(false); onVisibleChange?.(newVisible); // 当显示状态变为false时,重置actualPlacement为初始placement setActualPlacement(placement); } else { // 当显示状态变为true时,先延迟设置readyToShow // 这样允许位置预计算完成后再显示tooltip onVisibleChange?.(newVisible); } }; // 触发器事件处理 const triggerEvents = trigger === 'hover' ? { onMouseEnter: () => handleVisibleChange(true), onMouseLeave: () => handleVisibleChange(false), } : { onClick: () => handleVisibleChange(!isVisible), }; // 更新提示框位置的函数 const updateTooltipPosition = () => { if (!isVisible || !triggerRef.current || !tooltipRef.current) return; // 获取触发元素位置 const triggerRect = triggerRef.current.getBoundingClientRect(); // 设置最大宽度 tooltipRef.current.style.maxWidth = typeof maxWidth === 'number' ? `${maxWidth}px` : String(maxWidth); // 如果内容可滚动,设置最大高度,但仅应用于内容容器而不是整个tooltip if (scrollable && contentRef.current) { contentRef.current.style.maxHeight = typeof maxHeight === 'number' ? `${maxHeight}px` : String(maxHeight); // 确保只有内容区域显示滚动条,整个tooltip容器不滚动 contentRef.current.style.overflowY = 'auto'; tooltipRef.current.style.overflow = 'visible'; } // 强制重新计算布局,确保maxHeight生效后再获取尺寸 tooltipRef.current.style.visibility = 'hidden'; tooltipRef.current.style.display = 'block'; // 触发重排,确保maxHeight限制已应用 void tooltipRef.current.offsetHeight; // 获取应用maxHeight后的实际tooltip尺寸 const tooltipRect = tooltipRef.current.getBoundingClientRect(); // 计算各个方向的位置 let top = 0, left = 0; let arrowTop = 0, arrowLeft = 0; const arrowSize = 8; // 箭头大小 const gap = 10; // 提示框与触发元素的间距 // 使用actualPlacement而不是placement来计算位置 let currentPlacement = actualPlacement; switch (currentPlacement) { case 'top': left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2); top = triggerRect.top - tooltipRect.height - arrowSize; arrowLeft = tooltipRect.width / 2 - arrowSize; arrowTop = tooltipRect.height; break; case 'bottom': left = triggerRect.left + (triggerRect.width / 2) - (tooltipRect.width / 2); top = triggerRect.bottom + arrowSize - 8; arrowLeft = tooltipRect.width / 2 - arrowSize; arrowTop = -arrowSize; break; case 'left': left = triggerRect.left - tooltipRect.width - arrowSize; top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2); arrowLeft = tooltipRect.width; arrowTop = tooltipRect.height / 2 - arrowSize; break; case 'right': left = triggerRect.right + arrowSize; top = triggerRect.top + (triggerRect.height / 2) - (tooltipRect.height / 2); arrowLeft = -arrowSize; arrowTop = tooltipRect.height / 2 - arrowSize; break; } // 确保提示框不超出视口 const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // 调整位置,避免超出视口 if (left < 0) { left = gap; arrowLeft = Math.max(triggerRect.left + (triggerRect.width / 2) - left - arrowSize, arrowSize * 2); } else if (left + tooltipRect.width > viewportWidth) { left = viewportWidth - tooltipRect.width - gap; arrowLeft = Math.min(triggerRect.left + (triggerRect.width / 2) - left - arrowSize, tooltipRect.width - arrowSize * 2); } // 如果fixedPlacement为true,则强制使用指定的位置,不自动切换 if (!fixedPlacement) { // 自动调整位置逻辑 if (top < 0) { if (currentPlacement === 'top') { // 如果上方放不下,切换到下方 top = triggerRect.bottom + arrowSize - 8; arrowTop = -arrowSize; // 更新实际位置为bottom setActualPlacement('bottom'); currentPlacement = 'bottom'; // 更新CSS类 tooltipRef.current.classList.remove('tooltip-top'); tooltipRef.current.classList.add('tooltip-bottom'); } else { top = gap; arrowTop = Math.max(triggerRect.top + (triggerRect.height / 2) - top - arrowSize, arrowSize * 2); } } else if (top + tooltipRect.height > viewportHeight) { if (currentPlacement === 'bottom') { // 如果下方放不下,切换到上方 top = triggerRect.top - tooltipRect.height - arrowSize; arrowTop = tooltipRect.height; // 更新实际位置为top setActualPlacement('top'); currentPlacement = 'top'; // 更新CSS类 tooltipRef.current.classList.remove('tooltip-bottom'); tooltipRef.current.classList.add('tooltip-top'); } else { top = viewportHeight - tooltipRect.height - gap; arrowTop = Math.min(triggerRect.top + (triggerRect.height / 2) - top - arrowSize, tooltipRect.height - arrowSize * 2); } } // 处理左右方向的切换 if (left < 0 && currentPlacement === 'left') { // 如果左边放不下,切换到右边 left = triggerRect.right + arrowSize; arrowLeft = -arrowSize; // 更新实际位置为right setActualPlacement('right'); currentPlacement = 'right'; // 更新CSS类 tooltipRef.current.classList.remove('tooltip-left'); tooltipRef.current.classList.add('tooltip-right'); } else if (left + tooltipRect.width > viewportWidth && currentPlacement === 'right') { // 如果右边放不下,切换到左边 left = triggerRect.left - tooltipRect.width - arrowSize; arrowLeft = tooltipRect.width; // 更新实际位置为left setActualPlacement('left'); currentPlacement = 'left'; // 更新CSS类 tooltipRef.current.classList.remove('tooltip-right'); tooltipRef.current.classList.add('tooltip-left'); } } else { // 固定位置,但仍然需要确保在视口内可见 if (currentPlacement === 'top') { // 如果提示框超出了顶部,增加偏移 if (top < 0) { const originalTop = top; top = gap; // 调整箭头位置,使其指向触发元素 arrowTop = arrowTop + (originalTop - top); } } else if (currentPlacement === 'bottom') { // 如果提示框超出了底部,减少偏移 if (top + tooltipRect.height > viewportHeight) { const originalTop = top; top = viewportHeight - tooltipRect.height - gap; // 调整箭头位置,使其指向触发元素 arrowTop = arrowTop - (originalTop - top); } } else if (currentPlacement === 'left') { // 如果提示框超出了左侧,增加偏移 if (left < 0) { const originalLeft = left; left = gap; // 调整箭头位置,使其指向触发元素 arrowLeft = arrowLeft + (originalLeft - left); } } else if (currentPlacement === 'right') { // 如果提示框超出了右侧,减少偏移 if (left + tooltipRect.width > viewportWidth) { const originalLeft = left; left = viewportWidth - tooltipRect.width - gap; // 调整箭头位置,使其指向触发元素 arrowLeft = arrowLeft - (originalLeft - left); } } } // 应用位置样式 tooltipRef.current.style.position = 'fixed'; tooltipRef.current.style.top = `${top}px`; tooltipRef.current.style.left = `${left}px`; tooltipRef.current.style.transform = 'none'; // 重置任何变换 // 定位箭头 const arrow = tooltipRef.current.querySelector('.tooltip-arrow') as HTMLElement; if (arrow && showArrow) { // 使用当前实际的位置来定位箭头 switch (currentPlacement) { case 'top': arrow.style.bottom = `-${arrowSize}px`; arrow.style.left = `${arrowLeft}px`; arrow.style.top = 'auto'; arrow.style.right = 'auto'; // 设置箭头指向下方的样式 arrow.className = 'tooltip-arrow tooltip-arrow-bottom'; break; case 'bottom': arrow.style.top = `-${arrowSize}px`; arrow.style.left = `${arrowLeft}px`; arrow.style.bottom = 'auto'; arrow.style.right = 'auto'; // 设置箭头指向上方的样式 arrow.className = 'tooltip-arrow tooltip-arrow-top'; break; case 'left': arrow.style.right = `-${arrowSize}px`; arrow.style.top = `${arrowTop}px`; arrow.style.bottom = 'auto'; arrow.style.left = 'auto'; // 设置箭头指向右方的样式 arrow.className = 'tooltip-arrow tooltip-arrow-right'; break; case 'right': arrow.style.left = `-${arrowSize}px`; arrow.style.top = `${arrowTop}px`; arrow.style.bottom = 'auto'; arrow.style.right = 'auto'; // 设置箭头指向左方的样式 arrow.className = 'tooltip-arrow tooltip-arrow-left'; break; } } // 恢复可见性(位置计算完成后) tooltipRef.current.style.visibility = 'visible'; // 如果位置已更新且还没有显示,现在可以显示了 if (!readyToShow) { // 使用requestAnimationFrame确保DOM更新后再显示 requestAnimationFrame(() => { setReadyToShow(true); }); } }; // 当组件首次挂载或placement改变时,重置actualPlacement useEffect(() => { setActualPlacement(placement); }, [placement]); // 当isVisible状态变化时,处理初始化和清理工作 useEffect(() => { if (!isVisible) { // 当tooltip隐藏时,重置readyToShow状态 setReadyToShow(false); return; } // 创建一个隐藏的tooltip用于预计算位置 // 使用一个隐藏的div来渲染tooltip,并获取其尺寸 const preCalculatePosition = () => { // 初始时先不显示,让updateTooltipPosition完成位置计算 setReadyToShow(false); // 初始化位置计算 - 添加短暂延迟确保内容已渲染 setTimeout(() => { updateTooltipPosition(); }, 10); }; preCalculatePosition(); // 添加滚动和调整大小事件监听器 window.addEventListener('scroll', updateTooltipPosition, true); window.addEventListener('resize', updateTooltipPosition); // 定期更新位置(处理内容动态变化的情况) const intervalId = setInterval(updateTooltipPosition, 300); // 清理函数 return () => { window.removeEventListener('scroll', updateTooltipPosition, true); window.removeEventListener('resize', updateTooltipPosition); clearInterval(intervalId); }; }, [isVisible, placement, maxWidth, showArrow, fixedPlacement]); // 处理点击外部关闭 useEffect(() => { if (trigger !== 'click' || !isVisible) return; const handleClickOutside = (event: MouseEvent) => { if ( triggerRef.current && !triggerRef.current.contains(event.target as Node) && tooltipRef.current && !tooltipRef.current.contains(event.target as Node) ) { handleVisibleChange(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [trigger, isVisible]); // 渲染提示框内容 const renderTooltipContent = () => { // 根据是否启用滚动来确定内容容器类名 const contentClassName = scrollable ? 'tooltip-content-inner tooltip-scrollable' : 'tooltip-content-inner'; if (rich) { return (
{header &&
{header}
}
{content}
{footer &&
{footer}
}
); } return (
{content}
); }; // 生成提示框类名 const tooltipClassNames = [ 'tooltip-container', `tooltip-${actualPlacement}`, // 使用actualPlacement而不是placement `tooltip-${theme}`, rich ? 'tooltip-rich' : '', readyToShow ? 'tooltip-visible' : 'tooltip-hidden', // 使用readyToShow控制可见性 fixedPlacement ? 'tooltip-fixed-placement' : '', className ].filter(Boolean).join(' '); // 使用Portal渲染提示框,但根据readyToShow来控制可见性样式 const tooltipPortal = isVisible && typeof document !== 'undefined' ? createPortal(
{renderTooltipContent()} {showArrow &&
}
, document.body ) : null; return (
{children} {tooltipPortal}
); } export default Tooltip;