fix(ui): 可展开或已在界面上的文案不再弹出气泡
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,41 +33,96 @@ function getVisibleText(root: Element): string {
|
||||
return norm(parts.join(" "));
|
||||
}
|
||||
|
||||
/** 是否存在省略截断(含 line-clamp) */
|
||||
function isVisuallyTruncated(root: Element): boolean {
|
||||
const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))];
|
||||
for (const el of nodes) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (el.clientWidth <= 0 && el.clientHeight <= 0) continue;
|
||||
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;
|
||||
/** 原地展开全文控件:文案须是「展开/收起/展开全部…」,不含「展开 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. 可见内容被省略截断,需要补全
|
||||
* 3. 可见内容被省略截断,且 data-tip 正是那段全文
|
||||
* 例外:目标已能原地展开全文时不弹出;提示词已全部出现在界面上时不弹出
|
||||
*/
|
||||
function shouldShowTip(host: Element, tip: string): boolean {
|
||||
if (isVisuallyTruncated(host)) return true;
|
||||
if (hasFullTextExpand(host)) return false;
|
||||
if (tipCompletesTruncation(host, tip)) return true;
|
||||
const visible = getVisibleText(host);
|
||||
if (!visible) return true;
|
||||
const nTip = norm(tip);
|
||||
const nVis = visible;
|
||||
if (nTip === nVis) return false;
|
||||
if (nVis.includes(nTip)) return false;
|
||||
if (visibleCoversTip(visible, tip)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user