useMouseTracking.tsx 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import {DOMAttributes, MouseEvent, RefObject, 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. elem: RefObject<T>;
  6. onPositionChange: (args: undefined | CallbackArgs) => void;
  7. } & DOMAttributes<T>;
  8. class AbortError extends Error {}
  9. /**
  10. * Replace `elem.getBoundingClientRect()` which is too laggy for onPositionChange
  11. */
  12. function getBoundingRect(
  13. elem: Element,
  14. {signal}: {signal: AbortSignal}
  15. ): Promise<DOMRectReadOnly> {
  16. return new Promise((resolve, reject) => {
  17. if (signal.aborted) {
  18. reject(new AbortError());
  19. }
  20. const abortHandler = () => {
  21. reject(new AbortError());
  22. };
  23. const observer = new IntersectionObserver(entries => {
  24. for (const entry of entries) {
  25. const bounds = entry.boundingClientRect;
  26. resolve(bounds);
  27. signal.removeEventListener('abort', abortHandler);
  28. }
  29. observer.disconnect();
  30. });
  31. signal.addEventListener('abort', abortHandler);
  32. observer.observe(elem);
  33. });
  34. }
  35. function useMouseTracking<T extends Element>({
  36. elem,
  37. onPositionChange,
  38. onMouseEnter,
  39. onMouseMove,
  40. onMouseLeave,
  41. ...rest
  42. }: Opts<T>) {
  43. const controller = useRef<AbortController>(new AbortController());
  44. const handlePositionChange = useCallback(
  45. async (e: MouseEvent<T>) => {
  46. if (!elem.current) {
  47. onPositionChange(undefined);
  48. return;
  49. }
  50. try {
  51. const rect = await getBoundingRect(elem.current, {
  52. signal: controller.current.signal,
  53. });
  54. onPositionChange({
  55. height: rect.height,
  56. left: Math.min(e.clientX - rect.left, rect.width),
  57. top: Math.min(e.clientY - rect.top, rect.height),
  58. width: rect.width,
  59. });
  60. } catch (err) {
  61. if (err instanceof AbortError) {
  62. // Ignore cancelled getBoundingRect calls
  63. return;
  64. }
  65. Sentry.captureException(err);
  66. }
  67. },
  68. [onPositionChange, controller, elem]
  69. );
  70. const handleOnMouseLeave = useCallback(() => {
  71. if (controller.current) {
  72. controller.current.abort();
  73. controller.current = new AbortController();
  74. }
  75. onPositionChange(undefined);
  76. }, [onPositionChange, controller]);
  77. return {
  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;