scrollbarManager.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. import {Component, createContext, createRef} from 'react';
  2. import throttle from 'lodash/throttle';
  3. import {
  4. clamp,
  5. rectOfContent,
  6. toPercent,
  7. } from 'sentry/components/performance/waterfall/utils';
  8. import getDisplayName from 'sentry/utils/getDisplayName';
  9. import {setBodyUserSelect, UserSelectValues} from 'sentry/utils/userselect';
  10. import {DragManagerChildrenProps} from './dragManager';
  11. import SpanBar from './spanBar';
  12. import {SpansInViewMap, spanTargetHash} from './utils';
  13. export type ScrollbarManagerChildrenProps = {
  14. generateContentSpanBarRef: () => (instance: HTMLDivElement | null) => void;
  15. markSpanInView: (spanId: string, treeDepth: number) => void;
  16. markSpanOutOfView: (spanId: string) => void;
  17. onDragStart: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
  18. onScroll: () => void;
  19. onWheel: (deltaX: number) => void;
  20. scrollBarAreaRef: React.RefObject<HTMLDivElement>;
  21. storeSpanBar: (spanBar: SpanBar) => void;
  22. updateScrollState: () => void;
  23. virtualScrollbarRef: React.RefObject<HTMLDivElement>;
  24. };
  25. const ScrollbarManagerContext = createContext<ScrollbarManagerChildrenProps>({
  26. generateContentSpanBarRef: () => () => undefined,
  27. virtualScrollbarRef: createRef<HTMLDivElement>(),
  28. scrollBarAreaRef: createRef<HTMLDivElement>(),
  29. onDragStart: () => {},
  30. onScroll: () => {},
  31. onWheel: () => {},
  32. updateScrollState: () => {},
  33. markSpanOutOfView: () => {},
  34. markSpanInView: () => {},
  35. storeSpanBar: () => {},
  36. });
  37. const selectRefs = (
  38. refs: Set<HTMLDivElement> | React.RefObject<HTMLDivElement>,
  39. transform: (element: HTMLDivElement) => void
  40. ) => {
  41. if (!(refs instanceof Set)) {
  42. if (refs.current) {
  43. transform(refs.current);
  44. }
  45. return;
  46. }
  47. refs.forEach(element => {
  48. if (document.body.contains(element)) {
  49. transform(element);
  50. }
  51. });
  52. };
  53. // simple linear interpolation between start and end such that needle is between [0, 1]
  54. const lerp = (start: number, end: number, needle: number) => {
  55. return start + needle * (end - start);
  56. };
  57. type Props = {
  58. children: React.ReactNode;
  59. dividerPosition: number;
  60. // this is the DOM element where the drag events occur. it's also the reference point
  61. // for calculating the relative mouse x coordinate.
  62. interactiveLayerRef: React.RefObject<HTMLDivElement>;
  63. dragProps?: DragManagerChildrenProps;
  64. };
  65. type State = {
  66. maxContentWidth: number | undefined;
  67. };
  68. export class Provider extends Component<Props, State> {
  69. state: State = {
  70. maxContentWidth: undefined,
  71. };
  72. componentDidMount() {
  73. // React will guarantee that refs are set before componentDidMount() is called;
  74. // but only for DOM elements that actually got rendered
  75. this.initializeScrollState();
  76. const anchoredSpanHash = window.location.hash.split('#')[1];
  77. // If the user is opening the span tree with an anchor link provided, we need to continuously reconnect the observers.
  78. // This is because we need to wait for the window to scroll to the anchored span first, or there will be inconsistencies in
  79. // the spans that are actually considered in the view. The IntersectionObserver API cannot keep up with the speed
  80. // at which the window scrolls to the anchored span, and will be unable to register the spans that went out of the view.
  81. // We stop reconnecting the observers once we've confirmed that the anchored span is in the view (or after a timeout).
  82. if (anchoredSpanHash) {
  83. // We cannot assume the root is in view to start off, if there is an anchored span
  84. this.spansInView.isRootSpanInView = false;
  85. const anchoredSpanId = window.location.hash.replace(spanTargetHash(''), '');
  86. // Continuously check to see if the anchored span is in the view
  87. this.anchorCheckInterval = setInterval(() => {
  88. this.spanBars.forEach(spanBar => spanBar.connectObservers());
  89. if (this.spansInView.has(anchoredSpanId)) {
  90. clearInterval(this.anchorCheckInterval!);
  91. this.anchorCheckInterval = null;
  92. }
  93. }, 50);
  94. // If the anchored span is never found in the view (malformed ID), cancel the interval
  95. setTimeout(() => {
  96. if (this.anchorCheckInterval) {
  97. clearInterval(this.anchorCheckInterval);
  98. this.anchorCheckInterval = null;
  99. }
  100. }, 1000);
  101. return;
  102. }
  103. this.spanBars.forEach(spanBar => spanBar.connectObservers());
  104. }
  105. componentDidUpdate(prevProps: Props) {
  106. // Re-initialize the scroll state whenever:
  107. // - the window was selected via the minimap or,
  108. // - the divider was re-positioned.
  109. const dividerPositionChanged =
  110. this.props.dividerPosition !== prevProps.dividerPosition;
  111. const viewWindowChanged =
  112. prevProps.dragProps &&
  113. this.props.dragProps &&
  114. (prevProps.dragProps.viewWindowStart !== this.props.dragProps.viewWindowStart ||
  115. prevProps.dragProps.viewWindowEnd !== this.props.dragProps.viewWindowEnd);
  116. if (dividerPositionChanged || viewWindowChanged) {
  117. this.initializeScrollState();
  118. }
  119. }
  120. componentWillUnmount() {
  121. this.cleanUpListeners();
  122. if (this.anchorCheckInterval) {
  123. clearInterval(this.anchorCheckInterval);
  124. }
  125. }
  126. anchorCheckInterval: NodeJS.Timer | null = null;
  127. contentSpanBar: Set<HTMLDivElement> = new Set();
  128. virtualScrollbar: React.RefObject<HTMLDivElement> = createRef<HTMLDivElement>();
  129. scrollBarArea: React.RefObject<HTMLDivElement> = createRef<HTMLDivElement>();
  130. isDragging: boolean = false;
  131. isWheeling: boolean = false;
  132. wheelTimeout: NodeJS.Timeout | null = null;
  133. animationTimeout: NodeJS.Timeout | null = null;
  134. previousUserSelect: UserSelectValues | null = null;
  135. spansInView: SpansInViewMap = new SpansInViewMap();
  136. spanBars: SpanBar[] = [];
  137. getReferenceSpanBar() {
  138. for (const currentSpanBar of this.contentSpanBar) {
  139. const isHidden = currentSpanBar.offsetParent === null;
  140. if (!document.body.contains(currentSpanBar) || isHidden) {
  141. continue;
  142. }
  143. return currentSpanBar;
  144. }
  145. return undefined;
  146. }
  147. initializeScrollState = () => {
  148. if (this.contentSpanBar.size === 0 || !this.hasInteractiveLayer()) {
  149. return;
  150. }
  151. // reset all span bar content containers to their natural widths
  152. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  153. spanBarDOM.style.removeProperty('width');
  154. spanBarDOM.style.removeProperty('max-width');
  155. spanBarDOM.style.removeProperty('overflow');
  156. spanBarDOM.style.removeProperty('transform');
  157. });
  158. // Find the maximum content width. We set each content spanbar to be this maximum width,
  159. // such that all content spanbar widths are uniform.
  160. const maxContentWidth = Array.from(this.contentSpanBar).reduce(
  161. (currentMaxWidth, currentSpanBar) => {
  162. const isHidden = currentSpanBar.offsetParent === null;
  163. if (!document.body.contains(currentSpanBar) || isHidden) {
  164. return currentMaxWidth;
  165. }
  166. const maybeMaxWidth = currentSpanBar.scrollWidth;
  167. if (maybeMaxWidth > currentMaxWidth) {
  168. return maybeMaxWidth;
  169. }
  170. return currentMaxWidth;
  171. },
  172. 0
  173. );
  174. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  175. spanBarDOM.style.width = `${maxContentWidth}px`;
  176. spanBarDOM.style.maxWidth = `${maxContentWidth}px`;
  177. spanBarDOM.style.overflow = 'hidden';
  178. });
  179. // set inner width of scrollbar area
  180. selectRefs(this.scrollBarArea, (scrollBarArea: HTMLDivElement) => {
  181. scrollBarArea.style.width = `${maxContentWidth}px`;
  182. scrollBarArea.style.maxWidth = `${maxContentWidth}px`;
  183. });
  184. selectRefs(
  185. this.props.interactiveLayerRef,
  186. (interactiveLayerRefDOM: HTMLDivElement) => {
  187. interactiveLayerRefDOM.scrollLeft = 0;
  188. }
  189. );
  190. const spanBarDOM = this.getReferenceSpanBar();
  191. if (spanBarDOM) {
  192. this.syncVirtualScrollbar(spanBarDOM);
  193. }
  194. const left = this.spansInView.getScrollVal();
  195. this.performScroll(left);
  196. };
  197. syncVirtualScrollbar = (spanBar: HTMLDivElement) => {
  198. // sync the virtual scrollbar's width to the spanBar's width
  199. if (!this.virtualScrollbar.current || !this.hasInteractiveLayer()) {
  200. return;
  201. }
  202. const virtualScrollbarDOM = this.virtualScrollbar.current;
  203. const maxContentWidth = spanBar.getBoundingClientRect().width;
  204. if (maxContentWidth === undefined || maxContentWidth <= 0) {
  205. virtualScrollbarDOM.style.width = '0';
  206. return;
  207. }
  208. const visibleWidth =
  209. this.props.interactiveLayerRef.current!.getBoundingClientRect().width;
  210. // This is the width of the content not visible.
  211. const maxScrollDistance = maxContentWidth - visibleWidth;
  212. const virtualScrollbarWidth = visibleWidth / (visibleWidth + maxScrollDistance);
  213. if (virtualScrollbarWidth >= 1) {
  214. virtualScrollbarDOM.style.width = '0';
  215. return;
  216. }
  217. virtualScrollbarDOM.style.width = `max(50px, ${toPercent(virtualScrollbarWidth)})`;
  218. virtualScrollbarDOM.style.removeProperty('transform');
  219. };
  220. generateContentSpanBarRef = () => {
  221. let previousInstance: HTMLDivElement | null = null;
  222. const addContentSpanBarRef = (instance: HTMLDivElement | null) => {
  223. if (previousInstance) {
  224. this.contentSpanBar.delete(previousInstance);
  225. previousInstance = null;
  226. }
  227. if (instance) {
  228. this.contentSpanBar.add(instance);
  229. previousInstance = instance;
  230. }
  231. };
  232. return addContentSpanBarRef;
  233. };
  234. hasInteractiveLayer = (): boolean => !!this.props.interactiveLayerRef.current;
  235. initialMouseClickX: number | undefined = undefined;
  236. performScroll = (scrollLeft: number, isAnimated?: boolean) => {
  237. const {interactiveLayerRef} = this.props;
  238. if (!interactiveLayerRef.current) {
  239. return;
  240. }
  241. if (isAnimated) {
  242. this.startAnimation();
  243. }
  244. const interactiveLayerRefDOM = interactiveLayerRef.current;
  245. const interactiveLayerRect = interactiveLayerRefDOM.getBoundingClientRect();
  246. interactiveLayerRefDOM.scrollLeft = scrollLeft;
  247. // Update scroll position of the virtual scroll bar
  248. selectRefs(this.scrollBarArea, (scrollBarAreaDOM: HTMLDivElement) => {
  249. selectRefs(this.virtualScrollbar, (virtualScrollbarDOM: HTMLDivElement) => {
  250. const scrollBarAreaRect = scrollBarAreaDOM.getBoundingClientRect();
  251. const virtualScrollbarPosition = scrollLeft / scrollBarAreaRect.width;
  252. const virtualScrollBarRect = rectOfContent(virtualScrollbarDOM);
  253. const maxVirtualScrollableArea =
  254. 1 - virtualScrollBarRect.width / interactiveLayerRect.width;
  255. const virtualLeft =
  256. clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) *
  257. interactiveLayerRect.width;
  258. virtualScrollbarDOM.style.transform = `translateX(${virtualLeft}px)`;
  259. virtualScrollbarDOM.style.transformOrigin = 'left';
  260. });
  261. });
  262. // Update scroll positions of all the span bars
  263. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  264. const left = -scrollLeft;
  265. spanBarDOM.style.transform = `translateX(${left}px)`;
  266. spanBarDOM.style.transformOrigin = 'left';
  267. });
  268. };
  269. // Throttle the scroll function to prevent jankiness in the auto-adjust animations when scrolling fast
  270. throttledScroll = throttle(this.performScroll, 300, {trailing: true});
  271. onWheel = (deltaX: number) => {
  272. if (this.isDragging || !this.hasInteractiveLayer()) {
  273. return;
  274. }
  275. this.disableAnimation();
  276. // Setting this here is necessary, since updating the virtual scrollbar position will also trigger the onScroll function
  277. this.isWheeling = true;
  278. if (this.wheelTimeout) {
  279. clearTimeout(this.wheelTimeout);
  280. }
  281. this.wheelTimeout = setTimeout(() => {
  282. this.isWheeling = false;
  283. this.wheelTimeout = null;
  284. }, 200);
  285. const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
  286. const maxScrollLeft =
  287. interactiveLayerRefDOM.scrollWidth - interactiveLayerRefDOM.clientWidth;
  288. const scrollLeft = clamp(
  289. interactiveLayerRefDOM.scrollLeft + deltaX,
  290. 0,
  291. maxScrollLeft
  292. );
  293. this.performScroll(scrollLeft);
  294. };
  295. onScroll = () => {
  296. if (this.isDragging || this.isWheeling || !this.hasInteractiveLayer()) {
  297. return;
  298. }
  299. const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!;
  300. const scrollLeft = interactiveLayerRefDOM.scrollLeft;
  301. this.performScroll(scrollLeft);
  302. };
  303. onDragStart = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
  304. if (
  305. this.isDragging ||
  306. event.type !== 'mousedown' ||
  307. !this.hasInteractiveLayer() ||
  308. !this.virtualScrollbar.current
  309. ) {
  310. return;
  311. }
  312. event.stopPropagation();
  313. const virtualScrollbarRect = rectOfContent(this.virtualScrollbar.current);
  314. // get initial x-coordinate of the mouse click on the virtual scrollbar
  315. this.initialMouseClickX = Math.abs(event.clientX - virtualScrollbarRect.x);
  316. // prevent the user from selecting things outside the minimap when dragging
  317. // the mouse cursor inside the minimap
  318. this.previousUserSelect = setBodyUserSelect({
  319. userSelect: 'none',
  320. MozUserSelect: 'none',
  321. msUserSelect: 'none',
  322. webkitUserSelect: 'none',
  323. });
  324. // attach event listeners so that the mouse cursor does not select text during a drag
  325. window.addEventListener('mousemove', this.onDragMove);
  326. window.addEventListener('mouseup', this.onDragEnd);
  327. // indicate drag has begun
  328. this.isDragging = true;
  329. selectRefs(this.virtualScrollbar, scrollbarDOM => {
  330. scrollbarDOM.classList.add('dragging');
  331. document.body.style.setProperty('cursor', 'grabbing', 'important');
  332. });
  333. };
  334. onDragMove = (event: MouseEvent) => {
  335. if (
  336. !this.isDragging ||
  337. event.type !== 'mousemove' ||
  338. !this.hasInteractiveLayer() ||
  339. !this.virtualScrollbar.current ||
  340. this.initialMouseClickX === undefined
  341. ) {
  342. return;
  343. }
  344. const virtualScrollbarDOM = this.virtualScrollbar.current;
  345. const interactiveLayerRect =
  346. this.props.interactiveLayerRef.current!.getBoundingClientRect();
  347. const virtualScrollBarRect = rectOfContent(virtualScrollbarDOM);
  348. // Mouse x-coordinate relative to the interactive layer's left side
  349. const localDragX = event.pageX - interactiveLayerRect.x;
  350. // The drag movement with respect to the interactive layer's width.
  351. const rawMouseX = (localDragX - this.initialMouseClickX) / interactiveLayerRect.width;
  352. const maxVirtualScrollableArea =
  353. 1 - virtualScrollBarRect.width / interactiveLayerRect.width;
  354. // clamp rawMouseX to be within [0, 1]
  355. const virtualScrollbarPosition = clamp(rawMouseX, 0, 1);
  356. const virtualLeft =
  357. clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) *
  358. interactiveLayerRect.width;
  359. virtualScrollbarDOM.style.transform = `translate3d(${virtualLeft}px, 0, 0)`;
  360. virtualScrollbarDOM.style.transformOrigin = 'left';
  361. const virtualScrollPercentage = clamp(rawMouseX / maxVirtualScrollableArea, 0, 1);
  362. // Update scroll positions of all the span bars
  363. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  364. const maxScrollDistance =
  365. spanBarDOM.getBoundingClientRect().width - interactiveLayerRect.width;
  366. const left = -lerp(0, maxScrollDistance, virtualScrollPercentage);
  367. spanBarDOM.style.transform = `translate3d(${left}px, 0, 0)`;
  368. spanBarDOM.style.transformOrigin = 'left';
  369. });
  370. // Update the scroll position of the scroll bar area
  371. selectRefs(
  372. this.props.interactiveLayerRef,
  373. (interactiveLayerRefDOM: HTMLDivElement) => {
  374. selectRefs(this.scrollBarArea, (scrollBarAreaDOM: HTMLDivElement) => {
  375. const maxScrollDistance =
  376. scrollBarAreaDOM.getBoundingClientRect().width - interactiveLayerRect.width;
  377. const left = lerp(0, maxScrollDistance, virtualScrollPercentage);
  378. interactiveLayerRefDOM.scrollLeft = left;
  379. });
  380. }
  381. );
  382. };
  383. onDragEnd = (event: MouseEvent) => {
  384. if (!this.isDragging || event.type !== 'mouseup' || !this.hasInteractiveLayer()) {
  385. return;
  386. }
  387. // remove listeners that were attached in onDragStart
  388. this.cleanUpListeners();
  389. // restore body styles
  390. if (this.previousUserSelect) {
  391. setBodyUserSelect(this.previousUserSelect);
  392. this.previousUserSelect = null;
  393. }
  394. // indicate drag has ended
  395. this.isDragging = false;
  396. selectRefs(this.virtualScrollbar, scrollbarDOM => {
  397. scrollbarDOM.classList.remove('dragging');
  398. document.body.style.removeProperty('cursor');
  399. });
  400. };
  401. cleanUpListeners = () => {
  402. if (this.isDragging) {
  403. // we only remove listeners during a drag
  404. window.removeEventListener('mousemove', this.onDragMove);
  405. window.removeEventListener('mouseup', this.onDragEnd);
  406. }
  407. };
  408. markSpanOutOfView = (spanId: string) => {
  409. if (!this.spansInView.removeSpan(spanId)) {
  410. return;
  411. }
  412. const left = this.spansInView.getScrollVal();
  413. this.throttledScroll(left, true);
  414. };
  415. markSpanInView = (spanId: string, treeDepth: number) => {
  416. if (!this.spansInView.addSpan(spanId, treeDepth)) {
  417. return;
  418. }
  419. const left = this.spansInView.getScrollVal();
  420. this.throttledScroll(left, true);
  421. };
  422. startAnimation() {
  423. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  424. spanBarDOM.style.transition = 'transform 0.3s';
  425. });
  426. if (this.animationTimeout) {
  427. clearTimeout(this.animationTimeout);
  428. }
  429. // This timeout is set to trigger immediately after the animation ends, to disable the animation.
  430. // The animation needs to be cleared, otherwise manual horizontal scrolling will be animated
  431. this.animationTimeout = setTimeout(() => {
  432. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  433. spanBarDOM.style.transition = '';
  434. });
  435. this.animationTimeout = null;
  436. }, 300);
  437. }
  438. disableAnimation() {
  439. selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => {
  440. spanBarDOM.style.transition = '';
  441. });
  442. }
  443. storeSpanBar = (spanBar: SpanBar) => {
  444. this.spanBars.push(spanBar);
  445. };
  446. render() {
  447. const childrenProps: ScrollbarManagerChildrenProps = {
  448. generateContentSpanBarRef: this.generateContentSpanBarRef,
  449. onDragStart: this.onDragStart,
  450. onScroll: this.onScroll,
  451. onWheel: this.onWheel,
  452. virtualScrollbarRef: this.virtualScrollbar,
  453. scrollBarAreaRef: this.scrollBarArea,
  454. updateScrollState: this.initializeScrollState,
  455. markSpanOutOfView: this.markSpanOutOfView,
  456. markSpanInView: this.markSpanInView,
  457. storeSpanBar: this.storeSpanBar,
  458. };
  459. return (
  460. <ScrollbarManagerContext.Provider value={childrenProps}>
  461. {this.props.children}
  462. </ScrollbarManagerContext.Provider>
  463. );
  464. }
  465. }
  466. export const Consumer = ScrollbarManagerContext.Consumer;
  467. export const withScrollbarManager = <P extends ScrollbarManagerChildrenProps>(
  468. WrappedComponent: React.ComponentType<P>
  469. ) =>
  470. class extends Component<
  471. Omit<P, keyof ScrollbarManagerChildrenProps> & Partial<ScrollbarManagerChildrenProps>
  472. > {
  473. static displayName = `withScrollbarManager(${getDisplayName(WrappedComponent)})`;
  474. render() {
  475. return (
  476. <ScrollbarManagerContext.Consumer>
  477. {context => {
  478. const props = {
  479. ...this.props,
  480. ...context,
  481. } as P;
  482. return <WrappedComponent {...props} />;
  483. }}
  484. </ScrollbarManagerContext.Consumer>
  485. );
  486. }
  487. };