Scheduling Profiler: Extract and test scroll state from horizontal pan and zoom view (#19682)
* Extract reusable scroll logic from HorizontalPanAndZoomView * Change VerticalScrollView to use scrollState * Clarify test name
E-Liang Tan committed
Sep 3, 2020 at 22:27 UTC
e9454894ff1715d6d51558fb05c1344440901069
7 files changed
+599
-197
packages/react-devtools-scheduling-profiler/src/CanvasPage.js
+1
-2
@@ -149,8 +149,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
149
) => {
150
syncedHorizontalPanAndZoomViewsRef.current.forEach(
151
syncedView =>
152
- triggeringView !== syncedView &&
153
- syncedView.setPanAndZoomState(newState),
152
+ triggeringView !== syncedView && syncedView.setScrollState(newState),
153
);
154
};
155
packages/react-devtools-scheduling-profiler/src/view-base/HorizontalPanAndZoomView.js
+68
-148
@@ -18,54 +18,34 @@ import type {
18
WheelWithMetaInteraction,
19
} from './useCanvasInteraction';
20
import type {Rect} from './geometry';
21
+import type {ScrollState} from './utils/scrollState';
22
23
import {Surface} from './Surface';
24
import {View} from './View';
25
import {rectContainsPoint} from './geometry';
25
-import {clamp} from './utils/clamp';
26
import {
27
- MIN_ZOOM_LEVEL,
27
+ clampState,
28
+ moveStateToRange,
29
+ areScrollStatesEqual,
30
+ translateState,
31
+ zoomState,
32
+} from './utils/scrollState';
33
+import {
34
+ DEFAULT_ZOOM_LEVEL,
35
MAX_ZOOM_LEVEL,
36
+ MIN_ZOOM_LEVEL,
37
MOVE_WHEEL_DELTA_THRESHOLD,
38
} from './constants';
39
32
-type HorizontalPanAndZoomState = $ReadOnly<{|
33
- /** Horizontal offset; positive in the left direction */
34
- offsetX: number,
35
- zoomLevel: number,
36
-|}>;
37
-
40
export type HorizontalPanAndZoomViewOnChangeCallback = (
39
- state: HorizontalPanAndZoomState,
41
+ state: ScrollState,
42
view: HorizontalPanAndZoomView,
43
) => void;
44
43
-function panAndZoomStatesAreEqual(
44
- state1: HorizontalPanAndZoomState,
45
- state2: HorizontalPanAndZoomState,
46
-): boolean {
47
- return (
48
- state1.offsetX === state2.offsetX && state1.zoomLevel === state2.zoomLevel
49
- );
50
-}
51
-
52
-function zoomLevelAndIntrinsicWidthToFrameWidth(
53
- zoomLevel: number,
54
- intrinsicWidth: number,
55
-): number {
56
- return intrinsicWidth * zoomLevel;
57
-}
58
-
45
export class HorizontalPanAndZoomView extends View {
46
_intrinsicContentWidth: number;
61
-
62
- _panAndZoomState: HorizontalPanAndZoomState = {
63
- offsetX: 0,
64
- zoomLevel: 0.25,
65
- };
66
-
47
_isPanning = false;
68
-
48
+ _scrollState: ScrollState = {offset: 0, length: 0};
49
_onStateChange: HorizontalPanAndZoomViewOnChangeCallback = () => {};
50
51
constructor(
@@ -78,33 +58,42 @@ export class HorizontalPanAndZoomView extends View {
58
super(surface, frame);
59
this.addSubview(contentView);
60
this._intrinsicContentWidth = intrinsicContentWidth;
61
+ this._setScrollState({
62
+ offset: 0,
63
+ length: intrinsicContentWidth * DEFAULT_ZOOM_LEVEL,
64
+ });
65
if (onStateChange) this._onStateChange = onStateChange;
66
}
67
68
setFrame(newFrame: Rect) {
69
super.setFrame(newFrame);
70
87
- // Revalidate panAndZoomState
88
- this._setStateAndInformCallbacksIfChanged(this._panAndZoomState);
71
+ // Revalidate scrollState
72
+ this._setStateAndInformCallbacksIfChanged(this._scrollState);
73
}
74
91
- setPanAndZoomState(proposedState: HorizontalPanAndZoomState) {
92
- this._setPanAndZoomState(proposedState);
75
+ setScrollState(proposedState: ScrollState) {
76
+ this._setScrollState(proposedState);
77
}
78
79
/**
96
- * Just sets pan and zoom state. Use `_setStateAndInformCallbacksIfChanged`
97
- * if this view's callbacks should also be called.
80
+ * Just sets scroll state. Use `_setStateAndInformCallbacksIfChanged` if this
81
+ * view's callbacks should also be called.
82
*
83
* @returns Whether state was changed
84
* @private
85
*/
102
- _setPanAndZoomState(proposedState: HorizontalPanAndZoomState): boolean {
103
- const clampedState = this._clampedProposedState(proposedState);
104
- if (panAndZoomStatesAreEqual(clampedState, this._panAndZoomState)) {
86
+ _setScrollState(proposedState: ScrollState): boolean {
87
+ const clampedState = clampState({
88
+ state: proposedState,
89
+ minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
90
+ maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
91
+ containerLength: this.frame.size.width,
92
+ });
93
+ if (areScrollStatesEqual(clampedState, this._scrollState)) {
94
return false;
95
}
107
- this._panAndZoomState = clampedState;
96
+ this._scrollState = clampedState;
97
this.setNeedsDisplay();
98
return true;
99
}
@@ -112,11 +101,9 @@ export class HorizontalPanAndZoomView extends View {
101
/**
102
* @private
103
*/
115
- _setStateAndInformCallbacksIfChanged(
116
- proposedState: HorizontalPanAndZoomState,
117
- ) {
118
- if (this._setPanAndZoomState(proposedState)) {
119
- this._onStateChange(this._panAndZoomState, this);
104
+ _setStateAndInformCallbacksIfChanged(proposedState: ScrollState) {
105
+ if (this._setScrollState(proposedState)) {
106
+ this._onStateChange(this._scrollState, this);
107
}
108
}
109
@@ -133,17 +120,14 @@ export class HorizontalPanAndZoomView extends View {
120
}
121
122
layoutSubviews() {
136
- const {offsetX, zoomLevel} = this._panAndZoomState;
123
+ const {offset, length} = this._scrollState;
124
const proposedFrame = {
125
origin: {
139
- x: this.frame.origin.x + offsetX,
126
+ x: this.frame.origin.x + offset,
127
y: this.frame.origin.y,
128
},
129
size: {
143
- width: zoomLevelAndIntrinsicWidthToFrameWidth(
144
- zoomLevel,
145
- this._intrinsicContentWidth,
146
- ),
130
+ width: length,
131
height: this.frame.size.height,
132
},
133
};
@@ -157,27 +141,18 @@ export class HorizontalPanAndZoomView extends View {
141
*
142
* Does not inform callbacks of state change since this is a public API.
143
*/
160
- zoomToRange(startX: number, endX: number) {
161
- // Zoom and offset must be done separately, so that if the zoom level is
162
- // clamped the offset will still be correct (unless it gets clamped too).
163
- const zoomClampedState = this._clampedProposedStateZoomLevel({
164
- ...this._panAndZoomState,
165
- // Let:
166
- // I = intrinsic content width, i = zoom range = (endX - startX).
167
- // W = contentView's final zoomed width, w = this view's width
168
- // Goal: we want the visible width w to only contain the requested range i.
169
- // Derivation:
170
- // (1) i/I = w/W (by intuitive definition of variables)
171
- // (2) W = zoomLevel * I (definition of zoomLevel)
172
- // => zoomLevel = W/I (algebraic manipulation)
173
- // = w/i (rearranging (1))
174
- zoomLevel: this.frame.size.width / (endX - startX),
175
- });
176
- const offsetAdjustedState = this._clampedProposedStateOffsetX({
177
- ...zoomClampedState,
178
- offsetX: -startX * zoomClampedState.zoomLevel,
144
+ zoomToRange(rangeStart: number, rangeEnd: number) {
145
+ const newState = moveStateToRange({
146
+ state: this._scrollState,
147
+ rangeStart,
148
+ rangeEnd,
149
+ contentLength: this._intrinsicContentWidth,
150
+
151
+ minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
152
+ maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
153
+ containerLength: this.frame.size.width,
154
});
180
- this._setPanAndZoomState(offsetAdjustedState);
155
+ this._setScrollState(newState);
156
}
157
158
_handleMouseDown(interaction: MouseDownInteraction) {
@@ -190,12 +165,12 @@ export class HorizontalPanAndZoomView extends View {
165
if (!this._isPanning) {
166
return;
167
}
193
- const {offsetX} = this._panAndZoomState;
194
- const {movementX} = interaction.payload.event;
195
- this._setStateAndInformCallbacksIfChanged({
196
- ...this._panAndZoomState,
197
- offsetX: offsetX + movementX,
168
+ const newState = translateState({
169
+ state: this._scrollState,
170
+ delta: interaction.payload.event.movementX,
171
+ containerLength: this.frame.size.width,
172
});
173
+ this._setStateAndInformCallbacksIfChanged(newState);
174
}
175
176
_handleMouseUp(interaction: MouseUpInteraction) {
@@ -209,6 +184,7 @@ export class HorizontalPanAndZoomView extends View {
184
location,
185
delta: {deltaX, deltaY},
186
} = interaction.payload;
187
+
188
if (!rectContainsPoint(location, this.frame)) {
189
return; // Not scrolling on view
190
}
@@ -218,15 +194,16 @@ export class HorizontalPanAndZoomView extends View {
194
if (absDeltaY > absDeltaX) {
195
return; // Scrolling vertically
196
}
221
-
197
if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
198
return;
199
}
200
226
- this._setStateAndInformCallbacksIfChanged({
227
- ...this._panAndZoomState,
228
- offsetX: this._panAndZoomState.offsetX - deltaX,
201
+ const newState = translateState({
202
+ state: this._scrollState,
203
+ delta: -deltaX,
204
+ containerLength: this.frame.size.width,
205
});
206
+ this._setStateAndInformCallbacksIfChanged(newState);
207
}
208
209
_handleWheelZoom(
@@ -239,6 +216,7 @@ export class HorizontalPanAndZoomView extends View {
216
location,
217
delta: {deltaY},
218
} = interaction.payload;
219
+
220
if (!rectContainsPoint(location, this.frame)) {
221
return; // Not scrolling on view
222
}
@@ -248,28 +226,16 @@ export class HorizontalPanAndZoomView extends View {
226
return;
227
}
228
251
- const zoomClampedState = this._clampedProposedStateZoomLevel({
252
- ...this._panAndZoomState,
253
- zoomLevel: this._panAndZoomState.zoomLevel * (1 + 0.005 * -deltaY),
254
- });
255
-
256
- // Determine where the mouse is, and adjust the offset so that point stays
257
- // centered after zooming.
258
- const oldMouseXInFrame = location.x - zoomClampedState.offsetX;
259
- const fractionalMouseX =
260
- oldMouseXInFrame / this._contentView.frame.size.width;
261
- const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth(
262
- zoomClampedState.zoomLevel,
263
- this._intrinsicContentWidth,
264
- );
265
- const newMouseXInFrame = fractionalMouseX * newContentWidth;
229
+ const newState = zoomState({
230
+ state: this._scrollState,
231
+ multiplier: 1 + 0.005 * -deltaY,
232
+ fixedPoint: location.x - this._scrollState.offset,
233
267
- const offsetAdjustedState = this._clampedProposedStateOffsetX({
268
- ...zoomClampedState,
269
- offsetX: location.x - newMouseXInFrame,
234
+ minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
235
+ maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
236
+ containerLength: this.frame.size.width,
237
});
271
-
272
- this._setStateAndInformCallbacksIfChanged(offsetAdjustedState);
238
+ this._setStateAndInformCallbacksIfChanged(newState);
239
}
240
241
handleInteraction(interaction: Interaction) {
@@ -293,50 +259,4 @@ export class HorizontalPanAndZoomView extends View {
259
break;
260
}
261
}
296
-
297
- /**
298
- * @private
299
- */
300
- _clampedProposedStateZoomLevel(
301
- proposedState: HorizontalPanAndZoomState,
302
- ): HorizontalPanAndZoomState {
303
- // Content-based min zoom level to ensure that contentView's width >= our width.
304
- const minContentBasedZoomLevel =
305
- this.frame.size.width / this._intrinsicContentWidth;
306
- const minZoomLevel = Math.max(MIN_ZOOM_LEVEL, minContentBasedZoomLevel);
307
- return {
308
- ...proposedState,
309
- zoomLevel: clamp(minZoomLevel, MAX_ZOOM_LEVEL, proposedState.zoomLevel),
310
- };
311
- }
312
-
313
- /**
314
- * @private
315
- */
316
- _clampedProposedStateOffsetX(
317
- proposedState: HorizontalPanAndZoomState,
318
- ): HorizontalPanAndZoomState {
319
- const newContentWidth = zoomLevelAndIntrinsicWidthToFrameWidth(
320
- proposedState.zoomLevel,
321
- this._intrinsicContentWidth,
322
- );
323
- return {
324
- ...proposedState,
325
- offsetX: clamp(
326
- -(newContentWidth - this.frame.size.width),
327
- 0,
328
- proposedState.offsetX,
329
- ),
330
- };
331
- }
332
-
333
- /**
334
- * @private
335
- */
336
- _clampedProposedState(
337
- proposedState: HorizontalPanAndZoomState,
338
- ): HorizontalPanAndZoomState {
339
- const zoomClampedState = this._clampedProposedStateZoomLevel(proposedState);
340
- return this._clampedProposedStateOffsetX(zoomClampedState);
341
- }
262
}
packages/react-devtools-scheduling-profiler/src/view-base/VerticalScrollView.js
+33
-47
@@ -15,41 +15,33 @@ import type {
15
WheelPlainInteraction,
16
} from './useCanvasInteraction';
17
import type {Rect} from './geometry';
18
+import type {ScrollState} from './utils/scrollState';
19
20
import {Surface} from './Surface';
21
import {View} from './View';
22
import {rectContainsPoint} from './geometry';
22
-import {clamp} from './utils/clamp';
23
+import {
24
+ clampState,
25
+ areScrollStatesEqual,
26
+ translateState,
27
+} from './utils/scrollState';
28
import {MOVE_WHEEL_DELTA_THRESHOLD} from './constants';
29
25
-type VerticalScrollState = $ReadOnly<{|
26
- offsetY: number,
27
-|}>;
28
-
29
-function scrollStatesAreEqual(
30
- state1: VerticalScrollState,
31
- state2: VerticalScrollState,
32
-): boolean {
33
- return state1.offsetY === state2.offsetY;
34
-}
35
-
30
export class VerticalScrollView extends View {
37
- _scrollState: VerticalScrollState = {
38
- offsetY: 0,
39
- };
40
-
31
+ _scrollState: ScrollState = {offset: 0, length: 0};
32
_isPanning = false;
33
34
constructor(surface: Surface, frame: Rect, contentView: View) {
35
super(surface, frame);
36
this.addSubview(contentView);
37
+ this._setScrollState(this._scrollState);
38
}
39
40
setFrame(newFrame: Rect) {
41
super.setFrame(newFrame);
42
43
// Revalidate scrollState
52
- this._updateState(this._scrollState);
44
+ this._setScrollState(this._scrollState);
45
}
46
47
desiredSize() {
@@ -65,7 +57,7 @@ export class VerticalScrollView extends View {
57
}
58
59
layoutSubviews() {
68
- const {offsetY} = this._scrollState;
60
+ const {offset} = this._scrollState;
61
const desiredSize = this._contentView.desiredSize();
62
63
const minimumHeight = this.frame.size.height;
@@ -76,7 +68,7 @@ export class VerticalScrollView extends View {
68
const proposedFrame = {
69
origin: {
70
x: this.frame.origin.x,
79
- y: this.frame.origin.y + offsetY,
71
+ y: this.frame.origin.y + offset,
72
},
73
size: {
74
width: this.frame.size.width,
@@ -97,12 +89,12 @@ export class VerticalScrollView extends View {
89
if (!this._isPanning) {
90
return;
91
}
100
- const {offsetY} = this._scrollState;
101
- const {movementY} = interaction.payload.event;
102
- this._updateState({
103
- ...this._scrollState,
104
- offsetY: offsetY + movementY,
92
+ const newState = translateState({
93
+ state: this._scrollState,
94
+ delta: interaction.payload.event.movementY,
95
+ containerLength: this.frame.size.height,
96
});
97
+ this._setScrollState(newState);
98
}
99
100
_handleMouseUp(interaction: MouseUpInteraction) {
@@ -130,10 +122,12 @@ export class VerticalScrollView extends View {
122
return;
123
}
124
133
- this._updateState({
134
- ...this._scrollState,
135
- offsetY: this._scrollState.offsetY - deltaY,
125
+ const newState = translateState({
126
+ state: this._scrollState,
127
+ delta: -deltaY,
128
+ containerLength: this.frame.size.height,
129
});
130
+ this._setScrollState(newState);
131
}
132
133
handleInteraction(interaction: Interaction) {
@@ -156,26 +150,18 @@ export class VerticalScrollView extends View {
150
/**
151
* @private
152
*/
159
- _updateState(proposedState: VerticalScrollState) {
160
- const clampedState = this._clampedProposedState(proposedState);
161
- if (!scrollStatesAreEqual(clampedState, this._scrollState)) {
162
- this._scrollState = clampedState;
163
- this.setNeedsDisplay();
153
+ _setScrollState(proposedState: ScrollState) {
154
+ const height = this._contentView.frame.size.height;
155
+ const clampedState = clampState({
156
+ state: proposedState,
157
+ minContentLength: height,
158
+ maxContentLength: height,
159
+ containerLength: this.frame.size.height,
160
+ });
161
+ if (areScrollStatesEqual(clampedState, this._scrollState)) {
162
+ return;
163
}
165
- }
166
-
167
- /**
168
- * @private
169
- */
170
- _clampedProposedState(
171
- proposedState: VerticalScrollState,
172
- ): VerticalScrollState {
173
- return {
174
- offsetY: clamp(
175
- -(this._contentView.frame.size.height - this.frame.size.height),
176
- 0,
177
- proposedState.offsetY,
178
- ),
179
- };
164
+ this._scrollState = clampedState;
165
+ this.setNeedsDisplay();
166
}
167
}
packages/react-devtools-scheduling-profiler/src/view-base/constants.js
+1
@@ -11,3 +11,4 @@ export const MOVE_WHEEL_DELTA_THRESHOLD = 1;
11
export const ZOOM_WHEEL_DELTA_THRESHOLD = 1;
12
export const MIN_ZOOM_LEVEL = 0.25;
13
export const MAX_ZOOM_LEVEL = 1000;
14
+export const DEFAULT_ZOOM_LEVEL = 0.25;
packages/react-devtools-scheduling-profiler/src/view-base/utils/__tests__/clamp-test.js
new
+29
@@ -0,0 +1,29 @@
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 {clamp} from '../clamp';
11
+
12
+describe(clamp, () => {
13
+ it('should return min if value < min', () => {
14
+ expect(clamp(0, 1, -1)).toBe(0);
15
+ expect(clamp(0.1, 1.1, 0.05)).toBe(0.1);
16
+ });
17
+
18
+ it('should return value if min <= value <= max', () => {
19
+ expect(clamp(0, 1, 0)).toBe(0);
20
+ expect(clamp(0, 1, 0.5)).toBe(0.5);
21
+ expect(clamp(0, 1, 1)).toBe(1);
22
+ expect(clamp(0.1, 1.1, 0.15)).toBe(0.15);
23
+ });
24
+
25
+ it('should return max if max < value', () => {
26
+ expect(clamp(0, 1, 2)).toBe(1);
27
+ expect(clamp(0.1, 1.1, 1.15)).toBe(1.1);
28
+ });
29
+});
packages/react-devtools-scheduling-profiler/src/view-base/utils/__tests__/scrollState-test.js
new
+261
@@ -0,0 +1,261 @@
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 {
11
+ clampState,
12
+ moveStateToRange,
13
+ areScrollStatesEqual,
14
+ translateState,
15
+ zoomState,
16
+} from '../scrollState';
17
+
18
+describe(clampState, () => {
19
+ it('should passthrough offset if state fits within container', () => {
20
+ expect(
21
+ clampState({
22
+ state: {offset: 0, length: 50},
23
+ minContentLength: 0,
24
+ maxContentLength: 100,
25
+ containerLength: 50,
26
+ }).offset,
27
+ ).toBeCloseTo(0, 10);
28
+ expect(
29
+ clampState({
30
+ state: {offset: -20, length: 100},
31
+ minContentLength: 0,
32
+ maxContentLength: 100,
33
+ containerLength: 50,
34
+ }).offset,
35
+ ).toBeCloseTo(-20, 10);
36
+ });
37
+
38
+ it('should clamp offset if offset causes content to go out of container', () => {
39
+ expect(
40
+ clampState({
41
+ state: {offset: -1, length: 50},
42
+ minContentLength: 0,
43
+ maxContentLength: 100,
44
+ containerLength: 50,
45
+ }).offset,
46
+ ).toBeCloseTo(0, 10);
47
+ expect(
48
+ clampState({
49
+ state: {offset: 1, length: 50},
50
+ minContentLength: 0,
51
+ maxContentLength: 100,
52
+ containerLength: 50,
53
+ }).offset,
54
+ ).toBeCloseTo(0, 10);
55
+
56
+ expect(
57
+ clampState({
58
+ state: {offset: -51, length: 100},
59
+ minContentLength: 0,
60
+ maxContentLength: 100,
61
+ containerLength: 50,
62
+ }).offset,
63
+ ).toBeCloseTo(-50, 10);
64
+ expect(
65
+ clampState({
66
+ state: {offset: 1, length: 100},
67
+ minContentLength: 0,
68
+ maxContentLength: 100,
69
+ containerLength: 50,
70
+ }).offset,
71
+ ).toBeCloseTo(0, 10);
72
+ });
73
+
74
+ it('should passthrough length if container fits in content', () => {
75
+ expect(
76
+ clampState({
77
+ state: {offset: 0, length: 70},
78
+ minContentLength: 0,
79
+ maxContentLength: 100,
80
+ containerLength: 50,
81
+ }).length,
82
+ ).toBeCloseTo(70, 10);
83
+ expect(
84
+ clampState({
85
+ state: {offset: 0, length: 50},
86
+ minContentLength: 0,
87
+ maxContentLength: 100,
88
+ containerLength: 50,
89
+ }).length,
90
+ ).toBeCloseTo(50, 10);
91
+ expect(
92
+ clampState({
93
+ state: {offset: 0, length: 100},
94
+ minContentLength: 0,
95
+ maxContentLength: 100,
96
+ containerLength: 50,
97
+ }).length,
98
+ ).toBeCloseTo(100, 10);
99
+ });
100
+
101
+ it('should clamp length to minimum of max(minContentLength, containerLength)', () => {
102
+ expect(
103
+ clampState({
104
+ state: {offset: -20, length: 0},
105
+ minContentLength: 20,
106
+ maxContentLength: 100,
107
+ containerLength: 50,
108
+ }).length,
109
+ ).toBeCloseTo(50, 10);
110
+ expect(
111
+ clampState({
112
+ state: {offset: -20, length: 0},
113
+ minContentLength: 50,
114
+ maxContentLength: 100,
115
+ containerLength: 20,
116
+ }).length,
117
+ ).toBeCloseTo(50, 10);
118
+ });
119
+
120
+ it('should clamp length to maximum of max(containerLength, maxContentLength)', () => {
121
+ expect(
122
+ clampState({
123
+ state: {offset: -20, length: 100},
124
+ minContentLength: 0,
125
+ maxContentLength: 40,
126
+ containerLength: 50,
127
+ }).length,
128
+ ).toBeCloseTo(50, 10);
129
+ expect(
130
+ clampState({
131
+ state: {offset: -20, length: 100},
132
+ minContentLength: 0,
133
+ maxContentLength: 50,
134
+ containerLength: 40,
135
+ }).length,
136
+ ).toBeCloseTo(50, 10);
137
+ });
138
+});
139
+
140
+describe(translateState, () => {
141
+ it('should translate state by delta and leave length unchanged', () => {
142
+ expect(
143
+ translateState({
144
+ state: {offset: 0, length: 100},
145
+ delta: -3.14,
146
+ containerLength: 50,
147
+ }),
148
+ ).toEqual({offset: -3.14, length: 100});
149
+ });
150
+
151
+ it('should clamp resulting offset', () => {
152
+ expect(
153
+ translateState({
154
+ state: {offset: 0, length: 50},
155
+ delta: -3.14,
156
+ containerLength: 50,
157
+ }).offset,
158
+ ).toBeCloseTo(0, 10);
159
+ expect(
160
+ translateState({
161
+ state: {offset: 0, length: 53},
162
+ delta: -100,
163
+ containerLength: 50,
164
+ }).offset,
165
+ ).toBeCloseTo(-3, 10);
166
+ });
167
+});
168
+
169
+describe(zoomState, () => {
170
+ it('should scale width by multiplier', () => {
171
+ expect(
172
+ zoomState({
173
+ state: {offset: 0, length: 100},
174
+ multiplier: 1.5,
175
+ fixedPoint: 0,
176
+
177
+ minContentLength: 0,
178
+ maxContentLength: 1000,
179
+ containerLength: 50,
180
+ }),
181
+ ).toEqual({offset: 0, length: 150});
182
+ });
183
+
184
+ it('should clamp zoomed state', () => {
185
+ const zoomedState = zoomState({
186
+ state: {offset: -20, length: 100},
187
+ multiplier: 0.1,
188
+ fixedPoint: 5,
189
+
190
+ minContentLength: 50,
191
+ maxContentLength: 100,
192
+ containerLength: 50,
193
+ });
194
+ expect(zoomedState.offset).toBeCloseTo(0, 10);
195
+ expect(zoomedState.length).toBeCloseTo(50, 10);
196
+ });
197
+
198
+ it('should maintain containerStart<->fixedPoint distance', () => {
199
+ const offset = -20;
200
+ const fixedPointFromContainer = 10;
201
+
202
+ const zoomedState = zoomState({
203
+ state: {offset, length: 100},
204
+ multiplier: 2,
205
+ fixedPoint: fixedPointFromContainer - offset,
206
+
207
+ minContentLength: 0,
208
+ maxContentLength: 1000,
209
+ containerLength: 50,
210
+ });
211
+
212
+ expect(zoomedState).toMatchInlineSnapshot(`
213
+ Object {
214
+ "length": 200,
215
+ "offset": -50,
216
+ }
217
+ `);
218
+ });
219
+});
220
+
221
+describe(moveStateToRange, () => {
222
+ it('should set [rangeStart, rangeEnd] = container', () => {
223
+ const movedState = moveStateToRange({
224
+ state: {offset: -20, length: 100},
225
+ rangeStart: 50,
226
+ rangeEnd: 100,
227
+ contentLength: 400,
228
+
229
+ minContentLength: 10,
230
+ maxContentLength: 1000,
231
+ containerLength: 50,
232
+ });
233
+
234
+ expect(movedState).toMatchInlineSnapshot(`
235
+ Object {
236
+ "length": 400,
237
+ "offset": -50,
238
+ }
239
+ `);
240
+ });
241
+});
242
+
243
+describe(areScrollStatesEqual, () => {
244
+ it('should return true if equal', () => {
245
+ expect(
246
+ areScrollStatesEqual({offset: 0, length: 0}, {offset: 0, length: 0}),
247
+ ).toBe(true);
248
+ expect(
249
+ areScrollStatesEqual({offset: -1, length: 1}, {offset: -1, length: 1}),
250
+ ).toBe(true);
251
+ });
252
+
253
+ it('should return false if not equal', () => {
254
+ expect(
255
+ areScrollStatesEqual({offset: 0, length: 0}, {offset: -1, length: 0}),
256
+ ).toBe(false);
257
+ expect(
258
+ areScrollStatesEqual({offset: -1, length: 1}, {offset: -1, length: 0}),
259
+ ).toBe(false);
260
+ });
261
+});
packages/react-devtools-scheduling-profiler/src/view-base/utils/scrollState.js
new
+206
@@ -0,0 +1,206 @@
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 {clamp} from './clamp';
11
+
12
+/**
13
+ * Single-axis offset and length state.
14
+ *
15
+ * ```
16
+ * contentStart containerStart containerEnd contentEnd
17
+ * |<----------offset| | |
18
+ * |<-------------------length------------------->|
19
+ * ```
20
+ */
21
+export type ScrollState = $ReadOnly<{|
22
+ offset: number,
23
+ length: number,
24
+|}>;
25
+
26
+function clampOffset(state: ScrollState, containerLength: number): ScrollState {
27
+ return {
28
+ offset: clamp(-(state.length - containerLength), 0, state.offset),
29
+ length: state.length,
30
+ };
31
+}
32
+
33
+function clampLength({
34
+ state,
35
+ minContentLength,
36
+ maxContentLength,
37
+ containerLength,
38
+}: {
39
+ state: ScrollState,
40
+ minContentLength: number,
41
+ maxContentLength: number,
42
+ containerLength: number,
43
+}): ScrollState {
44
+ return {
45
+ offset: state.offset,
46
+ length: clamp(
47
+ Math.max(minContentLength, containerLength),
48
+ Math.max(containerLength, maxContentLength),
49
+ state.length,
50
+ ),
51
+ };
52
+}
53
+
54
+/**
55
+ * Returns `state` clamped such that:
56
+ * - `length`: you won't be able to zoom in/out such that the content is
57
+ * shorter than the `containerLength`.
58
+ * - `offset`: content remains in `containerLength`.
59
+ */
60
+export function clampState({
61
+ state,
62
+ minContentLength,
63
+ maxContentLength,
64
+ containerLength,
65
+}: {
66
+ state: ScrollState,
67
+ minContentLength: number,
68
+ maxContentLength: number,
69
+ containerLength: number,
70
+}): ScrollState {
71
+ return clampOffset(
72
+ clampLength({
73
+ state,
74
+ minContentLength,
75
+ maxContentLength,
76
+ containerLength,
77
+ }),
78
+ containerLength,
79
+ );
80
+}
81
+
82
+export function translateState({
83
+ state,
84
+ delta,
85
+ containerLength,
86
+}: {
87
+ state: ScrollState,
88
+ delta: number,
89
+ containerLength: number,
90
+}): ScrollState {
91
+ return clampOffset(
92
+ {
93
+ offset: state.offset + delta,
94
+ length: state.length,
95
+ },
96
+ containerLength,
97
+ );
98
+}
99
+
100
+/**
101
+ * Returns a new clamped `state` zoomed by `multiplier`.
102
+ *
103
+ * The provided fixed point will also remain stationary relative to
104
+ * `containerStart`.
105
+ *
106
+ * ```
107
+ * contentStart containerStart fixedPoint containerEnd
108
+ * |<---------offset-| x |
109
+ * |-fixedPoint-------------------------------->x |
110
+ * |-fixedPointFromContainer->x |
111
+ * |<----------containerLength----------->|
112
+ * ```
113
+ */
114
+export function zoomState({
115
+ state,
116
+ multiplier,
117
+ fixedPoint,
118
+
119
+ minContentLength,
120
+ maxContentLength,
121
+ containerLength,
122
+}: {
123
+ state: ScrollState,
124
+ multiplier: number,
125
+ fixedPoint: number,
126
+
127
+ minContentLength: number,
128
+ maxContentLength: number,
129
+ containerLength: number,
130
+}): ScrollState {
131
+ // Length and offset must be computed separately, so that if the length is
132
+ // clamped the offset will still be correct (unless it gets clamped too).
133
+
134
+ const zoomedState = clampLength({
135
+ state: {
136
+ offset: state.offset,
137
+ length: state.length * multiplier,
138
+ },
139
+ minContentLength,
140
+ maxContentLength,
141
+ containerLength,
142
+ });
143
+
144
+ // Adjust offset so that distance between containerStart<->fixedPoint is fixed
145
+ const fixedPointFromContainer = fixedPoint + state.offset;
146
+ const scaledFixedPoint = fixedPoint * (zoomedState.length / state.length);
147
+ const offsetAdjustedState = clampOffset(
148
+ {
149
+ offset: fixedPointFromContainer - scaledFixedPoint,
150
+ length: zoomedState.length,
151
+ },
152
+ containerLength,
153
+ );
154
+
155
+ return offsetAdjustedState;
156
+}
157
+
158
+export function moveStateToRange({
159
+ state,
160
+ rangeStart,
161
+ rangeEnd,
162
+ contentLength,
163
+
164
+ minContentLength,
165
+ maxContentLength,
166
+ containerLength,
167
+}: {
168
+ state: ScrollState,
169
+ rangeStart: number,
170
+ rangeEnd: number,
171
+ contentLength: number,
172
+
173
+ minContentLength: number,
174
+ maxContentLength: number,
175
+ containerLength: number,
176
+}): ScrollState {
177
+ // Length and offset must be computed separately, so that if the length is
178
+ // clamped the offset will still be correct (unless it gets clamped too).
179
+
180
+ const lengthClampedState = clampLength({
181
+ state: {
182
+ offset: state.offset,
183
+ length: contentLength * (containerLength / (rangeEnd - rangeStart)),
184
+ },
185
+ minContentLength,
186
+ maxContentLength,
187
+ containerLength,
188
+ });
189
+
190
+ const offsetAdjustedState = clampOffset(
191
+ {
192
+ offset: -rangeStart * (lengthClampedState.length / contentLength),
193
+ length: lengthClampedState.length,
194
+ },
195
+ containerLength,
196
+ );
197
+
198
+ return offsetAdjustedState;
199
+}
200
+
201
+export function areScrollStatesEqual(
202
+ state1: ScrollState,
203
+ state2: ScrollState,
204
+): boolean {
205
+ return state1.offset === state2.offset && state1.length === state2.length;
206
+}