@samitouri / QOS-React / commits / e9454894ff

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 +}