import {Component, createContext, createRef} from 'react'; import throttle from 'lodash/throttle'; import { clamp, rectOfContent, toPercent, } from 'sentry/components/performance/waterfall/utils'; import getDisplayName from 'sentry/utils/getDisplayName'; import {setBodyUserSelect, UserSelectValues} from 'sentry/utils/userselect'; import {DragManagerChildrenProps} from './dragManager'; import SpanBar from './spanBar'; import {SpansInViewMap, spanTargetHash} from './utils'; export type ScrollbarManagerChildrenProps = { generateContentSpanBarRef: () => (instance: HTMLDivElement | null) => void; markSpanInView: (spanId: string, treeDepth: number) => void; markSpanOutOfView: (spanId: string) => void; onDragStart: (event: React.MouseEvent) => void; onScroll: () => void; onWheel: (deltaX: number) => void; scrollBarAreaRef: React.RefObject; storeSpanBar: (spanBar: SpanBar) => void; updateScrollState: () => void; virtualScrollbarRef: React.RefObject; }; const ScrollbarManagerContext = createContext({ generateContentSpanBarRef: () => () => undefined, virtualScrollbarRef: createRef(), scrollBarAreaRef: createRef(), onDragStart: () => {}, onScroll: () => {}, onWheel: () => {}, updateScrollState: () => {}, markSpanOutOfView: () => {}, markSpanInView: () => {}, storeSpanBar: () => {}, }); const selectRefs = ( refs: Set | React.RefObject, transform: (element: HTMLDivElement) => void ) => { if (!(refs instanceof Set)) { if (refs.current) { transform(refs.current); } return; } refs.forEach(element => { if (document.body.contains(element)) { transform(element); } }); }; // simple linear interpolation between start and end such that needle is between [0, 1] const lerp = (start: number, end: number, needle: number) => { return start + needle * (end - start); }; type Props = { children: React.ReactNode; dividerPosition: number; // this is the DOM element where the drag events occur. it's also the reference point // for calculating the relative mouse x coordinate. interactiveLayerRef: React.RefObject; dragProps?: DragManagerChildrenProps; }; type State = { maxContentWidth: number | undefined; }; export class Provider extends Component { state: State = { maxContentWidth: undefined, }; componentDidMount() { // React will guarantee that refs are set before componentDidMount() is called; // but only for DOM elements that actually got rendered this.initializeScrollState(); const anchoredSpanHash = window.location.hash.split('#')[1]; // If the user is opening the span tree with an anchor link provided, we need to continuously reconnect the observers. // This is because we need to wait for the window to scroll to the anchored span first, or there will be inconsistencies in // the spans that are actually considered in the view. The IntersectionObserver API cannot keep up with the speed // at which the window scrolls to the anchored span, and will be unable to register the spans that went out of the view. // We stop reconnecting the observers once we've confirmed that the anchored span is in the view (or after a timeout). if (anchoredSpanHash) { // We cannot assume the root is in view to start off, if there is an anchored span this.spansInView.isRootSpanInView = false; const anchoredSpanId = window.location.hash.replace(spanTargetHash(''), ''); // Continuously check to see if the anchored span is in the view this.anchorCheckInterval = setInterval(() => { this.spanBars.forEach(spanBar => spanBar.connectObservers()); if (this.spansInView.has(anchoredSpanId)) { clearInterval(this.anchorCheckInterval!); this.anchorCheckInterval = null; } }, 50); // If the anchored span is never found in the view (malformed ID), cancel the interval setTimeout(() => { if (this.anchorCheckInterval) { clearInterval(this.anchorCheckInterval); this.anchorCheckInterval = null; } }, 1000); return; } this.spanBars.forEach(spanBar => spanBar.connectObservers()); } componentDidUpdate(prevProps: Props) { // Re-initialize the scroll state whenever: // - the window was selected via the minimap or, // - the divider was re-positioned. const dividerPositionChanged = this.props.dividerPosition !== prevProps.dividerPosition; const viewWindowChanged = prevProps.dragProps && this.props.dragProps && (prevProps.dragProps.viewWindowStart !== this.props.dragProps.viewWindowStart || prevProps.dragProps.viewWindowEnd !== this.props.dragProps.viewWindowEnd); if (dividerPositionChanged || viewWindowChanged) { this.initializeScrollState(); } } componentWillUnmount() { this.cleanUpListeners(); if (this.anchorCheckInterval) { clearInterval(this.anchorCheckInterval); } } anchorCheckInterval: NodeJS.Timer | null = null; contentSpanBar: Set = new Set(); virtualScrollbar: React.RefObject = createRef(); scrollBarArea: React.RefObject = createRef(); isDragging: boolean = false; isWheeling: boolean = false; wheelTimeout: NodeJS.Timeout | null = null; animationTimeout: NodeJS.Timeout | null = null; previousUserSelect: UserSelectValues | null = null; spansInView: SpansInViewMap = new SpansInViewMap(); spanBars: SpanBar[] = []; getReferenceSpanBar() { for (const currentSpanBar of this.contentSpanBar) { const isHidden = currentSpanBar.offsetParent === null; if (!document.body.contains(currentSpanBar) || isHidden) { continue; } return currentSpanBar; } return undefined; } initializeScrollState = () => { if (this.contentSpanBar.size === 0 || !this.hasInteractiveLayer()) { return; } // reset all span bar content containers to their natural widths selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { spanBarDOM.style.removeProperty('width'); spanBarDOM.style.removeProperty('max-width'); spanBarDOM.style.removeProperty('overflow'); spanBarDOM.style.removeProperty('transform'); }); // Find the maximum content width. We set each content spanbar to be this maximum width, // such that all content spanbar widths are uniform. const maxContentWidth = Array.from(this.contentSpanBar).reduce( (currentMaxWidth, currentSpanBar) => { const isHidden = currentSpanBar.offsetParent === null; if (!document.body.contains(currentSpanBar) || isHidden) { return currentMaxWidth; } const maybeMaxWidth = currentSpanBar.scrollWidth; if (maybeMaxWidth > currentMaxWidth) { return maybeMaxWidth; } return currentMaxWidth; }, 0 ); selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { spanBarDOM.style.width = `${maxContentWidth}px`; spanBarDOM.style.maxWidth = `${maxContentWidth}px`; spanBarDOM.style.overflow = 'hidden'; }); // set inner width of scrollbar area selectRefs(this.scrollBarArea, (scrollBarArea: HTMLDivElement) => { scrollBarArea.style.width = `${maxContentWidth}px`; scrollBarArea.style.maxWidth = `${maxContentWidth}px`; }); selectRefs( this.props.interactiveLayerRef, (interactiveLayerRefDOM: HTMLDivElement) => { interactiveLayerRefDOM.scrollLeft = 0; } ); const spanBarDOM = this.getReferenceSpanBar(); if (spanBarDOM) { this.syncVirtualScrollbar(spanBarDOM); } const left = this.spansInView.getScrollVal(); this.performScroll(left); }; syncVirtualScrollbar = (spanBar: HTMLDivElement) => { // sync the virtual scrollbar's width to the spanBar's width if (!this.virtualScrollbar.current || !this.hasInteractiveLayer()) { return; } const virtualScrollbarDOM = this.virtualScrollbar.current; const maxContentWidth = spanBar.getBoundingClientRect().width; if (maxContentWidth === undefined || maxContentWidth <= 0) { virtualScrollbarDOM.style.width = '0'; return; } const visibleWidth = this.props.interactiveLayerRef.current!.getBoundingClientRect().width; // This is the width of the content not visible. const maxScrollDistance = maxContentWidth - visibleWidth; const virtualScrollbarWidth = visibleWidth / (visibleWidth + maxScrollDistance); if (virtualScrollbarWidth >= 1) { virtualScrollbarDOM.style.width = '0'; return; } virtualScrollbarDOM.style.width = `max(50px, ${toPercent(virtualScrollbarWidth)})`; virtualScrollbarDOM.style.removeProperty('transform'); }; generateContentSpanBarRef = () => { let previousInstance: HTMLDivElement | null = null; const addContentSpanBarRef = (instance: HTMLDivElement | null) => { if (previousInstance) { this.contentSpanBar.delete(previousInstance); previousInstance = null; } if (instance) { this.contentSpanBar.add(instance); previousInstance = instance; } }; return addContentSpanBarRef; }; hasInteractiveLayer = (): boolean => !!this.props.interactiveLayerRef.current; initialMouseClickX: number | undefined = undefined; performScroll = (scrollLeft: number, isAnimated?: boolean) => { const {interactiveLayerRef} = this.props; if (!interactiveLayerRef.current) { return; } if (isAnimated) { this.startAnimation(); } const interactiveLayerRefDOM = interactiveLayerRef.current; const interactiveLayerRect = interactiveLayerRefDOM.getBoundingClientRect(); interactiveLayerRefDOM.scrollLeft = scrollLeft; // Update scroll position of the virtual scroll bar selectRefs(this.scrollBarArea, (scrollBarAreaDOM: HTMLDivElement) => { selectRefs(this.virtualScrollbar, (virtualScrollbarDOM: HTMLDivElement) => { const scrollBarAreaRect = scrollBarAreaDOM.getBoundingClientRect(); const virtualScrollbarPosition = scrollLeft / scrollBarAreaRect.width; const virtualScrollBarRect = rectOfContent(virtualScrollbarDOM); const maxVirtualScrollableArea = 1 - virtualScrollBarRect.width / interactiveLayerRect.width; const virtualLeft = clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) * interactiveLayerRect.width; virtualScrollbarDOM.style.transform = `translateX(${virtualLeft}px)`; virtualScrollbarDOM.style.transformOrigin = 'left'; }); }); // Update scroll positions of all the span bars selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { const left = -scrollLeft; spanBarDOM.style.transform = `translateX(${left}px)`; spanBarDOM.style.transformOrigin = 'left'; }); }; // Throttle the scroll function to prevent jankiness in the auto-adjust animations when scrolling fast throttledScroll = throttle(this.performScroll, 300, {trailing: true}); onWheel = (deltaX: number) => { if (this.isDragging || !this.hasInteractiveLayer()) { return; } this.disableAnimation(); // Setting this here is necessary, since updating the virtual scrollbar position will also trigger the onScroll function this.isWheeling = true; if (this.wheelTimeout) { clearTimeout(this.wheelTimeout); } this.wheelTimeout = setTimeout(() => { this.isWheeling = false; this.wheelTimeout = null; }, 200); const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!; const maxScrollLeft = interactiveLayerRefDOM.scrollWidth - interactiveLayerRefDOM.clientWidth; const scrollLeft = clamp( interactiveLayerRefDOM.scrollLeft + deltaX, 0, maxScrollLeft ); this.performScroll(scrollLeft); }; onScroll = () => { if (this.isDragging || this.isWheeling || !this.hasInteractiveLayer()) { return; } const interactiveLayerRefDOM = this.props.interactiveLayerRef.current!; const scrollLeft = interactiveLayerRefDOM.scrollLeft; this.performScroll(scrollLeft); }; onDragStart = (event: React.MouseEvent) => { if ( this.isDragging || event.type !== 'mousedown' || !this.hasInteractiveLayer() || !this.virtualScrollbar.current ) { return; } event.stopPropagation(); const virtualScrollbarRect = rectOfContent(this.virtualScrollbar.current); // get initial x-coordinate of the mouse click on the virtual scrollbar this.initialMouseClickX = Math.abs(event.clientX - virtualScrollbarRect.x); // prevent the user from selecting things outside the minimap when dragging // the mouse cursor inside the minimap this.previousUserSelect = setBodyUserSelect({ userSelect: 'none', MozUserSelect: 'none', msUserSelect: 'none', webkitUserSelect: 'none', }); // attach event listeners so that the mouse cursor does not select text during a drag window.addEventListener('mousemove', this.onDragMove); window.addEventListener('mouseup', this.onDragEnd); // indicate drag has begun this.isDragging = true; selectRefs(this.virtualScrollbar, scrollbarDOM => { scrollbarDOM.classList.add('dragging'); document.body.style.setProperty('cursor', 'grabbing', 'important'); }); }; onDragMove = (event: MouseEvent) => { if ( !this.isDragging || event.type !== 'mousemove' || !this.hasInteractiveLayer() || !this.virtualScrollbar.current || this.initialMouseClickX === undefined ) { return; } const virtualScrollbarDOM = this.virtualScrollbar.current; const interactiveLayerRect = this.props.interactiveLayerRef.current!.getBoundingClientRect(); const virtualScrollBarRect = rectOfContent(virtualScrollbarDOM); // Mouse x-coordinate relative to the interactive layer's left side const localDragX = event.pageX - interactiveLayerRect.x; // The drag movement with respect to the interactive layer's width. const rawMouseX = (localDragX - this.initialMouseClickX) / interactiveLayerRect.width; const maxVirtualScrollableArea = 1 - virtualScrollBarRect.width / interactiveLayerRect.width; // clamp rawMouseX to be within [0, 1] const virtualScrollbarPosition = clamp(rawMouseX, 0, 1); const virtualLeft = clamp(virtualScrollbarPosition, 0, maxVirtualScrollableArea) * interactiveLayerRect.width; virtualScrollbarDOM.style.transform = `translate3d(${virtualLeft}px, 0, 0)`; virtualScrollbarDOM.style.transformOrigin = 'left'; const virtualScrollPercentage = clamp(rawMouseX / maxVirtualScrollableArea, 0, 1); // Update scroll positions of all the span bars selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { const maxScrollDistance = spanBarDOM.getBoundingClientRect().width - interactiveLayerRect.width; const left = -lerp(0, maxScrollDistance, virtualScrollPercentage); spanBarDOM.style.transform = `translate3d(${left}px, 0, 0)`; spanBarDOM.style.transformOrigin = 'left'; }); // Update the scroll position of the scroll bar area selectRefs( this.props.interactiveLayerRef, (interactiveLayerRefDOM: HTMLDivElement) => { selectRefs(this.scrollBarArea, (scrollBarAreaDOM: HTMLDivElement) => { const maxScrollDistance = scrollBarAreaDOM.getBoundingClientRect().width - interactiveLayerRect.width; const left = lerp(0, maxScrollDistance, virtualScrollPercentage); interactiveLayerRefDOM.scrollLeft = left; }); } ); }; onDragEnd = (event: MouseEvent) => { if (!this.isDragging || event.type !== 'mouseup' || !this.hasInteractiveLayer()) { return; } // remove listeners that were attached in onDragStart this.cleanUpListeners(); // restore body styles if (this.previousUserSelect) { setBodyUserSelect(this.previousUserSelect); this.previousUserSelect = null; } // indicate drag has ended this.isDragging = false; selectRefs(this.virtualScrollbar, scrollbarDOM => { scrollbarDOM.classList.remove('dragging'); document.body.style.removeProperty('cursor'); }); }; cleanUpListeners = () => { if (this.isDragging) { // we only remove listeners during a drag window.removeEventListener('mousemove', this.onDragMove); window.removeEventListener('mouseup', this.onDragEnd); } }; markSpanOutOfView = (spanId: string) => { if (!this.spansInView.removeSpan(spanId)) { return; } const left = this.spansInView.getScrollVal(); this.throttledScroll(left, true); }; markSpanInView = (spanId: string, treeDepth: number) => { if (!this.spansInView.addSpan(spanId, treeDepth)) { return; } const left = this.spansInView.getScrollVal(); this.throttledScroll(left, true); }; startAnimation() { selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { spanBarDOM.style.transition = 'transform 0.3s'; }); if (this.animationTimeout) { clearTimeout(this.animationTimeout); } // This timeout is set to trigger immediately after the animation ends, to disable the animation. // The animation needs to be cleared, otherwise manual horizontal scrolling will be animated this.animationTimeout = setTimeout(() => { selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { spanBarDOM.style.transition = ''; }); this.animationTimeout = null; }, 300); } disableAnimation() { selectRefs(this.contentSpanBar, (spanBarDOM: HTMLDivElement) => { spanBarDOM.style.transition = ''; }); } storeSpanBar = (spanBar: SpanBar) => { this.spanBars.push(spanBar); }; render() { const childrenProps: ScrollbarManagerChildrenProps = { generateContentSpanBarRef: this.generateContentSpanBarRef, onDragStart: this.onDragStart, onScroll: this.onScroll, onWheel: this.onWheel, virtualScrollbarRef: this.virtualScrollbar, scrollBarAreaRef: this.scrollBarArea, updateScrollState: this.initializeScrollState, markSpanOutOfView: this.markSpanOutOfView, markSpanInView: this.markSpanInView, storeSpanBar: this.storeSpanBar, }; return ( {this.props.children} ); } } export const Consumer = ScrollbarManagerContext.Consumer; export const withScrollbarManager =

( WrappedComponent: React.ComponentType

) => class extends Component< Omit & Partial > { static displayName = `withScrollbarManager(${getDisplayName(WrappedComponent)})`; render() { return ( {context => { const props = { ...this.props, ...context, } as P; return ; }} ); } };