Files
jiang13-bbs/frontend/components/TipHost.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

332 lines
11 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。
*
* 移动端策略:仅在 (hover: hover) and (pointer: fine) 设备上响应真实鼠标
* 指针与键盘焦点。触屏 tap 既派发合成鼠标事件又会抢占焦点,若照常弹出,
* 会出现「一点击就弹、只能点别处关闭」的体验,因此触屏一律不触发。
*/
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 hoverMq = window.matchMedia("(hover: hover) and (pointer: fine)");
let canHover = hoverMq.matches;
// 只在键盘导航期间允许聚焦气泡(:focus-visible 语义),点按产生的焦点不弹
let keyboardActive = false;
// 触摸后的短暂窗口,兜底忽略浏览器派发的合成鼠标事件
let lastTouchAt = 0;
const TOUCH_GUARD_MS = 500;
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 onPointerOver = (e: PointerEvent) => {
if (!canHover || e.pointerType !== "mouse") return;
if (Date.now() - lastTouchAt < TOUCH_GUARD_MS) return;
onOver(e);
};
const onFocusIn = (e: FocusEvent) => {
if (!canHover || !keyboardActive) return;
onOver(e);
};
const onOut = (e: Event) => {
if (!(e instanceof PointerEvent) && !(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 onKeyDown = () => {
keyboardActive = true;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "keyboard") keyboardActive = false;
};
const onTouchStart = () => {
lastTouchAt = Date.now();
keyboardActive = false;
hide();
};
const onHoverMqChange = (e: MediaQueryListEvent) => {
canHover = e.matches;
if (!canHover) hide();
};
const onScrollOrResize = () => {
if (!activeEl.current) return;
tryShow(activeEl.current);
};
// pointerover 冒泡,便于 closest;仅响应真实鼠标指针,触屏/手写笔不弹
document.addEventListener("pointerover", onPointerOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onOut);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("touchstart", onTouchStart, true);
hoverMq.addEventListener("change", onHoverMqChange);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
clearPending();
document.removeEventListener("pointerover", onPointerOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onOut);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("touchstart", onTouchStart, true);
hoverMq.removeEventListener("change", onHoverMqChange);
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
);
}