// useMouseTooltip.js let tooltipEl = null let currentTarget = null let observer = null let isInitialized = false let moveHandler = null let overHandler = null let outHandler = null let pointerDownHandler = null let rafId = null let showTimer = null let hideTimer = null let lastMouseX = 0 let lastMouseY = 0 export function useMouseTooltip() { const PADDING = 16 const OFFSET = 15 const SHOW_DELAY = 300 const HIDE_DELAY = 80 function createTooltip() { if (tooltipEl) return tooltipEl = document.createElement('div') tooltipEl.className = 'cursor-pointer-tooltip' tooltipEl.style.cssText = ` position: fixed; z-index: 2147483647; pointer-events: none; left: 0; top: 0; background: rgba(var(--bs-body-bg-rgb),1); color: var(--bs-emphasis-color); border-radius: .5rem; box-shadow: 0 0 10px rgba(0,0,0,.1); font-size: .8125rem; line-height: 1.4; padding: .375rem .5rem; white-space: nowrap; opacity: 0; visibility: hidden; transform: scale(.85); transition: opacity .18s ease, transform .18s ease; will-change: opacity, transform, left, top; ` document.body.appendChild(tooltipEl) } function getTooltipSize() { if (!tooltipEl) { return { width: 0, height: 0 } } const rect = tooltipEl.getBoundingClientRect() return { width: rect.width, height: rect.height } } function updatePosition(clientX, clientY) { if (!tooltipEl) return const viewportWidth = window.innerWidth const viewportHeight = window.innerHeight const { width, height } = getTooltipSize() let x = clientX + OFFSET let y = clientY + OFFSET if (x + width > viewportWidth - PADDING) { x = clientX - width - OFFSET } if (y + height > viewportHeight - PADDING) { y = clientY - height - OFFSET } x = Math.max(PADDING, x) y = Math.max(PADDING, y) tooltipEl.style.left = `${x}px` tooltipEl.style.top = `${y}px` } function disconnectObserver() { if (observer) { observer.disconnect() observer = null } } function watchTooltip(target) { disconnectObserver() observer = new MutationObserver(() => { if ( !tooltipEl || currentTarget !== target ) { return } tooltipEl.textContent = target.dataset.tooltip || '' updatePosition( lastMouseX, lastMouseY ) }) observer.observe(target, { attributes: true, attributeFilter: ['data-tooltip'] }) } function forceHideTooltip() { disconnectObserver() if (showTimer) { clearTimeout(showTimer) showTimer = null } if (hideTimer) { clearTimeout(hideTimer) hideTimer = null } currentTarget = null if (!tooltipEl) return tooltipEl.style.opacity = '0' tooltipEl.style.visibility = 'hidden' tooltipEl.style.transform = 'scale(.85)' } function showTooltip(e, target) { const text = target.dataset.tooltip || '' if (!text) return createTooltip() if (hideTimer) { clearTimeout(hideTimer) hideTimer = null } if (showTimer) { clearTimeout(showTimer) showTimer = null } currentTarget = target lastMouseX = e.clientX lastMouseY = e.clientY tooltipEl.textContent = text watchTooltip(target) updatePosition( lastMouseX, lastMouseY ) tooltipEl.style.opacity = '0' tooltipEl.style.visibility = 'hidden' tooltipEl.style.transform = 'scale(.85)' showTimer = setTimeout(() => { if ( !currentTarget || !document.contains(currentTarget) ) { forceHideTooltip() return } if (currentTarget !== target) { return } tooltipEl.style.visibility = 'visible' requestAnimationFrame(() => { tooltipEl.style.opacity = '1' tooltipEl.style.transform = 'scale(1)' }) }, SHOW_DELAY) } function hideTooltip() { if (!tooltipEl) return disconnectObserver() if (showTimer) { clearTimeout(showTimer) showTimer = null } if (hideTimer) { clearTimeout(hideTimer) } hideTimer = setTimeout(() => { tooltipEl.style.opacity = '0' tooltipEl.style.transform = 'scale(.85)' currentTarget = null setTimeout(() => { if ( !tooltipEl || currentTarget ) { return } tooltipEl.style.visibility = 'hidden' }, 180) }, HIDE_DELAY) } function init() { if (isInitialized) return isInitialized = true moveHandler = (e) => { if ( !currentTarget || !tooltipEl ) { return } if ( !document.contains(currentTarget) ) { forceHideTooltip() return } lastMouseX = e.clientX lastMouseY = e.clientY if (rafId) return rafId = requestAnimationFrame(() => { rafId = null if ( !currentTarget || !document.contains(currentTarget) ) { forceHideTooltip() return } updatePosition( lastMouseX, lastMouseY ) }) } overHandler = (e) => { const target = e.target.closest( '.cursor-pointer' ) if (!target) return if ( target === currentTarget ) { return } showTooltip(e, target) } outHandler = (e) => { const target = e.target.closest( '.cursor-pointer' ) if (!target) return if ( target !== currentTarget ) { return } if ( e.relatedTarget && target.contains( e.relatedTarget ) ) { return } hideTooltip() } pointerDownHandler = () => { forceHideTooltip() } document.addEventListener( 'mouseover', overHandler, true ) document.addEventListener( 'mouseout', outHandler, true ) document.addEventListener( 'mousemove', moveHandler, true ) document.addEventListener( 'pointerdown', pointerDownHandler, true ) } function cleanup() { forceHideTooltip() if (rafId) { cancelAnimationFrame(rafId) rafId = null } if (moveHandler) { document.removeEventListener( 'mousemove', moveHandler, true ) } if (overHandler) { document.removeEventListener( 'mouseover', overHandler, true ) } if (outHandler) { document.removeEventListener( 'mouseout', outHandler, true ) } if (pointerDownHandler) { document.removeEventListener( 'pointerdown', pointerDownHandler, true ) } moveHandler = null overHandler = null outHandler = null pointerDownHandler = null currentTarget = null isInitialized = false } init() return cleanup }