补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
136 lines
3.5 KiB
TypeScript
136 lines
3.5 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;
|
||
|
||
/**
|
||
* 全站悬停即显提示:监听 [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
|
||
);
|
||
}
|