降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。 Co-authored-by: Cursor <cursoragent@cursor.com>
196 lines
5.8 KiB
TypeScript
196 lines
5.8 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(" "));
|
||
}
|
||
|
||
/** 是否存在省略截断(含 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<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
|
||
);
|
||
}
|