123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616 |
- 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<HTMLDivElement, MouseEvent>) => void;
- onScroll: () => void;
- onWheel: (deltaX: number) => void;
- scrollBarAreaRef: React.RefObject<HTMLDivElement>;
- storeSpanBar: (spanBar: SpanBar) => void;
- updateScrollState: () => void;
- virtualScrollbarRef: React.RefObject<HTMLDivElement>;
- };
- const ScrollbarManagerContext = createContext<ScrollbarManagerChildrenProps>({
- generateContentSpanBarRef: () => () => undefined,
- virtualScrollbarRef: createRef<HTMLDivElement>(),
- scrollBarAreaRef: createRef<HTMLDivElement>(),
- onDragStart: () => {},
- onScroll: () => {},
- onWheel: () => {},
- updateScrollState: () => {},
- markSpanOutOfView: () => {},
- markSpanInView: () => {},
- storeSpanBar: () => {},
- });
- const selectRefs = (
- refs: Set<HTMLDivElement> | React.RefObject<HTMLDivElement>,
- 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<HTMLDivElement>;
- dragProps?: DragManagerChildrenProps;
- };
- type State = {
- maxContentWidth: number | undefined;
- };
- export class Provider extends Component<Props, State> {
- 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<HTMLDivElement> = new Set();
- virtualScrollbar: React.RefObject<HTMLDivElement> = createRef<HTMLDivElement>();
- scrollBarArea: React.RefObject<HTMLDivElement> = createRef<HTMLDivElement>();
- 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<HTMLDivElement, 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 (
- <ScrollbarManagerContext.Provider value={childrenProps}>
- {this.props.children}
- </ScrollbarManagerContext.Provider>
- );
- }
- }
- export const Consumer = ScrollbarManagerContext.Consumer;
- export const withScrollbarManager = <P extends ScrollbarManagerChildrenProps>(
- WrappedComponent: React.ComponentType<P>
- ) =>
- class extends Component<
- Omit<P, keyof ScrollbarManagerChildrenProps> & Partial<ScrollbarManagerChildrenProps>
- > {
- static displayName = `withScrollbarManager(${getDisplayName(WrappedComponent)})`;
- render() {
- return (
- <ScrollbarManagerContext.Consumer>
- {context => {
- const props = {
- ...this.props,
- ...context,
- } as P;
- return <WrappedComponent {...props} />;
- }}
- </ScrollbarManagerContext.Consumer>
- );
- }
- };
|