useMouseTooltip.js 7.8 KB

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