251 lines
7.9 KiB
TypeScript
251 lines
7.9 KiB
TypeScript
"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<TipState>(null);
|
||
const tipRef = useRef<HTMLDivElement>(null);
|
||
const activeEl = useRef<Element | null>(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(
|
||
<div
|
||
ref={tipRef}
|
||
role="tooltip"
|
||
className="j13-tip-bubble"
|
||
data-place={tip.place}
|
||
style={{
|
||
left: tip.x,
|
||
top: tip.y,
|
||
transform:
|
||
tip.place === "top"
|
||
? "translate(-50%, -100%)"
|
||
: "translate(-50%, 0)",
|
||
}}
|
||
>
|
||
{tip.text}
|
||
</div>,
|
||
document.body
|
||
);
|
||
}
|