DevTools: Scheduling profiler: Add vertical scroll bar (#22005)

Co-authored-by: E-Liang Tan <eliang@eliangtan.com>
This commit is contained in:
Brian Vaughn
2021-08-11 14:19:45 -04:00
committed by GitHub
co-authored by E-Liang Tan
parent 88d121899a
commit 280e3f936f
14 changed files with 730 additions and 283 deletions
+21 -10
View File
@@ -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
@@ -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')
) {
@@ -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.
@@ -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;
}
}
@@ -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;
}
}
@@ -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';
@@ -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.
*/
@@ -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';
} 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';
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);
}
}
}
@@ -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;
}
}
}
@@ -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';
@@ -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();
}
}
}
@@ -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;
};
}
@@ -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';
@@ -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];
};