Files
jiang13-bbs/frontend/components/TipHost.tsx
freefire 5507552ca5 polish(frontend): 侧栏与徽章可读性,智能悬停提示
降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:54:14 +08:00

196 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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
);
}