useMouseTracking.tsx 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import {DOMAttributes, MouseEvent, useCallback, useRef} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. type CallbackArgs = {height: number; left: number; top: number; width: number};
  4. type Opts<T extends Element> = {
  5. onPositionChange: (args: undefined | CallbackArgs) => void;
  6. } & DOMAttributes<T>;
  7. class AbortError extends Error {}
  8. /**
  9. * Replace `elem.getBoundingClientRect()` which is too laggy for onPositionChange
  10. */
  11. function getBoundingRect(
  12. elem: Element,
  13. {signal}: {signal: AbortSignal}
  14. ): Promise<DOMRectReadOnly> {
  15. return new Promise((resolve, reject) => {
  16. if (signal.aborted) {
  17. reject(new AbortError());
  18. }
  19. const abortHandler = () => {
  20. reject(new AbortError());
  21. };
  22. const observer = new IntersectionObserver(entries => {
  23. for (const entry of entries) {
  24. const bounds = entry.boundingClientRect;
  25. resolve(bounds);
  26. signal.removeEventListener('abort', abortHandler);
  27. }
  28. observer.disconnect();
  29. });
  30. signal.addEventListener('abort', abortHandler);
  31. observer.observe(elem);
  32. });
  33. }
  34. function useMouseTracking<T extends Element>({
  35. onPositionChange,
  36. onMouseEnter,
  37. onMouseMove,
  38. onMouseLeave,
  39. ...rest
  40. }: Opts<T>) {
  41. const elem = useRef<T>(null);
  42. const controller = useRef<AbortController>(new AbortController());
  43. const handlePositionChange = useCallback(
  44. async (e: MouseEvent<T>) => {
  45. if (!elem.current) {
  46. onPositionChange(undefined);
  47. return;
  48. }
  49. try {
  50. const rect = await getBoundingRect(elem.current, {
  51. signal: controller.current.signal,
  52. });
  53. onPositionChange({
  54. height: rect.height,
  55. left: Math.min(e.clientX - rect.left, rect.width),
  56. top: Math.min(e.clientY - rect.top, rect.height),
  57. width: rect.width,
  58. });
  59. } catch (err) {
  60. if (err instanceof AbortError) {
  61. // Ignore cancelled getBoundingRect calls
  62. return;
  63. }
  64. Sentry.captureException(err);
  65. }
  66. },
  67. [onPositionChange, controller]
  68. );
  69. const handleOnMouseLeave = useCallback(() => {
  70. if (controller.current) {
  71. controller.current.abort();
  72. controller.current = new AbortController();
  73. }
  74. onPositionChange(undefined);
  75. }, [onPositionChange, controller]);
  76. return {
  77. ref: elem,
  78. ...rest,
  79. onMouseEnter: (e: MouseEvent<T>) => {
  80. handlePositionChange(e);
  81. onMouseEnter?.(e);
  82. },
  83. onMouseMove: (e: MouseEvent<T>) => {
  84. // prevent outside elements from firing, for example a tooltip
  85. if (!elem.current?.contains(e.target as Node)) {
  86. return;
  87. }
  88. handlePositionChange(e);
  89. onMouseMove?.(e);
  90. },
  91. onMouseLeave: (e: MouseEvent<T>) => {
  92. handleOnMouseLeave();
  93. onMouseLeave?.(e);
  94. },
  95. };
  96. }
  97. export default useMouseTracking;