diff --git a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js index 59939cd948692..2681eb7c876c1 100644 --- a/packages/react-devtools-scheduling-profiler/src/CanvasPage.js +++ b/packages/react-devtools-scheduling-profiler/src/CanvasPage.js @@ -30,14 +30,12 @@ import {copy} from 'clipboard-js'; import prettyMilliseconds from 'pretty-ms'; import { - BackgroundColorView, HorizontalPanAndZoomView, ResizableView, + VerticalScrollOverflowView, Surface, VerticalScrollView, View, - createComposedLayout, - lastViewTakesUpRemainingSpaceLayout, useCanvasInteraction, verticallyStackedLayout, zeroPoint, @@ -325,10 +323,9 @@ function AutoSizedCanvas({ const rootView = new View( surface, defaultFrame, - createComposedLayout( - verticallyStackedLayout, - lastViewTakesUpRemainingSpaceLayout, - ), + verticallyStackedLayout, + defaultFrame, + COLORS.BACKGROUND, ); rootView.addSubview(axisMarkersViewWrapper); if (userTimingMarksViewWrapper !== null) { @@ -345,10 +342,14 @@ function AutoSizedCanvas({ } rootView.addSubview(flamechartViewWrapper); - // If subviews are less than the available height, fill remaining height with a solid color. - rootView.addSubview(new BackgroundColorView(surface, defaultFrame)); + const verticalScrollOverflowView = new VerticalScrollOverflowView( + surface, + defaultFrame, + rootView, + viewState, + ); - surfaceRef.current.rootView = rootView; + surfaceRef.current.rootView = verticalScrollOverflowView; }, [data]); useLayoutEffect(() => { @@ -401,6 +402,16 @@ function AutoSizedCanvas({ const surface = surfaceRef.current; surface.handleInteraction(interaction); + // Flush any display work that got queued up as part of the previous interaction. + // Typically there should be no work, but certain interactions may need a second pass. + // For example, the ResizableView may collapse/expand its contents, + // which requires a second layout pass for an ancestor VerticalScrollOverflowView. + // + // TODO It would be nice to remove this call for performance reasons. + // To do that, we'll need to address the UX bug with VerticalScrollOverflowView. + // For more info see: https://github.com/facebook/react/pull/22005#issuecomment-896953399 + surface.displayIfNeeded(); + canvas.style.cursor = surface.getCurrentCursor() || 'default'; // Defer drawing to canvas until React's commit phase, to avoid drawing diff --git a/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js index 5adb7f89b7701..a356ad96885e9 100644 --- a/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js +++ b/packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js @@ -241,9 +241,11 @@ function processTimelineEvent( // Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant if ( - type.startsWith('blur') || - type.startsWith('click') || + type === 'blur' || + type === 'click' || + type === 'input' || type.startsWith('focus') || + type.startsWith('key') || type.startsWith('mouse') || type.startsWith('pointer') ) { diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollOverflowView.js b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollOverflowView.js deleted file mode 100644 index 7ddf6c1842691..0000000000000 --- a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollOverflowView.js +++ /dev/null @@ -1,3 +0,0 @@ -// TODO Vertically stack views (via verticallyStackedLayout). -// If stacked views are taller than the available height, a vertical scrollbar will be shown on the side, -// and width will be adjusted to subtract the width of the scrollbar. diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js index 3f2f775912166..a78e88425faa2 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js @@ -34,10 +34,16 @@ const CARET_MARGIN = 3; const CARET_WIDTH = 5; const CARET_HEIGHT = 3; +type OnChangeCallback = ( + scrollState: ScrollState, + containerLength: number, +) => void; + export class VerticalScrollView extends View { _contentView: View; _isPanning: boolean; _mutableViewStateKey: string; + _onChangeCallback: OnChangeCallback | null; _scrollState: ScrollState; _viewState: ViewState; @@ -53,6 +59,7 @@ export class VerticalScrollView extends View { this._contentView = contentView; this._isPanning = false; this._mutableViewStateKey = label + ':VerticalScrollView'; + this._onChangeCallback = null; this._scrollState = { offset: 0, length: 0, @@ -146,26 +153,45 @@ export class VerticalScrollView extends View { super.layoutSubviews(); } - handleInteraction(interaction: Interaction) { + handleInteraction(interaction: Interaction): ?boolean { switch (interaction.type) { case 'mousedown': - this._handleMouseDown(interaction); - break; + return this._handleMouseDown(interaction); case 'mousemove': - this._handleMouseMove(interaction); - break; + return this._handleMouseMove(interaction); case 'mouseup': - this._handleMouseUp(interaction); - break; + return this._handleMouseUp(interaction); case 'wheel-shift': - this._handleWheelShift(interaction); - break; + return this._handleWheelShift(interaction); } } + onChange(callback: OnChangeCallback) { + this._onChangeCallback = callback; + } + + scrollBy(deltaY: number): boolean { + const newState = translateState({ + state: this._scrollState, + delta: -deltaY, + containerLength: this.frame.size.height, + }); + + // If the state is updated by this wheel scroll, + // return true to prevent the interaction from bubbling. + // For instance, this prevents the outermost container from also scrolling. + return this._setScrollState(newState); + } + _handleMouseDown(interaction: MouseDownInteraction) { if (rectContainsPoint(interaction.payload.location, this.frame)) { - this._isPanning = true; + const frameHeight = this.frame.size.height; + const contentHeight = this._contentView.desiredSize().height; + // Don't claim drag operations if the content is not tall enough to be scrollable. + // This would block any outer scroll views from working. + if (frameHeight < contentHeight) { + this._isPanning = true; + } } } @@ -179,6 +205,7 @@ export class VerticalScrollView extends View { containerLength: this.frame.size.height, }); this._setScrollState(newState); + return true; } _handleMouseUp(interaction: MouseUpInteraction) { @@ -187,31 +214,27 @@ export class VerticalScrollView extends View { } } - _handleWheelShift(interaction: WheelWithShiftInteraction) { + _handleWheelShift(interaction: WheelWithShiftInteraction): boolean { const { location, delta: {deltaX, deltaY}, } = interaction.payload; + if (!rectContainsPoint(location, this.frame)) { - return; // Not scrolling on view + return false; // Not scrolling on view } const absDeltaX = Math.abs(deltaX); const absDeltaY = Math.abs(deltaY); if (absDeltaX > absDeltaY) { - return; // Scrolling horizontally + return false; // Scrolling horizontally } if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) { - return; + return false; // Movement was too small and should be ignored. } - const newState = translateState({ - state: this._scrollState, - delta: -deltaY, - containerLength: this.frame.size.height, - }); - this._setScrollState(newState); + return this.scrollBy(deltaY); } _restoreMutableViewState() { @@ -231,10 +254,7 @@ export class VerticalScrollView extends View { this.setNeedsDisplay(); } - /** - * @private - */ - _setScrollState(proposedState: ScrollState) { + _setScrollState(proposedState: ScrollState): boolean { const height = this._contentView.frame.size.height; const clampedState = clampState({ state: proposedState, @@ -247,6 +267,14 @@ export class VerticalScrollView extends View { this._scrollState.length = clampedState.length; this.setNeedsDisplay(); + + if (this._onChangeCallback !== null) { + this._onChangeCallback(clampedState, this.frame.size.height); + } + + return true; } + + return false; } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/View.js b/packages/react-devtools-scheduling-profiler/src/view-base/View.js index 206721cdd39c3..b78af9d1e24ac 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/View.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/View.js @@ -29,6 +29,8 @@ import {noopLayout, viewsToLayout, collapseLayoutIntoViews} from './layouter'; * subclasses. */ export class View { + _backgroundColor: string | null; + currentCursor: string | null = null; surface: Surface; @@ -70,7 +72,9 @@ export class View { frame: Rect, layouter: Layouter = noopLayout, visibleArea: Rect = frame, + backgroundColor?: string | null = null, ) { + this._backgroundColor = backgroundColor || null; this.surface = surface; this.frame = frame; this._layouter = layouter; @@ -246,6 +250,20 @@ export class View { subview.displayIfNeeded(context, viewRefs); } }); + + const backgroundColor = this._backgroundColor; + if (backgroundColor !== null) { + const desiredSize = this.desiredSize(); + if (visibleArea.size.height > desiredSize.height) { + context.fillStyle = backgroundColor; + context.fillRect( + visibleArea.origin.x, + visibleArea.origin.y + desiredSize.height, + visibleArea.size.width, + visibleArea.size.height - desiredSize.height, + ); + } + } } /** @@ -255,7 +273,7 @@ export class View { * * NOTE: Do not call directly! Use `handleInteractionAndPropagateToSubviews` */ - handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {} + handleInteraction(interaction: Interaction, viewRefs: ViewRefs): ?boolean {} /** * Handle an `interaction` and propagates it to all of this view's @@ -270,19 +288,39 @@ export class View { handleInteractionAndPropagateToSubviews( interaction: Interaction, viewRefs: ViewRefs, - ) { + ): boolean { const {subviews, visibleArea} = this; if (visibleArea.size.height === 0) { - return; + return false; } - this.handleInteraction(interaction, viewRefs); - - subviews.forEach(subview => { + // Pass the interaction to subviews first, + // so they have the opportunity to claim it before it bubbles. + // + // Views are painted first to last, + // so they should process interactions last to first, + // so views in front (on top) can claim the interaction first. + for (let i = subviews.length - 1; i >= 0; i--) { + const subview = subviews[i]; if (rectIntersectsRect(visibleArea, subview.visibleArea)) { - subview.handleInteractionAndPropagateToSubviews(interaction, viewRefs); + const didSubviewHandle = + subview.handleInteractionAndPropagateToSubviews( + interaction, + viewRefs, + ) === true; + if (didSubviewHandle) { + return true; + } } - }); + } + + const didSelfHandle = + this.handleInteraction(interaction, viewRefs) === true; + if (didSelfHandle) { + return true; + } + + return false; } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/index.js b/packages/react-devtools-scheduling-profiler/src/view-base/index.js index b5455ce249f3c..3abaf63e21b2d 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/index.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/index.js @@ -9,10 +9,11 @@ export * from './BackgroundColorView'; export * from './HorizontalPanAndZoomView'; -export * from './ResizableView'; export * from './Surface'; export * from './VerticalScrollView'; export * from './View'; export * from './geometry'; export * from './layouter'; +export * from './resizable'; export * from './useCanvasInteraction'; +export * from './vertical-scroll-overflow'; diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js b/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js index 58adb026c2907..8f6341270e065 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/layouter.js @@ -206,50 +206,6 @@ export const atLeastContainerHeightLayout: Layouter = ( })); }; -/** - * Forces last view to take up the space below the second-last view. - * Intended to be used with a vertical stack layout. - */ -export const lastViewTakesUpRemainingSpaceLayout: Layouter = ( - layout, - containerFrame, -) => { - if (layout.length === 0) { - // Nothing to do - return layout; - } - - if (layout.length === 1) { - // No second-last view; the view should just take up the container height - return containerHeightLayout(layout, containerFrame); - } - - const layoutInfoToPassThrough = layout.slice(0, layout.length - 1); - const secondLastLayoutInfo = - layoutInfoToPassThrough[layoutInfoToPassThrough.length - 1]; - - const remainingHeight = - containerFrame.size.height - - secondLastLayoutInfo.frame.origin.y - - secondLastLayoutInfo.frame.size.height; - const height = Math.max(remainingHeight, 0); // Prevent negative heights - - const lastLayoutInfo = layout[layout.length - 1]; - return [ - ...layoutInfoToPassThrough, - { - ...lastLayoutInfo, - frame: { - origin: lastLayoutInfo.frame.origin, - size: { - width: lastLayoutInfo.frame.size.width, - height, - }, - }, - }, - ]; -}; - /** * Create a layouter that applies each layouter in `layouters` in sequence. */ diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizableView.js similarity index 56% rename from packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js rename to packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizableView.js index 1f0f890ce38ef..b56422f715a52 100644 --- a/packages/react-devtools-scheduling-profiler/src/view-base/ResizableView.js +++ b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizableView.js @@ -14,20 +14,17 @@ import type { MouseDownInteraction, MouseMoveInteraction, MouseUpInteraction, -} from './useCanvasInteraction'; -import type {Rect} from './geometry'; -import type {ViewRefs} from './Surface'; -import type {ViewState} from '../types'; - -import {BORDER_SIZE, COLORS} from '../content-views/constants'; -import {drawText} from '../content-views/utils/text'; -import {Surface} from './Surface'; -import {View} from './View'; -import {intersectionOfRects, rectContainsPoint} from './geometry'; -import {noopLayout} from './layouter'; -import {clamp} from './utils/clamp'; - -type ResizeBarState = 'normal' | 'hovered' | 'dragging'; +} from '../useCanvasInteraction'; +import type {Rect} from '../geometry'; +import type {ViewRefs} from '../Surface'; +import type {ViewState} from '../../types'; + +import {ResizeBarView} from './ResizeBarView'; +import {Surface} from '../Surface'; +import {View} from '../View'; +import {rectContainsPoint} from '../geometry'; +import {noopLayout} from '../layouter'; +import {clamp} from '../utils/clamp'; type ResizingState = $ReadOnly<{| /** Distance between top of resize bar and mouseY */ @@ -41,8 +38,6 @@ type LayoutState = {| barOffsetY: number, |}; -const RESIZE_BAR_DOT_RADIUS = 1; -const RESIZE_BAR_DOT_SPACING = 4; const RESIZE_BAR_HEIGHT = 8; const RESIZE_BAR_WITH_LABEL_HEIGHT = 16; @@ -51,175 +46,11 @@ const HIDDEN_RECT = { size: {width: 0, height: 0}, }; -class ResizeBar extends View { - _interactionState: ResizeBarState = 'normal'; - _label: string; - - showLabel: boolean = false; - - constructor(surface: Surface, frame: Rect, label: string) { - super(surface, frame, noopLayout); - - this._label = label; - } - - desiredSize() { - return this.showLabel - ? {height: RESIZE_BAR_WITH_LABEL_HEIGHT, width: 0} - : {height: RESIZE_BAR_HEIGHT, width: 0}; - } - - draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) { - const {visibleArea} = this; - const {x, y} = visibleArea.origin; - const {width, height} = visibleArea.size; - - const isActive = - this._interactionState === 'dragging' || - (this._interactionState === 'hovered' && viewRefs.activeView === null); - - context.fillStyle = isActive - ? COLORS.REACT_RESIZE_BAR_ACTIVE - : COLORS.REACT_RESIZE_BAR; - context.fillRect(x, y, width, height); - - context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER; - context.fillRect(x, y, width, BORDER_SIZE); - context.fillRect(x, y + height - BORDER_SIZE, width, BORDER_SIZE); - - const horizontalCenter = x + width / 2; - const verticalCenter = y + height / 2; - - if (this.showLabel) { - // When the resize view is collapsed entirely, - // rather than showing a resize bar– this view displays a label. - const labelRect: Rect = { - origin: { - x: 0, - y: y + height - RESIZE_BAR_WITH_LABEL_HEIGHT, - }, - size: { - width: visibleArea.size.width, - height: visibleArea.size.height, - }, - }; - - const drawableRect = intersectionOfRects(labelRect, this.visibleArea); - - drawText(this._label, context, labelRect, drawableRect, { - fillStyle: COLORS.REACT_RESIZE_BAR_DOT, - textAlign: 'center', - }); - } else { - // Otherwise draw horizontally centered resize bar dots - context.beginPath(); - context.fillStyle = COLORS.REACT_RESIZE_BAR_DOT; - context.arc( - horizontalCenter, - verticalCenter, - RESIZE_BAR_DOT_RADIUS, - 0, - 2 * Math.PI, - ); - context.arc( - horizontalCenter + RESIZE_BAR_DOT_SPACING, - verticalCenter, - RESIZE_BAR_DOT_RADIUS, - 0, - 2 * Math.PI, - ); - context.arc( - horizontalCenter - RESIZE_BAR_DOT_SPACING, - verticalCenter, - RESIZE_BAR_DOT_RADIUS, - 0, - 2 * Math.PI, - ); - context.fill(); - } - } - - _setInteractionState(state: ResizeBarState) { - if (this._interactionState === state) { - return; - } - this._interactionState = state; - this.setNeedsDisplay(); - } - - _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) { - const cursorInView = rectContainsPoint( - interaction.payload.location, - this.frame, - ); - if (cursorInView) { - this._setInteractionState('dragging'); - viewRefs.activeView = this; - } - } - - _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) { - const cursorInView = rectContainsPoint( - interaction.payload.location, - this.frame, - ); - - if (viewRefs.activeView === this) { - // If we're actively dragging this resize bar, - // show the cursor even if the pointer isn't hovering over this view. - this.currentCursor = 'ns-resize'; - } else if (cursorInView) { - if (this.showLabel) { - this.currentCursor = 'pointer'; - } else { - this.currentCursor = 'ns-resize'; - } - } - - if (cursorInView) { - viewRefs.hoveredView = this; - } - - if (this._interactionState === 'dragging') { - return; - } - this._setInteractionState(cursorInView ? 'hovered' : 'normal'); - } - - _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) { - const cursorInView = rectContainsPoint( - interaction.payload.location, - this.frame, - ); - if (this._interactionState === 'dragging') { - this._setInteractionState(cursorInView ? 'hovered' : 'normal'); - } - - if (viewRefs.activeView === this) { - viewRefs.activeView = null; - } - } - - handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { - switch (interaction.type) { - case 'mousedown': - this._handleMouseDown(interaction, viewRefs); - return; - case 'mousemove': - this._handleMouseMove(interaction, viewRefs); - return; - case 'mouseup': - this._handleMouseUp(interaction, viewRefs); - return; - } - } -} - export class ResizableView extends View { _canvasRef: {current: HTMLCanvasElement | null}; _layoutState: LayoutState; _mutableViewStateKey: string; - _resizeBar: ResizeBar; + _resizeBar: ResizeBarView; _resizingState: ResizingState | null = null; _subview: View; _viewState: ViewState; @@ -238,7 +69,7 @@ export class ResizableView extends View { this._layoutState = {barOffsetY: 0}; this._mutableViewStateKey = label + ':ResizableView'; this._subview = subview; - this._resizeBar = new ResizeBar(surface, frame, label); + this._resizeBar = new ResizeBarView(surface, frame, label); this._viewState = viewState; this.addSubview(this._subview); @@ -379,6 +210,8 @@ export class ResizableView extends View { const subviewDesiredSize = this._subview.desiredSize(); this._updateLayoutStateAndResizeBar(subviewDesiredSize.height); this.setNeedsDisplay(); + + return true; } } } @@ -397,6 +230,8 @@ export class ResizableView extends View { // Double clicking on the expanded view should collapse. this._updateLayoutStateAndResizeBar(0); this.setNeedsDisplay(); + + return true; } } } @@ -410,6 +245,8 @@ export class ResizableView extends View { cursorOffsetInBarFrame: mouseY - resizeBarFrame.origin.y, mouseY, }; + + return true; } } @@ -421,6 +258,8 @@ export class ResizableView extends View { mouseY: interaction.payload.location.y, }; this.setNeedsDisplay(); + + return true; } } @@ -443,20 +282,15 @@ export class ResizableView extends View { handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { switch (interaction.type) { case 'click': - this._handleClick(interaction); - return; + return this._handleClick(interaction); case 'double-click': - this._handleDoubleClick(interaction); - return; + return this._handleDoubleClick(interaction); case 'mousedown': - this._handleMouseDown(interaction); - return; + return this._handleMouseDown(interaction); case 'mousemove': - this._handleMouseMove(interaction); - return; + return this._handleMouseMove(interaction); case 'mouseup': - this._handleMouseUp(interaction); - return; + return this._handleMouseUp(interaction); } } } diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizeBarView.js b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizeBarView.js new file mode 100644 index 0000000000000..d4924edf6d31d --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizeBarView.js @@ -0,0 +1,193 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { + Interaction, + MouseDownInteraction, + MouseMoveInteraction, + MouseUpInteraction, +} from '../useCanvasInteraction'; +import type {Rect} from '../geometry'; +import type {ViewRefs} from '../Surface'; + +import {BORDER_SIZE, COLORS} from '../../content-views/constants'; +import {drawText} from '../../content-views/utils/text'; +import {Surface} from '../Surface'; +import {View} from '../View'; +import {rectContainsPoint} from '../geometry'; +import {noopLayout} from '../layouter'; + +type ResizeBarState = 'normal' | 'hovered' | 'dragging'; + +const RESIZE_BAR_DOT_RADIUS = 1; +const RESIZE_BAR_DOT_SPACING = 4; +const RESIZE_BAR_HEIGHT = 8; +const RESIZE_BAR_WITH_LABEL_HEIGHT = 16; + +export class ResizeBarView extends View { + _interactionState: ResizeBarState = 'normal'; + _label: string; + + showLabel: boolean = false; + + constructor(surface: Surface, frame: Rect, label: string) { + super(surface, frame, noopLayout); + + this._label = label; + } + + desiredSize() { + return this.showLabel + ? {height: RESIZE_BAR_WITH_LABEL_HEIGHT, width: 0} + : {height: RESIZE_BAR_HEIGHT, width: 0}; + } + + draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) { + const {frame} = this; + const {x, y} = frame.origin; + const {width, height} = frame.size; + + const isActive = + this._interactionState === 'dragging' || + (this._interactionState === 'hovered' && viewRefs.activeView === null); + + context.fillStyle = isActive + ? COLORS.REACT_RESIZE_BAR_ACTIVE + : COLORS.REACT_RESIZE_BAR; + context.fillRect(x, y, width, height); + + context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER; + context.fillRect(x, y, width, BORDER_SIZE); + context.fillRect(x, y + height - BORDER_SIZE, width, BORDER_SIZE); + + const horizontalCenter = x + width / 2; + const verticalCenter = y + height / 2; + + if (this.showLabel) { + // When the resize view is collapsed entirely, + // rather than showing a resize bar– this view displays a label. + const labelRect: Rect = { + origin: { + x: 0, + y: y + height - RESIZE_BAR_WITH_LABEL_HEIGHT, + }, + size: { + width: frame.size.width, + height: RESIZE_BAR_WITH_LABEL_HEIGHT, + }, + }; + + drawText(this._label, context, labelRect, frame, { + fillStyle: COLORS.REACT_RESIZE_BAR_DOT, + textAlign: 'center', + }); + } else { + // Otherwise draw horizontally centered resize bar dots + context.beginPath(); + context.fillStyle = COLORS.REACT_RESIZE_BAR_DOT; + context.arc( + horizontalCenter, + verticalCenter, + RESIZE_BAR_DOT_RADIUS, + 0, + 2 * Math.PI, + ); + context.arc( + horizontalCenter + RESIZE_BAR_DOT_SPACING, + verticalCenter, + RESIZE_BAR_DOT_RADIUS, + 0, + 2 * Math.PI, + ); + context.arc( + horizontalCenter - RESIZE_BAR_DOT_SPACING, + verticalCenter, + RESIZE_BAR_DOT_RADIUS, + 0, + 2 * Math.PI, + ); + context.fill(); + } + } + + _setInteractionState(state: ResizeBarState) { + if (this._interactionState === state) { + return; + } + this._interactionState = state; + this.setNeedsDisplay(); + } + + _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + if (cursorInView) { + this._setInteractionState('dragging'); + viewRefs.activeView = this; + } + } + + _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + + if (viewRefs.activeView === this) { + // If we're actively dragging this resize bar, + // show the cursor even if the pointer isn't hovering over this view. + this.currentCursor = 'ns-resize'; + } else if (cursorInView) { + if (this.showLabel) { + this.currentCursor = 'pointer'; + } else { + this.currentCursor = 'ns-resize'; + } + } + + if (cursorInView) { + viewRefs.hoveredView = this; + } + + if (this._interactionState === 'dragging') { + return; + } + this._setInteractionState(cursorInView ? 'hovered' : 'normal'); + } + + _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) { + const cursorInView = rectContainsPoint( + interaction.payload.location, + this.frame, + ); + if (this._interactionState === 'dragging') { + this._setInteractionState(cursorInView ? 'hovered' : 'normal'); + } + + if (viewRefs.activeView === this) { + viewRefs.activeView = null; + } + } + + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { + switch (interaction.type) { + case 'mousedown': + this._handleMouseDown(interaction, viewRefs); + break; + case 'mousemove': + this._handleMouseMove(interaction, viewRefs); + break; + case 'mouseup': + this._handleMouseUp(interaction, viewRefs); + break; + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/resizable/index.js b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/index.js new file mode 100644 index 0000000000000..9acb4abaf97ab --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/resizable/index.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './ResizableView'; +export * from './ResizeBarView'; diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollBarView.js b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollBarView.js new file mode 100644 index 0000000000000..0195e0a7968ac --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollBarView.js @@ -0,0 +1,219 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Interaction} from '../useCanvasInteraction'; +import type {Rect} from '../geometry'; +import type {Surface, ViewRefs} from '../Surface'; +import type { + ClickInteraction, + MouseDownInteraction, + MouseMoveInteraction, + MouseUpInteraction, +} from '../useCanvasInteraction'; + +import {VerticalScrollOverflowView} from './VerticalScrollOverflowView'; +import {rectContainsPoint, rectEqualToRect} from '../geometry'; +import {View} from '../View'; +import {BORDER_SIZE, COLORS} from '../../content-views/constants'; + +const SCROLL_BAR_SIZE = 14; + +const HIDDEN_RECT = { + origin: { + x: 0, + y: 0, + }, + size: { + width: 0, + height: 0, + }, +}; + +export class VerticalScrollBarView extends View { + _contentHeight: number = 0; + _isScrolling: boolean = false; + _scrollBarRect: Rect = HIDDEN_RECT; + _scrollThumbRect: Rect = HIDDEN_RECT; + _verticalScrollOverflowView: VerticalScrollOverflowView; + + constructor( + surface: Surface, + frame: Rect, + verticalScrollOverflowView: VerticalScrollOverflowView, + ) { + super(surface, frame); + + this._verticalScrollOverflowView = verticalScrollOverflowView; + } + + desiredSize() { + return { + width: SCROLL_BAR_SIZE, + height: 0, // No desired height + }; + } + + getMaxScrollThumbY(): number { + const {height} = this.frame.size; + + const maxScrollThumbY = height - this._scrollThumbRect.size.height; + + return maxScrollThumbY; + } + + setContentHeight(contentHeight: number) { + this._contentHeight = contentHeight; + + const {height, width} = this.frame.size; + + const proposedScrollThumbRect = { + origin: { + x: this.frame.origin.x, + y: this._scrollThumbRect.origin.y, + }, + size: { + width, + height: height * (height / contentHeight), + }, + }; + + if (!rectEqualToRect(this._scrollThumbRect, proposedScrollThumbRect)) { + this._scrollThumbRect = proposedScrollThumbRect; + this.setNeedsDisplay(); + } + } + + setScrollThumbY(value: number) { + const {height} = this.frame.size; + + const maxScrollThumbY = this.getMaxScrollThumbY(); + const newScrollThumbY = Math.max(0, Math.min(maxScrollThumbY, value)); + + this._scrollThumbRect = { + ...this._scrollThumbRect, + origin: { + x: this.frame.origin.x, + y: newScrollThumbY, + }, + }; + + const maxContentOffset = this._contentHeight - height; + const contentScrollOffset = + (newScrollThumbY / maxScrollThumbY) * maxContentOffset * -1; + + this._verticalScrollOverflowView.setScrollOffset( + contentScrollOffset, + maxScrollThumbY, + ); + } + + draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) { + const {x, y} = this.frame.origin; + const {width, height} = this.frame.size; + + // TODO Use real color + context.fillStyle = COLORS.REACT_RESIZE_BAR; + context.fillRect(x, y, width, height); + + // TODO Use real color + context.fillStyle = COLORS.SCROLL_CARET; + context.fillRect( + this._scrollThumbRect.origin.x, + this._scrollThumbRect.origin.y, + this._scrollThumbRect.size.width, + this._scrollThumbRect.size.height, + ); + + // TODO Use real color + context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER; + context.fillRect(x, y, BORDER_SIZE, height); + } + + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) { + switch (interaction.type) { + case 'click': + this._handleClick(interaction, viewRefs); + break; + case 'mousedown': + this._handleMouseDown(interaction, viewRefs); + break; + case 'mousemove': + this._handleMouseMove(interaction, viewRefs); + break; + case 'mouseup': + this._handleMouseUp(interaction, viewRefs); + break; + } + } + + _handleClick(interaction: ClickInteraction, viewRefs: ViewRefs) { + const {location} = interaction.payload; + if (rectContainsPoint(location, this.frame)) { + if (rectContainsPoint(location, this._scrollThumbRect)) { + // Ignore clicks on the track thumb directly. + return; + } + + const currentScrollThumbY = this._scrollThumbRect.origin.y; + const y = location.y; + + const {height} = this.frame.size; + + // Scroll up or down about one viewport worth of content: + const deltaY = (height / this._contentHeight) * height * 0.8; + + this.setScrollThumbY( + y > currentScrollThumbY + ? this._scrollThumbRect.origin.y + deltaY + : this._scrollThumbRect.origin.y - deltaY, + ); + } + } + + _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) { + const {location} = interaction.payload; + if (!rectContainsPoint(location, this._scrollThumbRect)) { + return; + } + viewRefs.activeView = this; + + this.currentCursor = 'default'; + + this._isScrolling = true; + this.setNeedsDisplay(); + } + + _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) { + const {event, location} = interaction.payload; + if (rectContainsPoint(location, this.frame)) { + if (viewRefs.hoveredView !== this) { + viewRefs.hoveredView = this; + } + + this.currentCursor = 'default'; + } + + if (viewRefs.activeView === this) { + this.currentCursor = 'default'; + + this.setScrollThumbY(this._scrollThumbRect.origin.y + event.movementY); + } + } + + _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) { + if (viewRefs.activeView === this) { + viewRefs.activeView = null; + } + + if (this._isScrolling) { + this._isScrolling = false; + this.setNeedsDisplay(); + } + } +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollOverflowView.js b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollOverflowView.js new file mode 100644 index 0000000000000..37f96f16c2444 --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollOverflowView.js @@ -0,0 +1,91 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Rect} from '../geometry'; +import type {ScrollState} from '../utils/scrollState'; +import type {Surface} from '../Surface'; +import type {ViewState} from '../../types'; + +import {VerticalScrollBarView} from './VerticalScrollBarView'; +import {withVerticalScrollbarLayout} from './withVerticalScrollbarLayout'; +import {View} from '../View'; +import {VerticalScrollView} from '../VerticalScrollView'; + +export class VerticalScrollOverflowView extends View { + _contentView: View; + _isProcessingOnChange: boolean = false; + _isScrolling: boolean = false; + _scrollOffset: number = 0; + _scrollBarView: VerticalScrollBarView; + _verticalScrollView: VerticalScrollView; + + constructor( + surface: Surface, + frame: Rect, + contentView: View, + viewState: ViewState, + ) { + super(surface, frame, withVerticalScrollbarLayout); + + this._contentView = contentView; + this._verticalScrollView = new VerticalScrollView( + surface, + frame, + contentView, + viewState, + 'VerticalScrollOverflowView', + ); + this._verticalScrollView.onChange(this._onVerticalScrollViewChange); + + this._scrollBarView = new VerticalScrollBarView(surface, frame, this); + + this.addSubview(this._verticalScrollView); + this.addSubview(this._scrollBarView); + } + + layoutSubviews() { + super.layoutSubviews(); + + const contentSize = this._contentView.desiredSize(); + + // This should be done after calling super.layoutSubviews() – calling it + // before somehow causes _contentView to need display on every mousemove + // event when the scroll bar is shown. + this._scrollBarView.setContentHeight(contentSize.height); + } + + setScrollOffset(newScrollOffset: number, maxScrollOffset: number) { + const deltaY = newScrollOffset - this._scrollOffset; + + if (!this._isProcessingOnChange) { + this._verticalScrollView.scrollBy(-deltaY); + } + + this._scrollOffset = newScrollOffset; + + this.setNeedsDisplay(); + } + + _onVerticalScrollViewChange = ( + scrollState: ScrollState, + containerLength: number, + ) => { + const maxOffset = scrollState.length - containerLength; + if (maxOffset === 0) { + return; + } + + const percentage = Math.abs(scrollState.offset) / maxOffset; + const maxScrollThumbY = this._scrollBarView.getMaxScrollThumbY(); + + this._isProcessingOnChange = true; + this._scrollBarView.setScrollThumbY(percentage * maxScrollThumbY); + this._isProcessingOnChange = false; + }; +} diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/index.js b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/index.js new file mode 100644 index 0000000000000..ffb8e5a9df0bc --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/index.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './VerticalScrollBarView'; +export * from './VerticalScrollOverflowView'; diff --git a/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/withVerticalScrollbarLayout.js b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/withVerticalScrollbarLayout.js new file mode 100644 index 0000000000000..f17266b5d409e --- /dev/null +++ b/packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/withVerticalScrollbarLayout.js @@ -0,0 +1,55 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Layouter} from '../layouter'; + +/** + * Assumes {@param layout} will only contain 2 views. + */ +export const withVerticalScrollbarLayout: Layouter = ( + layout, + containerFrame, +) => { + const [contentLayoutInfo, scrollbarLayoutInfo] = layout; + + const desiredContentSize = contentLayoutInfo.view.desiredSize(); + const shouldShowScrollbar = + desiredContentSize.height > containerFrame.size.height; + const scrollbarWidth = shouldShowScrollbar + ? scrollbarLayoutInfo.view.desiredSize().width + : 0; + + const laidOutContentLayoutInfo = { + ...contentLayoutInfo, + frame: { + origin: contentLayoutInfo.view.frame.origin, + size: { + width: containerFrame.size.width - scrollbarWidth, + height: containerFrame.size.height, + }, + }, + }; + const laidOutScrollbarLayoutInfo = { + ...scrollbarLayoutInfo, + frame: { + origin: { + x: + laidOutContentLayoutInfo.frame.origin.x + + laidOutContentLayoutInfo.frame.size.width, + y: containerFrame.origin.y, + }, + size: { + width: scrollbarWidth, + height: containerFrame.size.height, + }, + }, + }; + + return [laidOutContentLayoutInfo, laidOutScrollbarLayoutInfo]; +};