@samitouri / QOS-React-2 / commits / 280e3f936f

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

Co-authored-by: E-Liang Tan <eliang@eliangtan.com>

Brian Vaughn committed Aug 11, 2021 at 14:19 UTC 280e3f936f89583b44e10f29ed0ed0155b50ab8a
14 files changed +731 -284
packages/react-devtools-scheduling-profiler/src/CanvasPage.js
+21 -10
@@ -30,14 +30,12 @@ import {copy} from 'clipboard-js';
30 import prettyMilliseconds from 'pretty-ms';
31
32 import {
33 - BackgroundColorView,
33 HorizontalPanAndZoomView,
34 ResizableView,
35 + VerticalScrollOverflowView,
36 Surface,
37 VerticalScrollView,
38 View,
39 - createComposedLayout,
40 - lastViewTakesUpRemainingSpaceLayout,
39 useCanvasInteraction,
40 verticallyStackedLayout,
41 zeroPoint,
@@ -325,10 +323,9 @@ function AutoSizedCanvas({
323 const rootView = new View(
324 surface,
325 defaultFrame,
328 - createComposedLayout(
329 - verticallyStackedLayout,
330 - lastViewTakesUpRemainingSpaceLayout,
331 - ),
326 + verticallyStackedLayout,
327 + defaultFrame,
328 + COLORS.BACKGROUND,
329 );
330 rootView.addSubview(axisMarkersViewWrapper);
331 if (userTimingMarksViewWrapper !== null) {
@@ -345,10 +342,14 @@ function AutoSizedCanvas({
342 }
343 rootView.addSubview(flamechartViewWrapper);
344
348 - // If subviews are less than the available height, fill remaining height with a solid color.
349 - rootView.addSubview(new BackgroundColorView(surface, defaultFrame));
345 + const verticalScrollOverflowView = new VerticalScrollOverflowView(
346 + surface,
347 + defaultFrame,
348 + rootView,
349 + viewState,
350 + );
351
351 - surfaceRef.current.rootView = rootView;
352 + surfaceRef.current.rootView = verticalScrollOverflowView;
353 }, [data]);
354
355 useLayoutEffect(() => {
@@ -401,6 +402,16 @@ function AutoSizedCanvas({
402 const surface = surfaceRef.current;
403 surface.handleInteraction(interaction);
404
405 + // Flush any display work that got queued up as part of the previous interaction.
406 + // Typically there should be no work, but certain interactions may need a second pass.
407 + // For example, the ResizableView may collapse/expand its contents,
408 + // which requires a second layout pass for an ancestor VerticalScrollOverflowView.
409 + //
410 + // TODO It would be nice to remove this call for performance reasons.
411 + // To do that, we'll need to address the UX bug with VerticalScrollOverflowView.
412 + // For more info see: https://github.com/facebook/react/pull/22005#issuecomment-896953399
413 + surface.displayIfNeeded();
414 +
415 canvas.style.cursor = surface.getCurrentCursor() || 'default';
416
417 // Defer drawing to canvas until React's commit phase, to avoid drawing
packages/react-devtools-scheduling-profiler/src/import-worker/preprocessData.js
+4 -2
@@ -241,9 +241,11 @@ function processTimelineEvent(
241
242 // Reduce noise from events like DOMActivate, load/unload, etc. which are usually not relevant
243 if (
244 - type.startsWith('blur') ||
245 - type.startsWith('click') ||
244 + type === 'blur' ||
245 + type === 'click' ||
246 + type === 'input' ||
247 type.startsWith('focus') ||
248 + type.startsWith('key') ||
249 type.startsWith('mouse') ||
250 type.startsWith('pointer')
251 ) {
packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollOverflowView.js deleted
-3
@@ -1,3 +0,0 @@
1 -// TODO Vertically stack views (via verticallyStackedLayout).
2 -// If stacked views are taller than the available height, a vertical scrollbar will be shown on the side,
3 -// and width will be adjusted to subtract the width of the scrollbar.
packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js
+52 -24
@@ -34,10 +34,16 @@ const CARET_MARGIN = 3;
34 const CARET_WIDTH = 5;
35 const CARET_HEIGHT = 3;
36
37 +type OnChangeCallback = (
38 + scrollState: ScrollState,
39 + containerLength: number,
40 +) => void;
41 +
42 export class VerticalScrollView extends View {
43 _contentView: View;
44 _isPanning: boolean;
45 _mutableViewStateKey: string;
46 + _onChangeCallback: OnChangeCallback | null;
47 _scrollState: ScrollState;
48 _viewState: ViewState;
49
@@ -53,6 +59,7 @@ export class VerticalScrollView extends View {
59 this._contentView = contentView;
60 this._isPanning = false;
61 this._mutableViewStateKey = label + ':VerticalScrollView';
62 + this._onChangeCallback = null;
63 this._scrollState = {
64 offset: 0,
65 length: 0,
@@ -146,26 +153,45 @@ export class VerticalScrollView extends View {
153 super.layoutSubviews();
154 }
155
149 - handleInteraction(interaction: Interaction) {
156 + handleInteraction(interaction: Interaction): ?boolean {
157 switch (interaction.type) {
158 case 'mousedown':
152 - this._handleMouseDown(interaction);
153 - break;
159 + return this._handleMouseDown(interaction);
160 case 'mousemove':
155 - this._handleMouseMove(interaction);
156 - break;
161 + return this._handleMouseMove(interaction);
162 case 'mouseup':
158 - this._handleMouseUp(interaction);
159 - break;
163 + return this._handleMouseUp(interaction);
164 case 'wheel-shift':
161 - this._handleWheelShift(interaction);
162 - break;
165 + return this._handleWheelShift(interaction);
166 }
167 }
168
169 + onChange(callback: OnChangeCallback) {
170 + this._onChangeCallback = callback;
171 + }
172 +
173 + scrollBy(deltaY: number): boolean {
174 + const newState = translateState({
175 + state: this._scrollState,
176 + delta: -deltaY,
177 + containerLength: this.frame.size.height,
178 + });
179 +
180 + // If the state is updated by this wheel scroll,
181 + // return true to prevent the interaction from bubbling.
182 + // For instance, this prevents the outermost container from also scrolling.
183 + return this._setScrollState(newState);
184 + }
185 +
186 _handleMouseDown(interaction: MouseDownInteraction) {
187 if (rectContainsPoint(interaction.payload.location, this.frame)) {
168 - this._isPanning = true;
188 + const frameHeight = this.frame.size.height;
189 + const contentHeight = this._contentView.desiredSize().height;
190 + // Don't claim drag operations if the content is not tall enough to be scrollable.
191 + // This would block any outer scroll views from working.
192 + if (frameHeight < contentHeight) {
193 + this._isPanning = true;
194 + }
195 }
196 }
197
@@ -179,6 +205,7 @@ export class VerticalScrollView extends View {
205 containerLength: this.frame.size.height,
206 });
207 this._setScrollState(newState);
208 + return true;
209 }
210
211 _handleMouseUp(interaction: MouseUpInteraction) {
@@ -187,31 +214,27 @@ export class VerticalScrollView extends View {
214 }
215 }
216
190 - _handleWheelShift(interaction: WheelWithShiftInteraction) {
217 + _handleWheelShift(interaction: WheelWithShiftInteraction): boolean {
218 const {
219 location,
220 delta: {deltaX, deltaY},
221 } = interaction.payload;
222 +
223 if (!rectContainsPoint(location, this.frame)) {
196 - return; // Not scrolling on view
224 + return false; // Not scrolling on view
225 }
226
227 const absDeltaX = Math.abs(deltaX);
228 const absDeltaY = Math.abs(deltaY);
229 if (absDeltaX > absDeltaY) {
202 - return; // Scrolling horizontally
230 + return false; // Scrolling horizontally
231 }
232
233 if (absDeltaY < MOVE_WHEEL_DELTA_THRESHOLD) {
206 - return;
234 + return false; // Movement was too small and should be ignored.
235 }
236
209 - const newState = translateState({
210 - state: this._scrollState,
211 - delta: -deltaY,
212 - containerLength: this.frame.size.height,
213 - });
214 - this._setScrollState(newState);
237 + return this.scrollBy(deltaY);
238 }
239
240 _restoreMutableViewState() {
@@ -231,10 +254,7 @@ export class VerticalScrollView extends View {
254 this.setNeedsDisplay();
255 }
256
234 - /**
235 - * @private
236 - */
237 - _setScrollState(proposedState: ScrollState) {
257 + _setScrollState(proposedState: ScrollState): boolean {
258 const height = this._contentView.frame.size.height;
259 const clampedState = clampState({
260 state: proposedState,
@@ -247,6 +267,14 @@ export class VerticalScrollView extends View {
267 this._scrollState.length = clampedState.length;
268
269 this.setNeedsDisplay();
270 +
271 + if (this._onChangeCallback !== null) {
272 + this._onChangeCallback(clampedState, this.frame.size.height);
273 + }
274 +
275 + return true;
276 }
277 +
278 + return false;
279 }
280 }
packages/react-devtools-scheduling-profiler/src/view-base/View.js
+46 -8
@@ -29,6 +29,8 @@ import {noopLayout, viewsToLayout, collapseLayoutIntoViews} from './layouter';
29 * subclasses.
30 */
31 export class View {
32 + _backgroundColor: string | null;
33 +
34 currentCursor: string | null = null;
35
36 surface: Surface;
@@ -70,7 +72,9 @@ export class View {
72 frame: Rect,
73 layouter: Layouter = noopLayout,
74 visibleArea: Rect = frame,
75 + backgroundColor?: string | null = null,
76 ) {
77 + this._backgroundColor = backgroundColor || null;
78 this.surface = surface;
79 this.frame = frame;
80 this._layouter = layouter;
@@ -246,6 +250,20 @@ export class View {
250 subview.displayIfNeeded(context, viewRefs);
251 }
252 });
253 +
254 + const backgroundColor = this._backgroundColor;
255 + if (backgroundColor !== null) {
256 + const desiredSize = this.desiredSize();
257 + if (visibleArea.size.height > desiredSize.height) {
258 + context.fillStyle = backgroundColor;
259 + context.fillRect(
260 + visibleArea.origin.x,
261 + visibleArea.origin.y + desiredSize.height,
262 + visibleArea.size.width,
263 + visibleArea.size.height - desiredSize.height,
264 + );
265 + }
266 + }
267 }
268
269 /**
@@ -255,7 +273,7 @@ export class View {
273 *
274 * NOTE: Do not call directly! Use `handleInteractionAndPropagateToSubviews`
275 */
258 - handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {}
276 + handleInteraction(interaction: Interaction, viewRefs: ViewRefs): ?boolean {}
277
278 /**
279 * Handle an `interaction` and propagates it to all of this view's
@@ -270,19 +288,39 @@ export class View {
288 handleInteractionAndPropagateToSubviews(
289 interaction: Interaction,
290 viewRefs: ViewRefs,
273 - ) {
291 + ): boolean {
292 const {subviews, visibleArea} = this;
293
294 if (visibleArea.size.height === 0) {
277 - return;
295 + return false;
296 }
297
280 - this.handleInteraction(interaction, viewRefs);
281 -
282 - subviews.forEach(subview => {
298 + // Pass the interaction to subviews first,
299 + // so they have the opportunity to claim it before it bubbles.
300 + //
301 + // Views are painted first to last,
302 + // so they should process interactions last to first,
303 + // so views in front (on top) can claim the interaction first.
304 + for (let i = subviews.length - 1; i >= 0; i--) {
305 + const subview = subviews[i];
306 if (rectIntersectsRect(visibleArea, subview.visibleArea)) {
284 - subview.handleInteractionAndPropagateToSubviews(interaction, viewRefs);
307 + const didSubviewHandle =
308 + subview.handleInteractionAndPropagateToSubviews(
309 + interaction,
310 + viewRefs,
311 + ) === true;
312 + if (didSubviewHandle) {
313 + return true;
314 + }
315 }
286 - });
316 + }
317 +
318 + const didSelfHandle =
319 + this.handleInteraction(interaction, viewRefs) === true;
320 + if (didSelfHandle) {
321 + return true;
322 + }
323 +
324 + return false;
325 }
326 }
packages/react-devtools-scheduling-profiler/src/view-base/index.js
+2 -1
@@ -9,10 +9,11 @@
9
10 export * from './BackgroundColorView';
11 export * from './HorizontalPanAndZoomView';
12 -export * from './ResizableView';
12 export * from './Surface';
13 export * from './VerticalScrollView';
14 export * from './View';
15 export * from './geometry';
16 export * from './layouter';
17 +export * from './resizable';
18 export * from './useCanvasInteraction';
19 +export * from './vertical-scroll-overflow';
packages/react-devtools-scheduling-profiler/src/view-base/layouter.js
-44
@@ -206,50 +206,6 @@ export const atLeastContainerHeightLayout: Layouter = (
206 }));
207 };
208
209 -/**
210 - * Forces last view to take up the space below the second-last view.
211 - * Intended to be used with a vertical stack layout.
212 - */
213 -export const lastViewTakesUpRemainingSpaceLayout: Layouter = (
214 - layout,
215 - containerFrame,
216 -) => {
217 - if (layout.length === 0) {
218 - // Nothing to do
219 - return layout;
220 - }
221 -
222 - if (layout.length === 1) {
223 - // No second-last view; the view should just take up the container height
224 - return containerHeightLayout(layout, containerFrame);
225 - }
226 -
227 - const layoutInfoToPassThrough = layout.slice(0, layout.length - 1);
228 - const secondLastLayoutInfo =
229 - layoutInfoToPassThrough[layoutInfoToPassThrough.length - 1];
230 -
231 - const remainingHeight =
232 - containerFrame.size.height -
233 - secondLastLayoutInfo.frame.origin.y -
234 - secondLastLayoutInfo.frame.size.height;
235 - const height = Math.max(remainingHeight, 0); // Prevent negative heights
236 -
237 - const lastLayoutInfo = layout[layout.length - 1];
238 - return [
239 - ...layoutInfoToPassThrough,
240 - {
241 - ...lastLayoutInfo,
242 - frame: {
243 - origin: lastLayoutInfo.frame.origin,
244 - size: {
245 - width: lastLayoutInfo.frame.size.width,
246 - height,
247 - },
248 - },
249 - },
250 - ];
251 -};
252 -
209 /**
210 * Create a layouter that applies each layouter in `layouters` in sequence.
211 */
packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizableView.js renamed
+26 -192
@@ -14,20 +14,17 @@ import type {
14 MouseDownInteraction,
15 MouseMoveInteraction,
16 MouseUpInteraction,
17 -} from './useCanvasInteraction';
18 -import type {Rect} from './geometry';
19 -import type {ViewRefs} from './Surface';
20 -import type {ViewState} from '../types';
21 -
22 -import {BORDER_SIZE, COLORS} from '../content-views/constants';
23 -import {drawText} from '../content-views/utils/text';
24 -import {Surface} from './Surface';
25 -import {View} from './View';
26 -import {intersectionOfRects, rectContainsPoint} from './geometry';
27 -import {noopLayout} from './layouter';
28 -import {clamp} from './utils/clamp';
29 -
30 -type ResizeBarState = 'normal' | 'hovered' | 'dragging';
17 +} from '../useCanvasInteraction';
18 +import type {Rect} from '../geometry';
19 +import type {ViewRefs} from '../Surface';
20 +import type {ViewState} from '../../types';
21 +
22 +import {ResizeBarView} from './ResizeBarView';
23 +import {Surface} from '../Surface';
24 +import {View} from '../View';
25 +import {rectContainsPoint} from '../geometry';
26 +import {noopLayout} from '../layouter';
27 +import {clamp} from '../utils/clamp';
28
29 type ResizingState = $ReadOnly<{|
30 /** Distance between top of resize bar and mouseY */
@@ -41,8 +38,6 @@ type LayoutState = {|
38 barOffsetY: number,
39 |};
40
44 -const RESIZE_BAR_DOT_RADIUS = 1;
45 -const RESIZE_BAR_DOT_SPACING = 4;
41 const RESIZE_BAR_HEIGHT = 8;
42 const RESIZE_BAR_WITH_LABEL_HEIGHT = 16;
43
@@ -51,175 +46,11 @@ const HIDDEN_RECT = {
46 size: {width: 0, height: 0},
47 };
48
54 -class ResizeBar extends View {
55 - _interactionState: ResizeBarState = 'normal';
56 - _label: string;
57 -
58 - showLabel: boolean = false;
59 -
60 - constructor(surface: Surface, frame: Rect, label: string) {
61 - super(surface, frame, noopLayout);
62 -
63 - this._label = label;
64 - }
65 -
66 - desiredSize() {
67 - return this.showLabel
68 - ? {height: RESIZE_BAR_WITH_LABEL_HEIGHT, width: 0}
69 - : {height: RESIZE_BAR_HEIGHT, width: 0};
70 - }
71 -
72 - draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
73 - const {visibleArea} = this;
74 - const {x, y} = visibleArea.origin;
75 - const {width, height} = visibleArea.size;
76 -
77 - const isActive =
78 - this._interactionState === 'dragging' ||
79 - (this._interactionState === 'hovered' && viewRefs.activeView === null);
80 -
81 - context.fillStyle = isActive
82 - ? COLORS.REACT_RESIZE_BAR_ACTIVE
83 - : COLORS.REACT_RESIZE_BAR;
84 - context.fillRect(x, y, width, height);
85 -
86 - context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER;
87 - context.fillRect(x, y, width, BORDER_SIZE);
88 - context.fillRect(x, y + height - BORDER_SIZE, width, BORDER_SIZE);
89 -
90 - const horizontalCenter = x + width / 2;
91 - const verticalCenter = y + height / 2;
92 -
93 - if (this.showLabel) {
94 - // When the resize view is collapsed entirely,
95 - // rather than showing a resize bar– this view displays a label.
96 - const labelRect: Rect = {
97 - origin: {
98 - x: 0,
99 - y: y + height - RESIZE_BAR_WITH_LABEL_HEIGHT,
100 - },
101 - size: {
102 - width: visibleArea.size.width,
103 - height: visibleArea.size.height,
104 - },
105 - };
106 -
107 - const drawableRect = intersectionOfRects(labelRect, this.visibleArea);
108 -
109 - drawText(this._label, context, labelRect, drawableRect, {
110 - fillStyle: COLORS.REACT_RESIZE_BAR_DOT,
111 - textAlign: 'center',
112 - });
113 - } else {
114 - // Otherwise draw horizontally centered resize bar dots
115 - context.beginPath();
116 - context.fillStyle = COLORS.REACT_RESIZE_BAR_DOT;
117 - context.arc(
118 - horizontalCenter,
119 - verticalCenter,
120 - RESIZE_BAR_DOT_RADIUS,
121 - 0,
122 - 2 * Math.PI,
123 - );
124 - context.arc(
125 - horizontalCenter + RESIZE_BAR_DOT_SPACING,
126 - verticalCenter,
127 - RESIZE_BAR_DOT_RADIUS,
128 - 0,
129 - 2 * Math.PI,
130 - );
131 - context.arc(
132 - horizontalCenter - RESIZE_BAR_DOT_SPACING,
133 - verticalCenter,
134 - RESIZE_BAR_DOT_RADIUS,
135 - 0,
136 - 2 * Math.PI,
137 - );
138 - context.fill();
139 - }
140 - }
141 -
142 - _setInteractionState(state: ResizeBarState) {
143 - if (this._interactionState === state) {
144 - return;
145 - }
146 - this._interactionState = state;
147 - this.setNeedsDisplay();
148 - }
149 -
150 - _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
151 - const cursorInView = rectContainsPoint(
152 - interaction.payload.location,
153 - this.frame,
154 - );
155 - if (cursorInView) {
156 - this._setInteractionState('dragging');
157 - viewRefs.activeView = this;
158 - }
159 - }
160 -
161 - _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
162 - const cursorInView = rectContainsPoint(
163 - interaction.payload.location,
164 - this.frame,
165 - );
166 -
167 - if (viewRefs.activeView === this) {
168 - // If we're actively dragging this resize bar,
169 - // show the cursor even if the pointer isn't hovering over this view.
170 - this.currentCursor = 'ns-resize';
171 - } else if (cursorInView) {
172 - if (this.showLabel) {
173 - this.currentCursor = 'pointer';
174 - } else {
175 - this.currentCursor = 'ns-resize';
176 - }
177 - }
178 -
179 - if (cursorInView) {
180 - viewRefs.hoveredView = this;
181 - }
182 -
183 - if (this._interactionState === 'dragging') {
184 - return;
185 - }
186 - this._setInteractionState(cursorInView ? 'hovered' : 'normal');
187 - }
188 -
189 - _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
190 - const cursorInView = rectContainsPoint(
191 - interaction.payload.location,
192 - this.frame,
193 - );
194 - if (this._interactionState === 'dragging') {
195 - this._setInteractionState(cursorInView ? 'hovered' : 'normal');
196 - }
197 -
198 - if (viewRefs.activeView === this) {
199 - viewRefs.activeView = null;
200 - }
201 - }
202 -
203 - handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
204 - switch (interaction.type) {
205 - case 'mousedown':
206 - this._handleMouseDown(interaction, viewRefs);
207 - return;
208 - case 'mousemove':
209 - this._handleMouseMove(interaction, viewRefs);
210 - return;
211 - case 'mouseup':
212 - this._handleMouseUp(interaction, viewRefs);
213 - return;
214 - }
215 - }
216 -}
217 -
49 export class ResizableView extends View {
50 _canvasRef: {current: HTMLCanvasElement | null};
51 _layoutState: LayoutState;
52 _mutableViewStateKey: string;
222 - _resizeBar: ResizeBar;
53 + _resizeBar: ResizeBarView;
54 _resizingState: ResizingState | null = null;
55 _subview: View;
56 _viewState: ViewState;
@@ -238,7 +69,7 @@ export class ResizableView extends View {
69 this._layoutState = {barOffsetY: 0};
70 this._mutableViewStateKey = label + ':ResizableView';
71 this._subview = subview;
241 - this._resizeBar = new ResizeBar(surface, frame, label);
72 + this._resizeBar = new ResizeBarView(surface, frame, label);
73 this._viewState = viewState;
74
75 this.addSubview(this._subview);
@@ -379,6 +210,8 @@ export class ResizableView extends View {
210 const subviewDesiredSize = this._subview.desiredSize();
211 this._updateLayoutStateAndResizeBar(subviewDesiredSize.height);
212 this.setNeedsDisplay();
213 +
214 + return true;
215 }
216 }
217 }
@@ -397,6 +230,8 @@ export class ResizableView extends View {
230 // Double clicking on the expanded view should collapse.
231 this._updateLayoutStateAndResizeBar(0);
232 this.setNeedsDisplay();
233 +
234 + return true;
235 }
236 }
237 }
@@ -410,6 +245,8 @@ export class ResizableView extends View {
245 cursorOffsetInBarFrame: mouseY - resizeBarFrame.origin.y,
246 mouseY,
247 };
248 +
249 + return true;
250 }
251 }
252
@@ -421,6 +258,8 @@ export class ResizableView extends View {
258 mouseY: interaction.payload.location.y,
259 };
260 this.setNeedsDisplay();
261 +
262 + return true;
263 }
264 }
265
@@ -443,20 +282,15 @@ export class ResizableView extends View {
282 handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
283 switch (interaction.type) {
284 case 'click':
446 - this._handleClick(interaction);
447 - return;
285 + return this._handleClick(interaction);
286 case 'double-click':
449 - this._handleDoubleClick(interaction);
450 - return;
287 + return this._handleDoubleClick(interaction);
288 case 'mousedown':
452 - this._handleMouseDown(interaction);
453 - return;
289 + return this._handleMouseDown(interaction);
290 case 'mousemove':
455 - this._handleMouseMove(interaction);
456 - return;
291 + return this._handleMouseMove(interaction);
292 case 'mouseup':
458 - this._handleMouseUp(interaction);
459 - return;
293 + return this._handleMouseUp(interaction);
294 }
295 }
296 }
packages/react-devtools-scheduling-profiler/src/view-base/resizable/ResizeBarView.js new
+193
@@ -0,0 +1,193 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {
11 + Interaction,
12 + MouseDownInteraction,
13 + MouseMoveInteraction,
14 + MouseUpInteraction,
15 +} from '../useCanvasInteraction';
16 +import type {Rect} from '../geometry';
17 +import type {ViewRefs} from '../Surface';
18 +
19 +import {BORDER_SIZE, COLORS} from '../../content-views/constants';
20 +import {drawText} from '../../content-views/utils/text';
21 +import {Surface} from '../Surface';
22 +import {View} from '../View';
23 +import {rectContainsPoint} from '../geometry';
24 +import {noopLayout} from '../layouter';
25 +
26 +type ResizeBarState = 'normal' | 'hovered' | 'dragging';
27 +
28 +const RESIZE_BAR_DOT_RADIUS = 1;
29 +const RESIZE_BAR_DOT_SPACING = 4;
30 +const RESIZE_BAR_HEIGHT = 8;
31 +const RESIZE_BAR_WITH_LABEL_HEIGHT = 16;
32 +
33 +export class ResizeBarView extends View {
34 + _interactionState: ResizeBarState = 'normal';
35 + _label: string;
36 +
37 + showLabel: boolean = false;
38 +
39 + constructor(surface: Surface, frame: Rect, label: string) {
40 + super(surface, frame, noopLayout);
41 +
42 + this._label = label;
43 + }
44 +
45 + desiredSize() {
46 + return this.showLabel
47 + ? {height: RESIZE_BAR_WITH_LABEL_HEIGHT, width: 0}
48 + : {height: RESIZE_BAR_HEIGHT, width: 0};
49 + }
50 +
51 + draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
52 + const {frame} = this;
53 + const {x, y} = frame.origin;
54 + const {width, height} = frame.size;
55 +
56 + const isActive =
57 + this._interactionState === 'dragging' ||
58 + (this._interactionState === 'hovered' && viewRefs.activeView === null);
59 +
60 + context.fillStyle = isActive
61 + ? COLORS.REACT_RESIZE_BAR_ACTIVE
62 + : COLORS.REACT_RESIZE_BAR;
63 + context.fillRect(x, y, width, height);
64 +
65 + context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER;
66 + context.fillRect(x, y, width, BORDER_SIZE);
67 + context.fillRect(x, y + height - BORDER_SIZE, width, BORDER_SIZE);
68 +
69 + const horizontalCenter = x + width / 2;
70 + const verticalCenter = y + height / 2;
71 +
72 + if (this.showLabel) {
73 + // When the resize view is collapsed entirely,
74 + // rather than showing a resize bar– this view displays a label.
75 + const labelRect: Rect = {
76 + origin: {
77 + x: 0,
78 + y: y + height - RESIZE_BAR_WITH_LABEL_HEIGHT,
79 + },
80 + size: {
81 + width: frame.size.width,
82 + height: RESIZE_BAR_WITH_LABEL_HEIGHT,
83 + },
84 + };
85 +
86 + drawText(this._label, context, labelRect, frame, {
87 + fillStyle: COLORS.REACT_RESIZE_BAR_DOT,
88 + textAlign: 'center',
89 + });
90 + } else {
91 + // Otherwise draw horizontally centered resize bar dots
92 + context.beginPath();
93 + context.fillStyle = COLORS.REACT_RESIZE_BAR_DOT;
94 + context.arc(
95 + horizontalCenter,
96 + verticalCenter,
97 + RESIZE_BAR_DOT_RADIUS,
98 + 0,
99 + 2 * Math.PI,
100 + );
101 + context.arc(
102 + horizontalCenter + RESIZE_BAR_DOT_SPACING,
103 + verticalCenter,
104 + RESIZE_BAR_DOT_RADIUS,
105 + 0,
106 + 2 * Math.PI,
107 + );
108 + context.arc(
109 + horizontalCenter - RESIZE_BAR_DOT_SPACING,
110 + verticalCenter,
111 + RESIZE_BAR_DOT_RADIUS,
112 + 0,
113 + 2 * Math.PI,
114 + );
115 + context.fill();
116 + }
117 + }
118 +
119 + _setInteractionState(state: ResizeBarState) {
120 + if (this._interactionState === state) {
121 + return;
122 + }
123 + this._interactionState = state;
124 + this.setNeedsDisplay();
125 + }
126 +
127 + _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
128 + const cursorInView = rectContainsPoint(
129 + interaction.payload.location,
130 + this.frame,
131 + );
132 + if (cursorInView) {
133 + this._setInteractionState('dragging');
134 + viewRefs.activeView = this;
135 + }
136 + }
137 +
138 + _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
139 + const cursorInView = rectContainsPoint(
140 + interaction.payload.location,
141 + this.frame,
142 + );
143 +
144 + if (viewRefs.activeView === this) {
145 + // If we're actively dragging this resize bar,
146 + // show the cursor even if the pointer isn't hovering over this view.
147 + this.currentCursor = 'ns-resize';
148 + } else if (cursorInView) {
149 + if (this.showLabel) {
150 + this.currentCursor = 'pointer';
151 + } else {
152 + this.currentCursor = 'ns-resize';
153 + }
154 + }
155 +
156 + if (cursorInView) {
157 + viewRefs.hoveredView = this;
158 + }
159 +
160 + if (this._interactionState === 'dragging') {
161 + return;
162 + }
163 + this._setInteractionState(cursorInView ? 'hovered' : 'normal');
164 + }
165 +
166 + _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
167 + const cursorInView = rectContainsPoint(
168 + interaction.payload.location,
169 + this.frame,
170 + );
171 + if (this._interactionState === 'dragging') {
172 + this._setInteractionState(cursorInView ? 'hovered' : 'normal');
173 + }
174 +
175 + if (viewRefs.activeView === this) {
176 + viewRefs.activeView = null;
177 + }
178 + }
179 +
180 + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
181 + switch (interaction.type) {
182 + case 'mousedown':
183 + this._handleMouseDown(interaction, viewRefs);
184 + break;
185 + case 'mousemove':
186 + this._handleMouseMove(interaction, viewRefs);
187 + break;
188 + case 'mouseup':
189 + this._handleMouseUp(interaction, viewRefs);
190 + break;
191 + }
192 + }
193 +}
packages/react-devtools-scheduling-profiler/src/view-base/resizable/index.js new
+11
@@ -0,0 +1,11 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export * from './ResizableView';
11 +export * from './ResizeBarView';
packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollBarView.js new
+219
@@ -0,0 +1,219 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Interaction} from '../useCanvasInteraction';
11 +import type {Rect} from '../geometry';
12 +import type {Surface, ViewRefs} from '../Surface';
13 +import type {
14 + ClickInteraction,
15 + MouseDownInteraction,
16 + MouseMoveInteraction,
17 + MouseUpInteraction,
18 +} from '../useCanvasInteraction';
19 +
20 +import {VerticalScrollOverflowView} from './VerticalScrollOverflowView';
21 +import {rectContainsPoint, rectEqualToRect} from '../geometry';
22 +import {View} from '../View';
23 +import {BORDER_SIZE, COLORS} from '../../content-views/constants';
24 +
25 +const SCROLL_BAR_SIZE = 14;
26 +
27 +const HIDDEN_RECT = {
28 + origin: {
29 + x: 0,
30 + y: 0,
31 + },
32 + size: {
33 + width: 0,
34 + height: 0,
35 + },
36 +};
37 +
38 +export class VerticalScrollBarView extends View {
39 + _contentHeight: number = 0;
40 + _isScrolling: boolean = false;
41 + _scrollBarRect: Rect = HIDDEN_RECT;
42 + _scrollThumbRect: Rect = HIDDEN_RECT;
43 + _verticalScrollOverflowView: VerticalScrollOverflowView;
44 +
45 + constructor(
46 + surface: Surface,
47 + frame: Rect,
48 + verticalScrollOverflowView: VerticalScrollOverflowView,
49 + ) {
50 + super(surface, frame);
51 +
52 + this._verticalScrollOverflowView = verticalScrollOverflowView;
53 + }
54 +
55 + desiredSize() {
56 + return {
57 + width: SCROLL_BAR_SIZE,
58 + height: 0, // No desired height
59 + };
60 + }
61 +
62 + getMaxScrollThumbY(): number {
63 + const {height} = this.frame.size;
64 +
65 + const maxScrollThumbY = height - this._scrollThumbRect.size.height;
66 +
67 + return maxScrollThumbY;
68 + }
69 +
70 + setContentHeight(contentHeight: number) {
71 + this._contentHeight = contentHeight;
72 +
73 + const {height, width} = this.frame.size;
74 +
75 + const proposedScrollThumbRect = {
76 + origin: {
77 + x: this.frame.origin.x,
78 + y: this._scrollThumbRect.origin.y,
79 + },
80 + size: {
81 + width,
82 + height: height * (height / contentHeight),
83 + },
84 + };
85 +
86 + if (!rectEqualToRect(this._scrollThumbRect, proposedScrollThumbRect)) {
87 + this._scrollThumbRect = proposedScrollThumbRect;
88 + this.setNeedsDisplay();
89 + }
90 + }
91 +
92 + setScrollThumbY(value: number) {
93 + const {height} = this.frame.size;
94 +
95 + const maxScrollThumbY = this.getMaxScrollThumbY();
96 + const newScrollThumbY = Math.max(0, Math.min(maxScrollThumbY, value));
97 +
98 + this._scrollThumbRect = {
99 + ...this._scrollThumbRect,
100 + origin: {
101 + x: this.frame.origin.x,
102 + y: newScrollThumbY,
103 + },
104 + };
105 +
106 + const maxContentOffset = this._contentHeight - height;
107 + const contentScrollOffset =
108 + (newScrollThumbY / maxScrollThumbY) * maxContentOffset * -1;
109 +
110 + this._verticalScrollOverflowView.setScrollOffset(
111 + contentScrollOffset,
112 + maxScrollThumbY,
113 + );
114 + }
115 +
116 + draw(context: CanvasRenderingContext2D, viewRefs: ViewRefs) {
117 + const {x, y} = this.frame.origin;
118 + const {width, height} = this.frame.size;
119 +
120 + // TODO Use real color
121 + context.fillStyle = COLORS.REACT_RESIZE_BAR;
122 + context.fillRect(x, y, width, height);
123 +
124 + // TODO Use real color
125 + context.fillStyle = COLORS.SCROLL_CARET;
126 + context.fillRect(
127 + this._scrollThumbRect.origin.x,
128 + this._scrollThumbRect.origin.y,
129 + this._scrollThumbRect.size.width,
130 + this._scrollThumbRect.size.height,
131 + );
132 +
133 + // TODO Use real color
134 + context.fillStyle = COLORS.REACT_RESIZE_BAR_BORDER;
135 + context.fillRect(x, y, BORDER_SIZE, height);
136 + }
137 +
138 + handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
139 + switch (interaction.type) {
140 + case 'click':
141 + this._handleClick(interaction, viewRefs);
142 + break;
143 + case 'mousedown':
144 + this._handleMouseDown(interaction, viewRefs);
145 + break;
146 + case 'mousemove':
147 + this._handleMouseMove(interaction, viewRefs);
148 + break;
149 + case 'mouseup':
150 + this._handleMouseUp(interaction, viewRefs);
151 + break;
152 + }
153 + }
154 +
155 + _handleClick(interaction: ClickInteraction, viewRefs: ViewRefs) {
156 + const {location} = interaction.payload;
157 + if (rectContainsPoint(location, this.frame)) {
158 + if (rectContainsPoint(location, this._scrollThumbRect)) {
159 + // Ignore clicks on the track thumb directly.
160 + return;
161 + }
162 +
163 + const currentScrollThumbY = this._scrollThumbRect.origin.y;
164 + const y = location.y;
165 +
166 + const {height} = this.frame.size;
167 +
168 + // Scroll up or down about one viewport worth of content:
169 + const deltaY = (height / this._contentHeight) * height * 0.8;
170 +
171 + this.setScrollThumbY(
172 + y > currentScrollThumbY
173 + ? this._scrollThumbRect.origin.y + deltaY
174 + : this._scrollThumbRect.origin.y - deltaY,
175 + );
176 + }
177 + }
178 +
179 + _handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
180 + const {location} = interaction.payload;
181 + if (!rectContainsPoint(location, this._scrollThumbRect)) {
182 + return;
183 + }
184 + viewRefs.activeView = this;
185 +
186 + this.currentCursor = 'default';
187 +
188 + this._isScrolling = true;
189 + this.setNeedsDisplay();
190 + }
191 +
192 + _handleMouseMove(interaction: MouseMoveInteraction, viewRefs: ViewRefs) {
193 + const {event, location} = interaction.payload;
194 + if (rectContainsPoint(location, this.frame)) {
195 + if (viewRefs.hoveredView !== this) {
196 + viewRefs.hoveredView = this;
197 + }
198 +
199 + this.currentCursor = 'default';
200 + }
201 +
202 + if (viewRefs.activeView === this) {
203 + this.currentCursor = 'default';
204 +
205 + this.setScrollThumbY(this._scrollThumbRect.origin.y + event.movementY);
206 + }
207 + }
208 +
209 + _handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
210 + if (viewRefs.activeView === this) {
211 + viewRefs.activeView = null;
212 + }
213 +
214 + if (this._isScrolling) {
215 + this._isScrolling = false;
216 + this.setNeedsDisplay();
217 + }
218 + }
219 +}
packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/VerticalScrollOverflowView.js new
+91
@@ -0,0 +1,91 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Rect} from '../geometry';
11 +import type {ScrollState} from '../utils/scrollState';
12 +import type {Surface} from '../Surface';
13 +import type {ViewState} from '../../types';
14 +
15 +import {VerticalScrollBarView} from './VerticalScrollBarView';
16 +import {withVerticalScrollbarLayout} from './withVerticalScrollbarLayout';
17 +import {View} from '../View';
18 +import {VerticalScrollView} from '../VerticalScrollView';
19 +
20 +export class VerticalScrollOverflowView extends View {
21 + _contentView: View;
22 + _isProcessingOnChange: boolean = false;
23 + _isScrolling: boolean = false;
24 + _scrollOffset: number = 0;
25 + _scrollBarView: VerticalScrollBarView;
26 + _verticalScrollView: VerticalScrollView;
27 +
28 + constructor(
29 + surface: Surface,
30 + frame: Rect,
31 + contentView: View,
32 + viewState: ViewState,
33 + ) {
34 + super(surface, frame, withVerticalScrollbarLayout);
35 +
36 + this._contentView = contentView;
37 + this._verticalScrollView = new VerticalScrollView(
38 + surface,
39 + frame,
40 + contentView,
41 + viewState,
42 + 'VerticalScrollOverflowView',
43 + );
44 + this._verticalScrollView.onChange(this._onVerticalScrollViewChange);
45 +
46 + this._scrollBarView = new VerticalScrollBarView(surface, frame, this);
47 +
48 + this.addSubview(this._verticalScrollView);
49 + this.addSubview(this._scrollBarView);
50 + }
51 +
52 + layoutSubviews() {
53 + super.layoutSubviews();
54 +
55 + const contentSize = this._contentView.desiredSize();
56 +
57 + // This should be done after calling super.layoutSubviews() – calling it
58 + // before somehow causes _contentView to need display on every mousemove
59 + // event when the scroll bar is shown.
60 + this._scrollBarView.setContentHeight(contentSize.height);
61 + }
62 +
63 + setScrollOffset(newScrollOffset: number, maxScrollOffset: number) {
64 + const deltaY = newScrollOffset - this._scrollOffset;
65 +
66 + if (!this._isProcessingOnChange) {
67 + this._verticalScrollView.scrollBy(-deltaY);
68 + }
69 +
70 + this._scrollOffset = newScrollOffset;
71 +
72 + this.setNeedsDisplay();
73 + }
74 +
75 + _onVerticalScrollViewChange = (
76 + scrollState: ScrollState,
77 + containerLength: number,
78 + ) => {
79 + const maxOffset = scrollState.length - containerLength;
80 + if (maxOffset === 0) {
81 + return;
82 + }
83 +
84 + const percentage = Math.abs(scrollState.offset) / maxOffset;
85 + const maxScrollThumbY = this._scrollBarView.getMaxScrollThumbY();
86 +
87 + this._isProcessingOnChange = true;
88 + this._scrollBarView.setScrollThumbY(percentage * maxScrollThumbY);
89 + this._isProcessingOnChange = false;
90 + };
91 +}
packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/index.js new
+11
@@ -0,0 +1,11 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +export * from './VerticalScrollBarView';
11 +export * from './VerticalScrollOverflowView';
packages/react-devtools-scheduling-profiler/src/view-base/vertical-scroll-overflow/withVerticalScrollbarLayout.js new
+55
@@ -0,0 +1,55 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Layouter} from '../layouter';
11 +
12 +/**
13 + * Assumes {@param layout} will only contain 2 views.
14 + */
15 +export const withVerticalScrollbarLayout: Layouter = (
16 + layout,
17 + containerFrame,
18 +) => {
19 + const [contentLayoutInfo, scrollbarLayoutInfo] = layout;
20 +
21 + const desiredContentSize = contentLayoutInfo.view.desiredSize();
22 + const shouldShowScrollbar =
23 + desiredContentSize.height > containerFrame.size.height;
24 + const scrollbarWidth = shouldShowScrollbar
25 + ? scrollbarLayoutInfo.view.desiredSize().width
26 + : 0;
27 +
28 + const laidOutContentLayoutInfo = {
29 + ...contentLayoutInfo,
30 + frame: {
31 + origin: contentLayoutInfo.view.frame.origin,
32 + size: {
33 + width: containerFrame.size.width - scrollbarWidth,
34 + height: containerFrame.size.height,
35 + },
36 + },
37 + };
38 + const laidOutScrollbarLayoutInfo = {
39 + ...scrollbarLayoutInfo,
40 + frame: {
41 + origin: {
42 + x:
43 + laidOutContentLayoutInfo.frame.origin.x +
44 + laidOutContentLayoutInfo.frame.size.width,
45 + y: containerFrame.origin.y,
46 + },
47 + size: {
48 + width: scrollbarWidth,
49 + height: containerFrame.size.height,
50 + },
51 + },
52 + };
53 +
54 + return [laidOutContentLayoutInfo, laidOutScrollbarLayoutInfo];
55 +};