timelineCursor.tsx 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, motion} from 'framer-motion';
  4. import {Overlay} from 'sentry/components/overlay';
  5. import {space} from 'sentry/styles/space';
  6. import testableTransition from 'sentry/utils/testableTransition';
  7. const TOOLTIP_OFFSET = 10;
  8. interface Options {
  9. /**
  10. * Function used to compute the text of the cursor tooltip. Recieves the %
  11. * value the cursor is within the container.
  12. */
  13. labelText: (percentPosition: number) => string;
  14. /**
  15. * May be set to false to disable rendering the timeline cursor
  16. */
  17. enabled?: boolean;
  18. }
  19. function useTimelineCursor<E extends HTMLElement>({enabled = true, labelText}: Options) {
  20. const rafIdRef = useRef<number | null>(null);
  21. const containerRef = useRef<E>(null);
  22. const labelRef = useRef<HTMLDivElement>(null);
  23. const [isVisible, setIsVisible] = useState(false);
  24. const handleMouseMove = useCallback(
  25. (e: MouseEvent) => {
  26. if (rafIdRef.current !== null) {
  27. window.cancelAnimationFrame(rafIdRef.current);
  28. }
  29. if (containerRef.current === null) {
  30. return;
  31. }
  32. const containerRect = containerRef.current.getBoundingClientRect();
  33. // Instead of using onMouseEnter / onMouseLeave we check if the mouse is
  34. // within the containerRect. This proves to be less glitchy as some
  35. // elements within the container may trigger an onMouseLeave even when
  36. // the mouse is still "inside" of the container
  37. const isInsideContainer =
  38. e.clientX > containerRect.left &&
  39. e.clientX < containerRect.right &&
  40. e.clientY > containerRect.top &&
  41. e.clientY < containerRect.bottom;
  42. if (isInsideContainer !== isVisible) {
  43. setIsVisible(isInsideContainer);
  44. }
  45. rafIdRef.current = window.requestAnimationFrame(() => {
  46. if (containerRef.current === null || labelRef.current === null) {
  47. return;
  48. }
  49. const offset = e.clientX - containerRect.left;
  50. const tooltipWidth = labelRef.current.offsetWidth;
  51. labelRef.current.innerText = labelText(offset / containerRect.width);
  52. containerRef.current.style.setProperty('--cursorOffset', `${offset}px`);
  53. containerRef.current.style.setProperty('--cursorMax', `${containerRect.width}px`);
  54. containerRef.current.style.setProperty('--cursorLabelWidth', `${tooltipWidth}px`);
  55. });
  56. },
  57. [isVisible, labelText]
  58. );
  59. useEffect(() => {
  60. if (enabled) {
  61. window.addEventListener('mousemove', handleMouseMove);
  62. } else {
  63. setIsVisible(false);
  64. }
  65. return () => window.removeEventListener('mousemove', handleMouseMove);
  66. }, [enabled, handleMouseMove]);
  67. const timelineCursor = (
  68. <AnimatePresence>
  69. {isVisible && (
  70. <Fragment>
  71. <Cursor role="presentation" />
  72. <CursorLabel ref={labelRef} animated placement="right" />
  73. </Fragment>
  74. )}
  75. </AnimatePresence>
  76. );
  77. return {cursorContainerRef: containerRef, timelineCursor};
  78. }
  79. const Cursor = styled(motion.div)`
  80. pointer-events: none;
  81. background: ${p => p.theme.translucentBorder};
  82. width: 2px;
  83. height: 100%;
  84. position: absolute;
  85. top: 0;
  86. left: clamp(0px, var(--cursorOffset), var(--cursorMax));
  87. transform: translateX(-2px);
  88. z-index: 3;
  89. `;
  90. Cursor.defaultProps = {
  91. initial: 'initial',
  92. animate: 'animate',
  93. exit: 'exit',
  94. transition: testableTransition({duration: 0.1}),
  95. variants: {
  96. initial: {opacity: 0},
  97. animate: {opacity: 1},
  98. exit: {opacity: 0},
  99. },
  100. };
  101. const CursorLabel = styled(Overlay)`
  102. font-variant-numeric: tabular-nums;
  103. width: max-content;
  104. padding: ${space(0.75)} ${space(1)};
  105. color: ${p => p.theme.textColor};
  106. font-size: ${p => p.theme.fontSizeSmall};
  107. line-height: 1.2;
  108. position: absolute;
  109. top: 12px;
  110. left: clamp(
  111. 0px,
  112. calc(var(--cursorOffset) + ${TOOLTIP_OFFSET}px),
  113. calc(var(--cursorMax) - var(--cursorLabelWidth) - ${TOOLTIP_OFFSET}px)
  114. );
  115. `;
  116. export {useTimelineCursor};