新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
332 lines
11 KiB
TypeScript
332 lines
11 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;
|
||
|
||
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
|
||
);
|
||
}
|