timelineCursor.tsx 4.6 KB

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