timelineZoom.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import {useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, motion} from 'framer-motion';
  4. import testableTransition from 'sentry/utils/testableTransition';
  5. /**
  6. * The minimum number in pixels which the selection should be considered valid
  7. * and will fire the onSelect handler.
  8. */
  9. const MIN_SIZE = 5;
  10. interface Options {
  11. /**
  12. * May be set to false to disable rendering the timeline cursor
  13. */
  14. enabled?: boolean;
  15. /**
  16. * Triggered when a selection has been made
  17. */
  18. onSelect?: (startX: number, endX: number) => void;
  19. }
  20. function useTimelineZoom<E extends HTMLElement>({enabled = true, onSelect}: Options) {
  21. const rafIdRef = useRef<number | null>(null);
  22. const containerRef = useRef<E>(null);
  23. const [isActive, setIsActive] = useState(false);
  24. const initialX = useRef(0);
  25. const startX = useRef(0);
  26. const endX = useRef(0);
  27. const handleMouseMove = useCallback(
  28. (e: MouseEvent) => {
  29. if (rafIdRef.current !== null) {
  30. window.cancelAnimationFrame(rafIdRef.current);
  31. }
  32. if (containerRef.current === null) {
  33. return;
  34. }
  35. if (!isActive) {
  36. return;
  37. }
  38. const containerRect = containerRef.current.getBoundingClientRect();
  39. rafIdRef.current = window.requestAnimationFrame(() => {
  40. if (containerRef.current === null) {
  41. return;
  42. }
  43. const offset = e.clientX - containerRect.left - initialX.current;
  44. const isLeft = offset < 0;
  45. const absoluteOffset = Math.abs(offset);
  46. const start = !isLeft
  47. ? initialX.current
  48. : Math.max(0, initialX.current - absoluteOffset);
  49. const width =
  50. e.clientX < containerRect.left
  51. ? initialX.current
  52. : Math.min(containerRect.width - start, absoluteOffset);
  53. containerRef.current.style.setProperty('--selectionStart', `${start}px`);
  54. containerRef.current.style.setProperty('--selectionWidth', `${width}px`);
  55. startX.current = start;
  56. endX.current = start + width;
  57. });
  58. },
  59. [isActive]
  60. );
  61. const handleMouseDown = useCallback((e: MouseEvent) => {
  62. if (containerRef.current === null) {
  63. return;
  64. }
  65. // Only primary click activates selection
  66. if (e.button !== 0) {
  67. return;
  68. }
  69. const containerRect = containerRef.current.getBoundingClientRect();
  70. const offset = e.clientX - containerRect.left;
  71. // Selection is only activated when inside the container
  72. const isInsideContainer =
  73. e.clientX > containerRect.left &&
  74. e.clientX < containerRect.right &&
  75. e.clientY > containerRect.top &&
  76. e.clientY < containerRect.bottom;
  77. if (!isInsideContainer) {
  78. return;
  79. }
  80. setIsActive(true);
  81. initialX.current = offset;
  82. document.body.style.setProperty('user-select', 'none');
  83. containerRef.current.style.setProperty('--selectionStart', `${offset}px`);
  84. containerRef.current.style.setProperty('--selectionWidth', '0px');
  85. }, []);
  86. const handleMouseUp = useCallback(() => {
  87. if (containerRef.current === null) {
  88. return;
  89. }
  90. if (!isActive) {
  91. return;
  92. }
  93. setIsActive(false);
  94. document.body.style.removeProperty('user-select');
  95. if (endX.current - startX.current >= MIN_SIZE) {
  96. onSelect?.(startX.current, endX.current);
  97. }
  98. startX.current = 0;
  99. endX.current = 0;
  100. }, [isActive, onSelect]);
  101. useEffect(() => {
  102. if (enabled) {
  103. window.addEventListener('mousemove', handleMouseMove);
  104. window.addEventListener('mousedown', handleMouseDown);
  105. window.addEventListener('mouseup', handleMouseUp);
  106. } else {
  107. setIsActive(false);
  108. }
  109. return () => {
  110. window.removeEventListener('mousemove', handleMouseMove);
  111. window.removeEventListener('mousedown', handleMouseDown);
  112. window.removeEventListener('mouseup', handleMouseUp);
  113. };
  114. }, [enabled, handleMouseMove, handleMouseDown, handleMouseUp]);
  115. const timelineSelector = (
  116. <AnimatePresence>{isActive && <Selection role="presentation" />}</AnimatePresence>
  117. );
  118. return {selectionContainerRef: containerRef, isActive, timelineSelector};
  119. }
  120. const Selection = styled(motion.div)`
  121. pointer-events: none;
  122. background: ${p => p.theme.translucentBorder};
  123. border-left: 1px solid ${p => p.theme.purple200};
  124. border-right: 1px solid ${p => p.theme.purple200};
  125. height: 100%;
  126. position: absolute;
  127. top: 0;
  128. left: var(--selectionStart);
  129. width: var(--selectionWidth);
  130. z-index: 2;
  131. `;
  132. Selection.defaultProps = {
  133. initial: 'initial',
  134. animate: 'animate',
  135. exit: 'exit',
  136. transition: testableTransition({duration: 0.2}),
  137. variants: {
  138. initial: {opacity: 0},
  139. animate: {opacity: 1},
  140. exit: {opacity: 0},
  141. },
  142. };
  143. export {useTimelineZoom};