fix(ui): 可展开或已在界面上的文案不再弹出气泡

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 03:43:22 +08:00
parent d93635c961
commit 1f4e72550a

View File

@@ -33,41 +33,96 @@ function getVisibleText(root: Element): string {
return norm(parts.join(" ")); return norm(parts.join(" "));
} }
/** 是否存在省略截断(含 line-clamp) */ /** 原地展开全文控件:文案须是「展开/收起/展开全部…」,不含「展开 N 条回复」 */
function isVisuallyTruncated(root: Element): boolean { function isFullTextExpandControl(el: Element): boolean {
const nodes: Element[] = [root, ...Array.from(root.querySelectorAll("*"))]; const t = (el.textContent || "").replace(/\s+/g, " ").trim();
for (const el of nodes) { if (!t) return false;
if (!(el instanceof HTMLElement)) continue; if (t === "展开" || t === "收起") return true;
if (el.clientWidth <= 0 && el.clientHeight <= 0) continue; if (/^(展开全部|查看全文|显示全部|显示全文)/.test(t)) return true;
const st = getComputedStyle(el); return false;
const ellipsis = st.textOverflow === "ellipsis" || el.classList.contains("truncate"); }
const nowrapHidden =
(st.overflowX === "hidden" || st.overflow === "hidden") && /**
(st.whiteSpace === "nowrap" || st.whiteSpace === "pre"); * 悬浮目标已能原地展开全文(有 data-expandable,或同级紧随展开按钮)。
const clampRaw = st.getPropertyValue("-webkit-line-clamp").trim(); * 此时气泡只是复述,不再弹出。
const clamped = Boolean(clampRaw) && clampRaw !== "none"; */
if (ellipsis || nowrapHidden) { function hasFullTextExpand(host: Element): boolean {
if (el.scrollWidth > el.clientWidth + 1) return true; if (host.closest("[data-expandable]")) return true;
} const scope = host.parentElement;
if (clamped && el.scrollHeight > el.clientHeight + 1) return true; 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; 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. 提示文案与可见内容不一致(且提示未完整出现在可见文字中) * 1. 提示文案与可见内容不一致(且提示未完整出现在可见文字中)
* 2. 停留目标无可见文字(图标等) * 2. 停留目标无可见文字(图标等)
* 3. 可见内容被省略截断,需要补全 * 3. 可见内容被省略截断,且 data-tip 正是那段全文
* 例外:目标已能原地展开全文时不弹出;提示词已全部出现在界面上时不弹出
*/ */
function shouldShowTip(host: Element, tip: string): boolean { 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); const visible = getVisibleText(host);
if (!visible) return true; if (!visible) return true;
const nTip = norm(tip); if (visibleCoversTip(visible, tip)) return false;
const nVis = visible;
if (nTip === nVis) return false;
if (nVis.includes(nTip)) return false;
return true; return true;
} }