Files
jiang13-bbs/frontend/components/TipHost.tsx
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

281 lines
8.9 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(" "));
}
/** 原地展开全文控件:文案须是「展开/收起/展开全部…」,不含「展开 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(() => {
let pendingTimer: number | null = null;
const clearPending = () => {
if (pendingTimer !== null) {
window.clearTimeout(pendingTimer);
pendingTimer = null;
}
};
const hide = () => {
clearPending();
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;
}
// 同一目标已在显示:直接复位位置,不重新计时
if (activeEl.current === host && pendingTimer === null) {
placeTip(host, text);
return;
}
// data-tip-delay="毫秒":停留一会再显示,期间移出即取消
const delay = Number(host.getAttribute("data-tip-delay") || 0);
if (!Number.isFinite(delay) || delay <= 0) {
clearPending();
activeEl.current = host;
placeTip(host, text);
return;
}
clearPending();
pendingTimer = window.setTimeout(() => {
pendingTimer = null;
const focused = host.contains(document.activeElement);
if (!host.matches(":hover") && !focused) return;
activeEl.current = host;
placeTip(host, text);
}, delay);
};
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 () => {
clearPending();
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
);
}