Files
jiang13-bbs/frontend/components/TipHost.tsx
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

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

136 lines
3.5 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;
/**
* 全站悬停即显提示:监听 [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 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;
}
const text = host.getAttribute("data-tip")?.trim();
if (!text) {
hide();
return;
}
activeEl.current = host;
placeTip(host, text);
};
const onOut = (e: Event) => {
if (!(e instanceof MouseEvent)) return;
const related = e.relatedTarget;
if (
related instanceof Node &&
activeEl.current &&
activeEl.current.contains(related)
) {
return;
}
hide();
};
const onScrollOrResize = () => {
if (!activeEl.current) return;
const text = activeEl.current.getAttribute("data-tip")?.trim();
if (!text) {
hide();
return;
}
placeTip(activeEl.current, text);
};
// 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
);
}