From 1f4e72550a1d0677abeac3c01174ae2b716acbf3 Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 17 Sep 2026 03:43:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E5=8F=AF=E5=B1=95=E5=BC=80=E6=88=96?= =?UTF-8?q?=E5=B7=B2=E5=9C=A8=E7=95=8C=E9=9D=A2=E4=B8=8A=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=A1=88=E4=B8=8D=E5=86=8D=E5=BC=B9=E5=87=BA=E6=B0=94=E6=B3=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/components/TipHost.tsx | 101 ++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/frontend/components/TipHost.tsx b/frontend/components/TipHost.tsx index d424b4f..dbe186a 100644 --- a/frontend/components/TipHost.tsx +++ b/frontend/components/TipHost.tsx @@ -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; }