"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(" ")); } /** 原地展开全文控件:文案须是「展开/收起/展开全部…」,不含「展开 N 条回复」 */ function isFullTextExpandControl(el: Element): boolean { const t = (el.textContent || "").replace(/\s+/g, " ").trim(); if (!t) return false; if (t === "展开" || t === "收起") return true; if (/^(展开全部|查看全文|显示全部|显示全文)/.test(t)) return true; return false; } /** * 悬浮目标已能原地展开全文(有 data-expandable,或同级紧随展开按钮)。 * 此时气泡只是复述,不再弹出。 */ function hasFullTextExpand(host: Element): boolean { if (host.closest("[data-expandable]")) return true; const scope = host.parentElement; if (!scope) return false; for (const btn of scope.querySelectorAll("button, [role='button']")) { if (host.contains(btn)) continue; if (btn.parentElement !== scope) continue; const pos = host.compareDocumentPosition(btn); if (!(pos & Node.DOCUMENT_POSITION_FOLLOWING)) continue; if (isFullTextExpandControl(btn)) return true; } return false; } /** 单节点是否视觉截断(省略号 / nowrap / line-clamp) */ function elementIsTruncated(el: HTMLElement): boolean { if (el.clientWidth <= 0 && el.clientHeight <= 0) return false; 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; } /** 提示是否在补全某段被截断的文字(data-tip 须等于该节点全文) */ function tipCompletesTruncation(root: Element, tip: string): boolean { const nTip = norm(tip); if (!nTip) return false; const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))]; for (const el of nodes) { if (!(el instanceof HTMLElement) || !elementIsTruncated(el)) continue; const t = getVisibleText(el); if (t && norm(t) === nTip) return true; } return false; } /** 去掉箭头等连接符后的词元;用于判断提示是否已画在界面上 */ function tipTokens(s: string): string[] { return s .replace(/[→←↔\-–—>|/·•]/g, " ") .split(/\s+/) .map((x) => x.trim()) .filter(Boolean); } /** 可见文字已覆盖提示(含「甲 → 乙」vs 已画出的甲、乙) */ function visibleCoversTip(visible: string, tip: string): boolean { const nVis = norm(visible); const nTip = norm(tip); if (!nTip) return true; if (nVis === nTip || nVis.includes(nTip)) return true; const tokens = tipTokens(nTip); if (tokens.length === 0) return false; return tokens.every((tok) => nVis.includes(tok)); } /** * 仅在以下场景显示气泡: * 1. 提示文案与可见内容不一致(且提示未完整出现在可见文字中) * 2. 停留目标无可见文字(图标等) * 3. 可见内容被省略截断,且 data-tip 正是那段全文 * 例外:目标已能原地展开全文时不弹出;提示词已全部出现在界面上时不弹出 */ function shouldShowTip(host: Element, tip: string): boolean { if (hasFullTextExpand(host)) return false; if (tipCompletesTruncation(host, tip)) return true; const visible = getVisibleText(host); if (!visible) return true; if (visibleCoversTip(visible, tip)) 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(() => { let pendingTimer: number | null = null; const clearPending = () => { if (pendingTimer !== null) { window.clearTimeout(pendingTimer); pendingTimer = null; } }; const hide = () => { clearPending(); 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; } // 同一目标已在显示:直接复位位置,不重新计时 if (activeEl.current === host && pendingTimer === null) { placeTip(host, text); return; } // data-tip-delay="毫秒":停留一会再显示,期间移出即取消 const delay = Number(host.getAttribute("data-tip-delay") || 0); if (!Number.isFinite(delay) || delay <= 0) { clearPending(); activeEl.current = host; placeTip(host, text); return; } clearPending(); pendingTimer = window.setTimeout(() => { pendingTimer = null; const focused = host.contains(document.activeElement); if (!host.matches(":hover") && !focused) return; activeEl.current = host; placeTip(host, text); }, delay); }; 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 () => { clearPending(); 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 ); }