timelineCursor.tsx 4.4 KB

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