// 在线名单徽章即时提示工具,负责动态徽章的悬浮气泡定位和显示。 const TOOLTIP_SELECTOR = ".user-badge-icon[data-instant-tooltip]"; /** * 绑定在线名单徽章的即时悬浮提示。 * Vite 模块脚本通常晚于 Blade 经典脚本执行,这里由模块加载后主动绑定,避免依赖 Blade 调用时序。 * * @param {HTMLElement|null} tooltip 提示气泡容器 * @param {Document|HTMLElement} root 事件委托根节点 * @returns {{hide: () => void, destroy: () => void}|null} */ export function bindInstantHoverTooltip( tooltip = document.getElementById("chat-hover-tooltip"), root = document, ) { if (!tooltip || tooltip.dataset.instantTooltipBound === "1") { return null; } tooltip.dataset.instantTooltipBound = "1"; let activeTrigger = null; /** * 从事件目标解析可显示提示的徽章节点。 * * @param {EventTarget|null} target 事件目标 * @returns {HTMLElement|null} */ const resolveTrigger = (target) => { if (!(target instanceof Element)) { return null; } return target.closest(TOOLTIP_SELECTOR); }; /** * 把提示气泡定位到徽章旁边,同时避免超出视口边缘。 * * @param {HTMLElement|null} trigger 当前悬浮徽章 * @returns {void} */ const position = (trigger) => { if (!trigger) { return; } const offset = 10; const rect = trigger.getBoundingClientRect(); const tooltipWidth = tooltip.offsetWidth; const tooltipHeight = tooltip.offsetHeight; const fitsRight = rect.right + offset + tooltipWidth <= window.innerWidth - 8; const side = fitsRight ? "right" : "left"; const nextLeft = fitsRight ? rect.right + offset : Math.max(8, rect.left - tooltipWidth - offset); const nextTop = Math.min( Math.max(8, rect.top + (rect.height - tooltipHeight) / 2), window.innerHeight - tooltipHeight - 8, ); tooltip.dataset.side = side; tooltip.style.left = `${nextLeft}px`; tooltip.style.top = `${nextTop}px`; }; /** * 显示当前徽章对应的提示文字。 * * @param {HTMLElement|null} trigger 当前悬浮徽章 * @returns {void} */ const show = (trigger) => { const tooltipText = trigger?.dataset.instantTooltip || ""; if (!trigger || !tooltipText) { return; } activeTrigger = trigger; tooltip.textContent = tooltipText; tooltip.style.display = "block"; position(trigger); }; /** * 隐藏提示气泡并清理方向状态。 * * @returns {void} */ const hide = () => { activeTrigger = null; tooltip.style.display = "none"; tooltip.textContent = ""; delete tooltip.dataset.side; }; const handleMouseOver = (event) => { const trigger = resolveTrigger(event.target); if (trigger) { show(trigger); } }; const handleMouseOut = (event) => { const trigger = resolveTrigger(event.target); if (!trigger || trigger !== activeTrigger) { return; } if (event.relatedTarget && trigger.contains(event.relatedTarget)) { return; } hide(); }; const reposition = () => { if (activeTrigger) { position(activeTrigger); } }; root.addEventListener("mouseover", handleMouseOver); root.addEventListener("mouseout", handleMouseOut); window.addEventListener("scroll", reposition, true); window.addEventListener("resize", reposition); return { hide, destroy() { root.removeEventListener("mouseover", handleMouseOver); root.removeEventListener("mouseout", handleMouseOut); window.removeEventListener("scroll", reposition, true); window.removeEventListener("resize", reposition); delete tooltip.dataset.instantTooltipBound; hide(); }, }; }