dividerHandlerManager.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import {Component, createContext, createRef} from 'react';
  2. import {
  3. clamp,
  4. rectOfContent,
  5. toPercent,
  6. } from 'sentry/components/performance/waterfall/utils';
  7. import {setBodyUserSelect, UserSelectValues} from 'sentry/utils/userselect';
  8. // divider handle is positioned at 50% width from the left-hand side
  9. const DEFAULT_DIVIDER_POSITION = 0.4;
  10. const selectRefs = (
  11. refs: Array<React.RefObject<HTMLDivElement>>,
  12. transform: (dividerDOM: HTMLDivElement) => void
  13. ) => {
  14. refs.forEach(ref => {
  15. if (ref.current) {
  16. transform(ref.current);
  17. }
  18. });
  19. };
  20. export type DividerHandlerManagerChildrenProps = {
  21. addDividerLineRef: () => React.RefObject<HTMLDivElement>;
  22. addGhostDividerLineRef: () => React.RefObject<HTMLDivElement>;
  23. dividerPosition: number;
  24. onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
  25. setHover: (nextHover: boolean) => void;
  26. };
  27. type StateType = {
  28. dividerPosition: number; // between 0 and 1
  29. };
  30. const DividerManagerContext = createContext<DividerHandlerManagerChildrenProps>({
  31. dividerPosition: DEFAULT_DIVIDER_POSITION,
  32. onDragStart: () => {},
  33. setHover: () => {},
  34. addDividerLineRef: () => createRef<HTMLDivElement>(),
  35. addGhostDividerLineRef: () => createRef<HTMLDivElement>(),
  36. });
  37. type PropType = {
  38. children: React.ReactNode;
  39. // this is the DOM element where the drag events occur. it's also the reference point
  40. // for calculating the relative mouse x coordinate.
  41. interactiveLayerRef: React.RefObject<HTMLDivElement>;
  42. };
  43. export class Provider extends Component<PropType, StateType> {
  44. state: StateType = {
  45. dividerPosition: DEFAULT_DIVIDER_POSITION,
  46. };
  47. componentWillUnmount() {
  48. this.cleanUpListeners();
  49. }
  50. previousUserSelect: UserSelectValues | null = null;
  51. dividerHandlePosition: number = DEFAULT_DIVIDER_POSITION;
  52. isDragging: boolean = false;
  53. dividerLineRefs: Array<React.RefObject<HTMLDivElement>> = [];
  54. ghostDividerLineRefs: Array<React.RefObject<HTMLDivElement>> = [];
  55. hasInteractiveLayer = (): boolean => !!this.props.interactiveLayerRef.current;
  56. addDividerLineRef = () => {
  57. const ref = createRef<HTMLDivElement>();
  58. this.dividerLineRefs.push(ref);
  59. return ref;
  60. };
  61. addGhostDividerLineRef = () => {
  62. const ref = createRef<HTMLDivElement>();
  63. this.ghostDividerLineRefs.push(ref);
  64. return ref;
  65. };
  66. setHover = (nextHover: boolean) => {
  67. if (this.isDragging) {
  68. return;
  69. }
  70. selectRefs(this.dividerLineRefs, dividerDOM => {
  71. if (nextHover) {
  72. dividerDOM.classList.add('hovering');
  73. return;
  74. }
  75. dividerDOM.classList.remove('hovering');
  76. });
  77. };
  78. onDragStart = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
  79. if (this.isDragging || event.type !== 'mousedown' || !this.hasInteractiveLayer()) {
  80. return;
  81. }
  82. event.stopPropagation();
  83. // prevent the user from selecting things outside the minimap when dragging
  84. // the mouse cursor inside the minimap
  85. this.previousUserSelect = setBodyUserSelect({
  86. userSelect: 'none',
  87. MozUserSelect: 'none',
  88. msUserSelect: 'none',
  89. webkitUserSelect: 'none',
  90. });
  91. // attach event listeners so that the mouse cursor does not select text during a drag
  92. window.addEventListener('mousemove', this.onDragMove);
  93. window.addEventListener('mouseup', this.onDragEnd);
  94. this.setHover(true);
  95. // indicate drag has begun
  96. this.isDragging = true;
  97. selectRefs(this.dividerLineRefs, (dividerDOM: HTMLDivElement) => {
  98. dividerDOM.style.backgroundColor = 'rgba(73,80,87,0.75)';
  99. dividerDOM.style.cursor = 'col-resize';
  100. });
  101. selectRefs(this.ghostDividerLineRefs, (dividerDOM: HTMLDivElement) => {
  102. dividerDOM.style.cursor = 'col-resize';
  103. const {parentNode} = dividerDOM;
  104. if (!parentNode) {
  105. return;
  106. }
  107. const container = parentNode as HTMLDivElement;
  108. container.style.display = 'block';
  109. });
  110. };
  111. onDragMove = (event: MouseEvent) => {
  112. if (!this.isDragging || event.type !== 'mousemove' || !this.hasInteractiveLayer()) {
  113. return;
  114. }
  115. const rect = rectOfContent(this.props.interactiveLayerRef.current!);
  116. // mouse x-coordinate relative to the interactive layer's left side
  117. const rawMouseX = (event.pageX - rect.x) / rect.width;
  118. const min = 0;
  119. const max = 1;
  120. // clamp rawMouseX to be within [0, 1]
  121. this.dividerHandlePosition = clamp(rawMouseX, min, max);
  122. const dividerHandlePositionString = toPercent(this.dividerHandlePosition);
  123. selectRefs(this.ghostDividerLineRefs, (dividerDOM: HTMLDivElement) => {
  124. const {parentNode} = dividerDOM;
  125. if (!parentNode) {
  126. return;
  127. }
  128. const container = parentNode as HTMLDivElement;
  129. container.style.width = `calc(${dividerHandlePositionString} + 0.5px)`;
  130. });
  131. };
  132. onDragEnd = (event: MouseEvent) => {
  133. if (!this.isDragging || event.type !== 'mouseup' || !this.hasInteractiveLayer()) {
  134. return;
  135. }
  136. // remove listeners that were attached in onDragStart
  137. this.cleanUpListeners();
  138. // restore body styles
  139. if (this.previousUserSelect) {
  140. setBodyUserSelect(this.previousUserSelect);
  141. this.previousUserSelect = null;
  142. }
  143. // indicate drag has ended
  144. this.isDragging = false;
  145. this.setHover(false);
  146. selectRefs(this.dividerLineRefs, (dividerDOM: HTMLDivElement) => {
  147. dividerDOM.style.backgroundColor = '';
  148. dividerDOM.style.cursor = '';
  149. });
  150. selectRefs(this.ghostDividerLineRefs, (dividerDOM: HTMLDivElement) => {
  151. dividerDOM.style.cursor = '';
  152. const {parentNode} = dividerDOM;
  153. if (!parentNode) {
  154. return;
  155. }
  156. const container = parentNode as HTMLDivElement;
  157. container.style.display = 'none';
  158. });
  159. this.setState({
  160. // commit dividerHandlePosition to be dividerPosition
  161. dividerPosition: this.dividerHandlePosition,
  162. });
  163. };
  164. cleanUpListeners = () => {
  165. if (this.isDragging) {
  166. // we only remove listeners during a drag
  167. window.removeEventListener('mousemove', this.onDragMove);
  168. window.removeEventListener('mouseup', this.onDragEnd);
  169. }
  170. };
  171. render() {
  172. const childrenProps = {
  173. dividerPosition: this.state.dividerPosition,
  174. setHover: this.setHover,
  175. onDragStart: this.onDragStart,
  176. addDividerLineRef: this.addDividerLineRef,
  177. addGhostDividerLineRef: this.addGhostDividerLineRef,
  178. };
  179. // NOTE: <DividerManagerContext.Provider /> will not re-render its children
  180. // - if the `value` prop changes, and
  181. // - if the `children` prop stays the same
  182. //
  183. // Thus, only <DividerManagerContext.Consumer /> components will re-render.
  184. // This is an optimization for when childrenProps changes, but this.props does not change.
  185. //
  186. // We prefer to minimize the amount of top-down prop drilling from this component
  187. // to the respective divider components.
  188. return (
  189. <DividerManagerContext.Provider value={childrenProps}>
  190. {this.props.children}
  191. </DividerManagerContext.Provider>
  192. );
  193. }
  194. }
  195. export const Consumer = DividerManagerContext.Consumer;