"use client"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; type TipState = { text: string; x: number; y: number; place: "top" | "bottom"; } | null; function norm(s: string): string { return s.replace(/\s+/g, " ").trim(); } /** 可见文字:跳过 aria-hidden / svg / img,避免把图标标题算进来 */ function getVisibleText(root: Element): string { const parts: string[] = []; const walk = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { const t = node.textContent?.replace(/\s+/g, " ").trim(); if (t) parts.push(t); return; } if (!(node instanceof Element)) return; if (node.getAttribute("aria-hidden") === "true") return; const tag = node.tagName.toUpperCase(); if (tag === "SVG" || tag === "IMG" || tag === "CANVAS" || tag === "PATH") return; for (const child of node.childNodes) walk(child); }; walk(root); return norm(parts.join(" ")); } /** 是否存在省略截断(含 line-clamp) */ function isVisuallyTruncated(root: Element): boolean { const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))]; for (const el of nodes) { if (!(el instanceof HTMLElement)) continue; if (el.clientWidth <= 0 && el.clientHeight <= 0) continue; const st = getComputedStyle(el); const ellipsis = st.textOverflow === "ellipsis" || el.classList.contains("truncate"); const nowrapHidden = (st.overflowX === "hidden" || st.overflow === "hidden") && (st.whiteSpace === "nowrap" || st.whiteSpace === "pre"); const clampRaw = st.getPropertyValue("-webkit-line-clamp").trim(); const clamped = Boolean(clampRaw) && clampRaw !== "none"; if (ellipsis || nowrapHidden) { if (el.scrollWidth > el.clientWidth + 1) return true; } if (clamped && el.scrollHeight > el.clientHeight + 1) return true; } return false; } /** * 仅在以下场景显示气泡: * 1. 提示文案与可见内容不一致(且提示未完整出现在可见文字中) * 2. 停留目标无可见文字(图标等) * 3. 可见内容被省略截断,需要补全 */ function shouldShowTip(host: Element, tip: string): boolean { if (isVisuallyTruncated(host)) return true; const visible = getVisibleText(host); if (!visible) return true; const nTip = norm(tip); const nVis = visible; if (nTip === nVis) return false; if (nVis.includes(nTip)) return false; return true; } /** * 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。 * 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。 */ export default function TipHost() { const [tip, setTip] = useState(null); const tipRef = useRef(null); const activeEl = useRef(null); useEffect(() => { const hide = () => { activeEl.current = null; setTip(null); }; const placeTip = (el: Element, text: string) => { const rect = el.getBoundingClientRect(); const preferBottom = rect.top < 48; const x = rect.left + rect.width / 2; const y = preferBottom ? rect.bottom + 8 : rect.top - 8; setTip({ text, x, y, place: preferBottom ? "bottom" : "top", }); }; const tryShow = (host: Element) => { const text = host.getAttribute("data-tip")?.trim(); if (!text || !shouldShowTip(host, text)) { hide(); return; } activeEl.current = host; placeTip(host, text); }; const onOver = (e: Event) => { const t = e.target; if (!(t instanceof Element)) return; const host = t.closest("[data-tip]"); if (!host) { if (activeEl.current) hide(); return; } tryShow(host); }; const onOut = (e: Event) => { if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return; const related = "relatedTarget" in e ? e.relatedTarget : null; if ( related instanceof Node && activeEl.current && activeEl.current.contains(related) ) { return; } hide(); }; const onScrollOrResize = () => { if (!activeEl.current) return; tryShow(activeEl.current); }; // mouseover 冒泡,便于 closest;立即显示,无延时 document.addEventListener("mouseover", onOver); document.addEventListener("mouseout", onOut); document.addEventListener("focusin", onOver); document.addEventListener("focusout", onOut); window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); return () => { document.removeEventListener("mouseover", onOver); document.removeEventListener("mouseout", onOut); document.removeEventListener("focusin", onOver); document.removeEventListener("focusout", onOut); window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }; }, []); // 测量后微调,避免贴边裁切 useEffect(() => { if (!tip || !tipRef.current) return; const el = tipRef.current; const w = el.offsetWidth; const pad = 10; let left = tip.x; const half = w / 2; if (left - half < pad) left = pad + half; if (left + half > window.innerWidth - pad) { left = window.innerWidth - pad - half; } el.style.left = `${left}px`; }, [tip]); if (!tip) return null; return createPortal(
{tip.text}
, document.body ); }