useMouseTooltip.js 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // useMouseTooltip.js
  2. let tooltipEl = null
  3. let currentTarget = null
  4. let observer = null
  5. let isInitialized = false
  6. let moveHandler = null
  7. let overHandler = null
  8. let outHandler = null
  9. let pointerDownHandler = null
  10. let rafId = null
  11. let showTimer = null
  12. let hideTimer = null
  13. let lastMouseX = 0
  14. let lastMouseY = 0
  15. export function useMouseTooltip() {
  16. const PADDING = 16
  17. const OFFSET = 15
  18. const SHOW_DELAY = 300
  19. const HIDE_DELAY = 80
  20. function createTooltip() {
  21. if (tooltipEl) return
  22. tooltipEl = document.createElement('div')
  23. tooltipEl.className = 'cursor-pointer-tooltip'
  24. tooltipEl.style.cssText = `
  25. position: fixed;
  26. z-index: 2147483647;
  27. pointer-events: none;
  28. left: 0;
  29. top: 0;
  30. background: rgba(var(--bs-body-bg-rgb),1);
  31. color: var(--bs-emphasis-color);
  32. border-radius: .5rem;
  33. box-shadow: 0 0 10px rgba(0,0,0,.1);
  34. font-size: .8125rem;
  35. line-height: 1.4;
  36. padding: .375rem .5rem;
  37. white-space: nowrap;
  38. opacity: 0;
  39. visibility: hidden;
  40. transform: scale(.85);
  41. transition:
  42. opacity .18s ease,
  43. transform .18s ease;
  44. will-change:
  45. opacity,
  46. transform,
  47. left,
  48. top;
  49. `
  50. document.body.appendChild(tooltipEl)
  51. }
  52. function getTooltipSize() {
  53. if (!tooltipEl) {
  54. return {
  55. width: 0,
  56. height: 0
  57. }
  58. }
  59. const rect = tooltipEl.getBoundingClientRect()
  60. return {
  61. width: rect.width,
  62. height: rect.height
  63. }
  64. }
  65. function updatePosition(clientX, clientY) {
  66. if (!tooltipEl) return
  67. const viewportWidth = window.innerWidth
  68. const viewportHeight = window.innerHeight
  69. const { width, height } = getTooltipSize()
  70. let x = clientX + OFFSET
  71. let y = clientY + OFFSET
  72. if (x + width > viewportWidth - PADDING) {
  73. x = clientX - width - OFFSET
  74. }
  75. if (y + height > viewportHeight - PADDING) {
  76. y = clientY - height - OFFSET
  77. }
  78. x = Math.max(PADDING, x)
  79. y = Math.max(PADDING, y)
  80. tooltipEl.style.left = `${x}px`
  81. tooltipEl.style.top = `${y}px`
  82. }
  83. function disconnectObserver() {
  84. if (observer) {
  85. observer.disconnect()
  86. observer = null
  87. }
  88. }
  89. function watchTooltip(target) {
  90. disconnectObserver()
  91. observer = new MutationObserver(() => {
  92. if (
  93. !tooltipEl ||
  94. currentTarget !== target
  95. ) {
  96. return
  97. }
  98. tooltipEl.textContent =
  99. target.dataset.tooltip || ''
  100. updatePosition(
  101. lastMouseX,
  102. lastMouseY
  103. )
  104. })
  105. observer.observe(target, {
  106. attributes: true,
  107. attributeFilter: ['data-tooltip']
  108. })
  109. }
  110. function forceHideTooltip() {
  111. disconnectObserver()
  112. if (showTimer) {
  113. clearTimeout(showTimer)
  114. showTimer = null
  115. }
  116. if (hideTimer) {
  117. clearTimeout(hideTimer)
  118. hideTimer = null
  119. }
  120. currentTarget = null
  121. if (!tooltipEl) return
  122. tooltipEl.style.opacity = '0'
  123. tooltipEl.style.visibility = 'hidden'
  124. tooltipEl.style.transform = 'scale(.85)'
  125. }
  126. function showTooltip(e, target) {
  127. const text =
  128. target.dataset.tooltip || ''
  129. if (!text) return
  130. createTooltip()
  131. if (hideTimer) {
  132. clearTimeout(hideTimer)
  133. hideTimer = null
  134. }
  135. if (showTimer) {
  136. clearTimeout(showTimer)
  137. showTimer = null
  138. }
  139. currentTarget = target
  140. lastMouseX = e.clientX
  141. lastMouseY = e.clientY
  142. tooltipEl.textContent = text
  143. watchTooltip(target)
  144. updatePosition(
  145. lastMouseX,
  146. lastMouseY
  147. )
  148. tooltipEl.style.opacity = '0'
  149. tooltipEl.style.visibility = 'hidden'
  150. tooltipEl.style.transform = 'scale(.85)'
  151. showTimer = setTimeout(() => {
  152. if (
  153. !currentTarget ||
  154. !document.contains(currentTarget)
  155. ) {
  156. forceHideTooltip()
  157. return
  158. }
  159. if (currentTarget !== target) {
  160. return
  161. }
  162. tooltipEl.style.visibility =
  163. 'visible'
  164. requestAnimationFrame(() => {
  165. tooltipEl.style.opacity = '1'
  166. tooltipEl.style.transform =
  167. 'scale(1)'
  168. })
  169. }, SHOW_DELAY)
  170. }
  171. function hideTooltip() {
  172. if (!tooltipEl) return
  173. disconnectObserver()
  174. if (showTimer) {
  175. clearTimeout(showTimer)
  176. showTimer = null
  177. }
  178. if (hideTimer) {
  179. clearTimeout(hideTimer)
  180. }
  181. hideTimer = setTimeout(() => {
  182. tooltipEl.style.opacity = '0'
  183. tooltipEl.style.transform =
  184. 'scale(.85)'
  185. currentTarget = null
  186. setTimeout(() => {
  187. if (
  188. !tooltipEl ||
  189. currentTarget
  190. ) {
  191. return
  192. }
  193. tooltipEl.style.visibility =
  194. 'hidden'
  195. }, 180)
  196. }, HIDE_DELAY)
  197. }
  198. function init() {
  199. if (isInitialized) return
  200. isInitialized = true
  201. moveHandler = (e) => {
  202. if (
  203. !currentTarget ||
  204. !tooltipEl
  205. ) {
  206. return
  207. }
  208. if (
  209. !document.contains(currentTarget)
  210. ) {
  211. forceHideTooltip()
  212. return
  213. }
  214. lastMouseX = e.clientX
  215. lastMouseY = e.clientY
  216. if (rafId) return
  217. rafId = requestAnimationFrame(() => {
  218. rafId = null
  219. if (
  220. !currentTarget ||
  221. !document.contains(currentTarget)
  222. ) {
  223. forceHideTooltip()
  224. return
  225. }
  226. updatePosition(
  227. lastMouseX,
  228. lastMouseY
  229. )
  230. })
  231. }
  232. overHandler = (e) => {
  233. const target =
  234. e.target.closest(
  235. '.cursor-pointer'
  236. )
  237. if (!target) return
  238. if (
  239. target === currentTarget
  240. ) {
  241. return
  242. }
  243. showTooltip(e, target)
  244. }
  245. outHandler = (e) => {
  246. const target =
  247. e.target.closest(
  248. '.cursor-pointer'
  249. )
  250. if (!target) return
  251. if (
  252. target !== currentTarget
  253. ) {
  254. return
  255. }
  256. if (
  257. e.relatedTarget &&
  258. target.contains(
  259. e.relatedTarget
  260. )
  261. ) {
  262. return
  263. }
  264. hideTooltip()
  265. }
  266. pointerDownHandler = () => {
  267. forceHideTooltip()
  268. }
  269. document.addEventListener(
  270. 'mouseover',
  271. overHandler,
  272. true
  273. )
  274. document.addEventListener(
  275. 'mouseout',
  276. outHandler,
  277. true
  278. )
  279. document.addEventListener(
  280. 'mousemove',
  281. moveHandler,
  282. true
  283. )
  284. document.addEventListener(
  285. 'pointerdown',
  286. pointerDownHandler,
  287. true
  288. )
  289. }
  290. function cleanup() {
  291. forceHideTooltip()
  292. if (rafId) {
  293. cancelAnimationFrame(rafId)
  294. rafId = null
  295. }
  296. if (moveHandler) {
  297. document.removeEventListener(
  298. 'mousemove',
  299. moveHandler,
  300. true
  301. )
  302. }
  303. if (overHandler) {
  304. document.removeEventListener(
  305. 'mouseover',
  306. overHandler,
  307. true
  308. )
  309. }
  310. if (outHandler) {
  311. document.removeEventListener(
  312. 'mouseout',
  313. outHandler,
  314. true
  315. )
  316. }
  317. if (pointerDownHandler) {
  318. document.removeEventListener(
  319. 'pointerdown',
  320. pointerDownHandler,
  321. true
  322. )
  323. }
  324. moveHandler = null
  325. overHandler = null
  326. outHandler = null
  327. pointerDownHandler = null
  328. currentTarget = null
  329. isInitialized = false
  330. }
  331. init()
  332. return cleanup
  333. }