Scheduling Profiler: Misc UX and performance improvements (#22043)

This commit is contained in:
Brian Vaughn
2021-08-09 17:11:33 -04:00
committed by GitHub
parent abd2323569
commit 43cd906694
12 changed files with 1077 additions and 624 deletions
+78 -48
View File
@@ -7,19 +7,18 @@
* @flow
*/
import type {
Point,
HorizontalPanAndZoomViewOnChangeCallback,
} from './view-base';
import type {Point} from './view-base';
import type {
ReactHoverContextInfo,
ReactProfilerData,
ReactMeasure,
ViewState,
} from './types';
import * as React from 'react';
import {
Fragment,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -54,12 +53,14 @@ import {
UserTimingMarksView,
} from './content-views';
import {COLORS} from './content-views/constants';
import {clampState, moveStateToRange} from './view-base/utils/scrollState';
import EventTooltip from './EventTooltip';
import {RegistryContext} from 'react-devtools-shared/src/devtools/ContextMenu/Contexts';
import ContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenu';
import ContextMenuItem from 'react-devtools-shared/src/devtools/ContextMenu/ContextMenuItem';
import useContextMenu from 'react-devtools-shared/src/devtools/ContextMenu/useContextMenu';
import {getBatchRange} from './utils/getBatchRange';
import {MAX_ZOOM_LEVEL, MIN_ZOOM_LEVEL} from './view-base/constants';
import styles from './CanvasPage.css';
@@ -67,16 +68,22 @@ const CONTEXT_MENU_ID = 'canvas';
type Props = {|
profilerData: ReactProfilerData,
viewState: ViewState,
|};
function CanvasPage({profilerData}: Props) {
function CanvasPage({profilerData, viewState}: Props) {
return (
<div
className={styles.CanvasPage}
style={{backgroundColor: COLORS.BACKGROUND}}>
<AutoSizer>
{({height, width}: {height: number, width: number}) => (
<AutoSizedCanvas data={profilerData} height={height} width={width} />
<AutoSizedCanvas
data={profilerData}
height={height}
viewState={viewState}
width={width}
/>
)}
</AutoSizer>
</div>
@@ -98,27 +105,43 @@ const copySummary = (data: ReactProfilerData, measure: ReactMeasure) => {
);
};
// TODO (scheduling profiler) Why is the "zoom" feature so much slower than normal rendering?
const zoomToBatch = (
data: ReactProfilerData,
measure: ReactMeasure,
syncedHorizontalPanAndZoomViews: HorizontalPanAndZoomView[],
viewState: ViewState,
width: number,
) => {
const {batchUID} = measure;
const [startTime, stopTime] = getBatchRange(batchUID, data);
syncedHorizontalPanAndZoomViews.forEach(syncedView =>
// Using time as range works because the views' intrinsic content size is based on time.
syncedView.zoomToRange(startTime, stopTime),
);
const [rangeStart, rangeEnd] = getBatchRange(batchUID, data);
// Convert from time range to ScrollState
const scrollState = moveStateToRange({
state: viewState.horizontalScrollState,
rangeStart,
rangeEnd,
contentLength: data.duration,
minContentLength: data.duration * MIN_ZOOM_LEVEL,
maxContentLength: data.duration * MAX_ZOOM_LEVEL,
containerLength: width,
});
viewState.updateHorizontalScrollState(scrollState);
};
type AutoSizedCanvasProps = {|
data: ReactProfilerData,
height: number,
viewState: ViewState,
width: number,
|};
function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
function AutoSizedCanvas({
data,
height,
viewState,
width,
}: AutoSizedCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [isContextMenuShown, setIsContextMenuShown] = useState<boolean>(false);
@@ -136,30 +159,31 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
const componentMeasuresViewRef = useRef(null);
const reactMeasuresViewRef = useRef(null);
const flamechartViewRef = useRef(null);
const syncedHorizontalPanAndZoomViewsRef = useRef<HorizontalPanAndZoomView[]>(
[],
);
const {hideMenu: hideContextMenu} = useContext(RegistryContext);
useLayoutEffect(() => {
const surface = surfaceRef.current;
const defaultFrame = {origin: zeroPoint, size: {width, height}};
// Clear synced views
syncedHorizontalPanAndZoomViewsRef.current = [];
// Auto hide context menu when panning.
viewState.onHorizontalScrollStateChange(scrollState => {
hideContextMenu();
});
const syncAllHorizontalPanAndZoomViewStates: HorizontalPanAndZoomViewOnChangeCallback = (
newState,
triggeringView?: HorizontalPanAndZoomView,
) => {
syncedHorizontalPanAndZoomViewsRef.current.forEach(
syncedView =>
triggeringView !== syncedView && syncedView.setScrollState(newState),
);
};
// Initialize horizontal view state
viewState.updateHorizontalScrollState(
clampState({
state: viewState.horizontalScrollState,
minContentLength: data.duration * MIN_ZOOM_LEVEL,
maxContentLength: data.duration * MAX_ZOOM_LEVEL,
containerLength: defaultFrame.size.width,
}),
);
function createViewHelper(
view: View,
resizeLabel: string = '',
label: string,
shouldScrollVertically: boolean = false,
shouldResizeVertically: boolean = false,
): View {
@@ -169,6 +193,8 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
surface,
defaultFrame,
view,
viewState,
label,
);
}
@@ -177,23 +203,22 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
defaultFrame,
verticalScrollView !== null ? verticalScrollView : view,
data.duration,
syncAllHorizontalPanAndZoomViewStates,
viewState,
);
syncedHorizontalPanAndZoomViewsRef.current.push(horizontalPanAndZoomView);
let viewToReturn = horizontalPanAndZoomView;
let resizableView = null;
if (shouldResizeVertically) {
viewToReturn = new ResizableView(
resizableView = new ResizableView(
surface,
defaultFrame,
horizontalPanAndZoomView,
viewState,
canvasRef,
resizeLabel,
label,
);
}
return viewToReturn;
return resizableView || horizontalPanAndZoomView;
}
const axisMarkersView = new TimeAxisMarkersView(
@@ -201,7 +226,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
defaultFrame,
data.duration,
);
const axisMarkersViewWrapper = createViewHelper(axisMarkersView);
const axisMarkersViewWrapper = createViewHelper(axisMarkersView, 'time');
let userTimingMarksViewWrapper = null;
if (data.otherUserTimingMarks.length > 0) {
@@ -212,7 +237,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data.duration,
);
userTimingMarksViewRef.current = userTimingMarksView;
userTimingMarksViewWrapper = createViewHelper(userTimingMarksView);
userTimingMarksViewWrapper = createViewHelper(
userTimingMarksView,
'user timing api',
);
}
const nativeEventsView = new NativeEventsView(surface, defaultFrame, data);
@@ -230,7 +258,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data,
);
schedulingEventsViewRef.current = schedulingEventsView;
const schedulingEventsViewWrapper = createViewHelper(schedulingEventsView);
const schedulingEventsViewWrapper = createViewHelper(
schedulingEventsView,
'react updates',
);
let suspenseEventsViewWrapper = null;
if (data.suspenseEvents.length > 0) {
@@ -256,7 +287,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
reactMeasuresViewRef.current = reactMeasuresView;
const reactMeasuresViewWrapper = createViewHelper(
reactMeasuresView,
'react',
'react scheduling',
true,
true,
);
@@ -269,7 +300,10 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
data,
);
componentMeasuresViewRef.current = componentMeasuresView;
componentMeasuresViewWrapper = createViewHelper(componentMeasuresView);
componentMeasuresViewWrapper = createViewHelper(
componentMeasuresView,
'react components',
);
}
const flamechartView = new FlamechartView(
@@ -329,7 +363,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
return;
}
// Wheel events should always hide the current toolltip.
// Wheel events should always hide the current tooltip.
switch (interaction.type) {
case 'wheel-control':
case 'wheel-meta':
@@ -617,11 +651,7 @@ function AutoSizedCanvas({data, height, width}: AutoSizedCanvasProps) {
{measure !== null && (
<ContextMenuItem
onClick={() =>
zoomToBatch(
contextData.data,
measure,
syncedHorizontalPanAndZoomViewsRef.current,
)
zoomToBatch(contextData.data, measure, viewState, width)
}
title="Zoom to batch">
Zoom to batch
@@ -8,6 +8,7 @@
*/
import type {DataResource} from './createDataResourceFromImportedFile';
import type {ViewState} from './types';
import * as React from 'react';
import {
@@ -27,9 +28,11 @@ import CanvasPage from './CanvasPage';
import styles from './SchedulingProfiler.css';
export function SchedulingProfiler(_: {||}) {
const {importSchedulingProfilerData, schedulingProfilerData} = useContext(
SchedulingProfilerContext,
);
const {
importSchedulingProfilerData,
schedulingProfilerData,
viewState,
} = useContext(SchedulingProfilerContext);
const ref = useRef(null);
@@ -66,6 +69,7 @@ export function SchedulingProfiler(_: {||}) {
dataResource={schedulingProfilerData}
key={key}
onFileSelect={importSchedulingProfilerData}
viewState={viewState}
/>
</Suspense>
) : (
@@ -130,9 +134,11 @@ const CouldNotLoadProfile = ({error, onFileSelect}) => (
const DataResourceComponent = ({
dataResource,
onFileSelect,
viewState,
}: {|
dataResource: DataResource,
onFileSelect: (file: File) => void,
viewState: ViewState,
|}) => {
const dataOrError = dataResource.read();
if (dataOrError instanceof Error) {
@@ -140,5 +146,5 @@ const DataResourceComponent = ({
<CouldNotLoadProfile error={dataOrError} onFileSelect={onFileSelect} />
);
}
return <CanvasPage profilerData={dataOrError} />;
return <CanvasPage profilerData={dataOrError} viewState={viewState} />;
};
@@ -11,12 +11,14 @@ import * as React from 'react';
import {createContext, useCallback, useMemo, useState} from 'react';
import createDataResourceFromImportedFile from './createDataResourceFromImportedFile';
import type {HorizontalScrollStateChangeCallback, ViewState} from './types';
import type {DataResource} from './createDataResourceFromImportedFile';
export type Context = {|
clearSchedulingProfilerData: () => void,
importSchedulingProfilerData: (file: File) => void,
schedulingProfilerData: DataResource | null,
viewState: ViewState,
|};
const SchedulingProfilerContext = createContext<Context>(
@@ -42,20 +44,51 @@ function SchedulingProfilerContextController({children}: Props) {
setSchedulingProfilerData(createDataResourceFromImportedFile(file));
}, []);
// TODO (scheduling profiler) Start/stop time ref here?
// Recreate view state any time new profiling data is imported.
const viewState = useMemo<ViewState>(() => {
const horizontalScrollStateChangeCallbacks: Set<HorizontalScrollStateChangeCallback> = new Set();
const horizontalScrollState = {
offset: 0,
length: 0,
};
return {
horizontalScrollState,
onHorizontalScrollStateChange: callback => {
horizontalScrollStateChangeCallbacks.add(callback);
},
updateHorizontalScrollState: scrollState => {
if (
horizontalScrollState.offset === scrollState.offset &&
horizontalScrollState.length === scrollState.length
) {
return;
}
horizontalScrollState.offset = scrollState.offset;
horizontalScrollState.length = scrollState.length;
horizontalScrollStateChangeCallbacks.forEach(callback => {
callback(scrollState);
});
},
viewToMutableViewStateMap: new Map(),
};
}, [schedulingProfilerData]);
const value = useMemo(
() => ({
clearSchedulingProfilerData,
importSchedulingProfilerData,
schedulingProfilerData,
// TODO (scheduling profiler)
viewState,
}),
[
clearSchedulingProfilerData,
importSchedulingProfilerData,
schedulingProfilerData,
// TODO (scheduling profiler)
viewState,
],
);
@@ -38,21 +38,12 @@ import {REACT_TOTAL_NUM_LANES} from '../constants';
const REACT_LANE_HEIGHT = REACT_MEASURE_HEIGHT + BORDER_SIZE;
const MAX_ROWS_TO_SHOW_INITIALLY = 5;
function getMeasuresForLane(
allMeasures: ReactMeasure[],
lane: ReactLane,
): ReactMeasure[] {
return allMeasures.filter(measure => measure.lanes.includes(lane));
}
export class ReactMeasuresView extends View {
_profilerData: ReactProfilerData;
_intrinsicSize: IntrinsicSize;
_lanesToRender: ReactLane[];
_laneToMeasures: Map<ReactLane, ReactMeasure[]>;
_profilerData: ReactProfilerData;
_hoveredMeasure: ReactMeasure | null = null;
onHover: ((measure: ReactMeasure | null) => void) | null = null;
constructor(surface: Surface, frame: Rect, profilerData: ReactProfilerData) {
@@ -63,17 +54,14 @@ export class ReactMeasuresView extends View {
_performPreflightComputations() {
this._lanesToRender = [];
this._laneToMeasures = new Map();
for (let lane: ReactLane = 0; lane < REACT_TOTAL_NUM_LANES; lane++) {
const measuresForLane = getMeasuresForLane(
this._profilerData.measures,
const measuresForLane = this._profilerData.laneToReactMeasureMap.get(
lane,
);
// Only show lanes with measures
if (measuresForLane.length) {
if (measuresForLane != null && measuresForLane.length > 0) {
this._lanesToRender.push(lane);
this._laneToMeasures.set(lane, measuresForLane);
}
}
@@ -213,7 +201,7 @@ export class ReactMeasuresView extends View {
frame,
_hoveredMeasure,
_lanesToRender,
_laneToMeasures,
_profilerData,
visibleArea,
} = this;
@@ -233,7 +221,7 @@ export class ReactMeasuresView extends View {
for (let i = 0; i < _lanesToRender.length; i++) {
const lane = _lanesToRender[i];
const baseY = frame.origin.y + i * REACT_LANE_HEIGHT;
const measuresForLane = _laneToMeasures.get(lane);
const measuresForLane = _profilerData.laneToReactMeasureMap.get(lane);
if (!measuresForLane) {
throw new Error(
@@ -242,7 +230,7 @@ export class ReactMeasuresView extends View {
}
// Render lane labels
const label = this._profilerData.laneToLabelMap.get(lane);
const label = _profilerData.laneToLabelMap.get(lane);
if (label == null) {
console.warn(`Could not find label for lane ${lane}.`);
} else {
@@ -316,8 +304,8 @@ export class ReactMeasuresView extends View {
frame,
_intrinsicSize,
_lanesToRender,
_laneToMeasures,
onHover,
_profilerData,
visibleArea,
} = this;
if (!onHover) {
@@ -347,7 +335,7 @@ export class ReactMeasuresView extends View {
// This will always be the one on "top" (the one the user is hovering over).
const scaleFactor = positioningScaleFactor(_intrinsicSize.width, frame);
const hoverTimestamp = positionToTimestamp(location.x, scaleFactor, frame);
const measures = _laneToMeasures.get(lane);
const measures = _profilerData.laneToReactMeasureMap.get(lane);
if (!measures) {
onHover(null);
return;
@@ -13,13 +13,14 @@ import {
} from '@elg/speedscope';
import type {TimelineEvent} from '@elg/speedscope';
import type {
Milliseconds,
BatchUID,
Flamechart,
Milliseconds,
NativeEvent,
Phase,
ReactLane,
ReactComponentMeasure,
ReactMeasure,
ReactMeasureType,
ReactProfilerData,
SchedulingEvent,
@@ -33,7 +34,7 @@ import {getBatchRange} from '../utils/getBatchRange';
type MeasureStackElement = {|
type: ReactMeasureType,
depth: number,
index: number,
measure: ReactMeasure,
startTime: Milliseconds,
stopTime?: Milliseconds,
|};
@@ -92,17 +93,19 @@ export function getLanesFromTransportDecimalBitmask(
return lanes;
}
const laneToLabelMap: Map<number, string> = new Map();
function updateLaneToLabelMap(laneLabelTuplesString: string): void {
function updateLaneToLabelMap(
profilerData: ReactProfilerData,
laneLabelTuplesString: string,
): void {
// These marks appear multiple times in the data;
// We only need to extact them once.
if (laneToLabelMap.size === 0) {
if (profilerData.laneToLabelMap.size === 0) {
const laneLabelTuples = laneLabelTuplesString.split(',');
for (let laneIndex = 0; laneIndex < laneLabelTuples.length; laneIndex++) {
// The numeric lane value (e.g. 64) isn't important.
// The profiler parses and stores the lane's position within the bitmap,
// (e.g. lane 1 is index 0, lane 16 is index 4).
laneToLabelMap.set(laneIndex, laneLabelTuples[laneIndex]);
profilerData.laneToLabelMap.set(laneIndex, laneLabelTuples[laneIndex]);
}
}
}
@@ -133,18 +136,32 @@ function markWorkStarted(
state: ProcessorState,
) {
const {batchUID, measureStack} = state;
const index = currentProfilerData.measures.length;
const depth = getDepth(measureStack);
state.measureStack.push({depth, index, startTime, type});
currentProfilerData.measures.push({
const measure: ReactMeasure = {
type,
batchUID,
depth,
lanes,
timestamp: startTime,
duration: 0,
};
state.measureStack.push({depth, measure, startTime, type});
// This array is pre-initialized when the batchUID is generated.
const measures = currentProfilerData.batchUIDToMeasuresMap.get(batchUID);
if (measures != null) {
measures.push(measure);
} else {
currentProfilerData.batchUIDToMeasuresMap.set(state.batchUID, [measure]);
}
// This array is pre-initialized before processing starts.
lanes.forEach(lane => {
((currentProfilerData.laneToReactMeasureMap.get(
lane,
): any): ReactMeasure[]).push(measure);
});
}
@@ -174,8 +191,7 @@ function markWorkCompleted(
);
}
const {index, startTime} = stack.pop();
const measure = currentProfilerData.measures[index];
const {measure, startTime} = stack.pop();
if (!measure) {
console.error('Could not find matching measure for type "%s".', type);
}
@@ -282,7 +298,7 @@ function processTimelineEvent(
}
} else if (name.startsWith('--react-lane-labels-')) {
const [laneLabelTuplesString] = name.substr(20).split('-');
updateLaneToLabelMap(laneLabelTuplesString);
updateLaneToLabelMap(currentProfilerData, laneLabelTuplesString);
} else if (name.startsWith('--component-render-start-')) {
const [componentName] = name.substr(25).split('-');
@@ -648,12 +664,18 @@ export default function preprocessData(
): ReactProfilerData {
const flamechart = preprocessFlamechart(timeline);
const laneToReactMeasureMap = new Map();
for (let lane: ReactLane = 0; lane < REACT_TOTAL_NUM_LANES; lane++) {
laneToReactMeasureMap.set(lane, []);
}
const profilerData: ReactProfilerData = {
batchUIDToMeasuresMap: new Map(),
componentMeasures: [],
duration: 0,
flamechart,
laneToLabelMap,
measures: [],
laneToLabelMap: new Map(),
laneToReactMeasureMap,
nativeEvents: [],
otherUserTimingMarks: [],
reactVersion: null,
@@ -739,7 +761,11 @@ export default function preprocessData(
state.potentialSuspenseEventsOutsideOfTransition.forEach(
([suspenseEvent, lanes]) => {
// HACK This is a bit gross but the numeric lane value might change between render versions.
if (!lanes.some(lane => laneToLabelMap.get(lane) === 'Transition')) {
if (
!lanes.some(
lane => profilerData.laneToLabelMap.get(lane) === 'Transition',
)
) {
suspenseEvent.warning = WARNING_STRINGS.SUSPEND_DURING_UPATE;
}
},
+19 -2
View File
@@ -7,7 +7,7 @@
* @flow
*/
// Type utilities
import type {ScrollState} from './view-base/utils/scrollState';
// Source: https://github.com/facebook/flow/issues/4002#issuecomment-323612798
// eslint-disable-next-line no-unused-vars
@@ -123,12 +123,29 @@ export type FlamechartStackLayer = FlamechartStackFrame[];
export type Flamechart = FlamechartStackLayer[];
export type HorizontalScrollStateChangeCallback = (
scrollState: ScrollState,
) => void;
// Imperative view state that corresponds to profiler data.
// This state lives outside of React's lifecycle
// and should be erased/reset whenever new profiler data is loaded.
export type ViewState = {|
horizontalScrollState: ScrollState,
onHorizontalScrollStateChange: (
callback: HorizontalScrollStateChangeCallback,
) => void,
updateHorizontalScrollState: (scrollState: ScrollState) => void,
viewToMutableViewStateMap: Map<string, mixed>,
|};
export type ReactProfilerData = {|
batchUIDToMeasuresMap: Map<BatchUID, ReactMeasure[]>,
componentMeasures: ReactComponentMeasure[],
duration: number,
flamechart: Flamechart,
laneToLabelMap: Map<ReactLane, string>,
measures: ReactMeasure[],
laneToReactMeasureMap: Map<ReactLane, ReactMeasure[]>,
nativeEvents: NativeEvent[],
otherUserTimingMarks: UserTimingMark[],
reactVersion: string | null,
@@ -9,37 +9,35 @@
import memoize from 'memoize-one';
import type {BatchUID, Milliseconds, ReactProfilerData} from '../types';
import type {
BatchUID,
Milliseconds,
ReactMeasure,
ReactProfilerData,
} from '../types';
function unmemoizedGetBatchRange(
batchUID: BatchUID,
data: ReactProfilerData,
minStartTime?: ?number,
minStartTime?: number = 0,
): [Milliseconds, Milliseconds] {
const {measures} = data;
let startTime = 0;
let stopTime = Infinity;
let i = 0;
// Find the first measure in the current batch.
for (i; i < measures.length; i++) {
const measure = measures[i];
if (measure.batchUID === batchUID) {
if (minStartTime == null || measure.timestamp >= minStartTime) {
startTime = measure.timestamp;
break;
}
}
const measures = data.batchUIDToMeasuresMap.get(batchUID);
if (measures == null || measures.length === 0) {
throw Error(`Could not find measures with batch UID "${batchUID}"`);
}
// Find the last measure in the current batch.
for (i; i < measures.length; i++) {
const measure = measures[i];
if (measure.batchUID === batchUID) {
stopTime = measure.timestamp;
} else {
const lastMeasure = ((measures[measures.length - 1]: any): ReactMeasure);
const stopTime = lastMeasure.timestamp + lastMeasure.duration;
if (stopTime < minStartTime) {
return [0, 0];
}
let startTime = minStartTime;
for (let index = 0; index < measures.length; index++) {
const measure = measures[index];
if (measure.timestamp >= minStartTime) {
startTime = measure.timestamp;
break;
}
}
@@ -18,6 +18,7 @@ import type {
import type {Rect} from './geometry';
import type {ScrollState} from './utils/scrollState';
import type {ViewRefs} from './Surface';
import type {ViewState} from '../types';
import {Surface} from './Surface';
import {View} from './View';
@@ -30,96 +31,84 @@ import {
zoomState,
} from './utils/scrollState';
import {
DEFAULT_ZOOM_LEVEL,
MAX_ZOOM_LEVEL,
MIN_ZOOM_LEVEL,
MOVE_WHEEL_DELTA_THRESHOLD,
} from './constants';
export type HorizontalPanAndZoomViewOnChangeCallback = (
state: ScrollState,
view: HorizontalPanAndZoomView,
) => void;
export class HorizontalPanAndZoomView extends View {
_contentView: View;
_intrinsicContentWidth: number;
_isPanning = false;
_scrollState: ScrollState = {offset: 0, length: 0};
_onStateChange: HorizontalPanAndZoomViewOnChangeCallback = () => {};
_viewState: ViewState;
constructor(
surface: Surface,
frame: Rect,
contentView: View,
intrinsicContentWidth: number,
onStateChange?: HorizontalPanAndZoomViewOnChangeCallback,
viewState: ViewState,
) {
super(surface, frame);
this.addSubview(contentView);
this._contentView = contentView;
this._intrinsicContentWidth = intrinsicContentWidth;
this._setScrollState({
offset: 0,
length: intrinsicContentWidth * DEFAULT_ZOOM_LEVEL,
this._viewState = viewState;
viewState.onHorizontalScrollStateChange(scrollState => {
this.zoomToRange(scrollState.offset, scrollState.length);
});
if (onStateChange) this._onStateChange = onStateChange;
}
setFrame(newFrame: Rect) {
super.setFrame(newFrame);
// Revalidate scrollState
this._setStateAndInformCallbacksIfChanged(this._scrollState);
}
setScrollState(proposedState: ScrollState) {
this._setScrollState(proposedState);
this.addSubview(contentView);
}
/**
* Just sets scroll state. Use `_setStateAndInformCallbacksIfChanged` if this
* view's callbacks should also be called.
* Just sets scroll state.
* Use `_setStateAndInformCallbacksIfChanged` if this view's callbacks should also be called.
*
* @returns Whether state was changed
* @private
*/
_setScrollState(proposedState: ScrollState): boolean {
setScrollState(proposedState: ScrollState) {
const clampedState = clampState({
state: proposedState,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
if (areScrollStatesEqual(clampedState, this._scrollState)) {
return false;
if (
!areScrollStatesEqual(clampedState, this._viewState.horizontalScrollState)
) {
this.setNeedsDisplay();
}
this._scrollState = clampedState;
this.setNeedsDisplay();
return true;
}
/**
* @private
* Zoom to a specific range of the content specified as a range of the
* content view's intrinsic content size.
*
* Does not inform callbacks of state change since this is a public API.
*/
_setStateAndInformCallbacksIfChanged(proposedState: ScrollState) {
if (this._setScrollState(proposedState)) {
this._onStateChange(this._scrollState, this);
}
zoomToRange(rangeStart: number, rangeEnd: number) {
const newState = moveStateToRange({
state: this._viewState.horizontalScrollState,
rangeStart,
rangeEnd,
contentLength: this._intrinsicContentWidth,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
this.setScrollState(newState);
}
desiredSize() {
return this._contentView.desiredSize();
}
/**
* Reference to the content view. This view is also the only view in
* `this.subviews`.
*/
get _contentView() {
return this.subviews[0];
}
layoutSubviews() {
const {offset, length} = this._scrollState;
const {offset, length} = this._viewState.horizontalScrollState;
const proposedFrame = {
origin: {
x: this.frame.origin.x + offset,
@@ -134,24 +123,22 @@ export class HorizontalPanAndZoomView extends View {
super.layoutSubviews();
}
/**
* Zoom to a specific range of the content specified as a range of the
* content view's intrinsic content size.
*
* Does not inform callbacks of state change since this is a public API.
*/
zoomToRange(rangeStart: number, rangeEnd: number) {
const newState = moveStateToRange({
state: this._scrollState,
rangeStart,
rangeEnd,
contentLength: this._intrinsicContentWidth,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
this._setScrollState(newState);
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction, viewRefs);
break;
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
case 'mouseup':
this._handleMouseUp(interaction, viewRefs);
break;
case 'wheel-plain':
case 'wheel-shift':
this._handleWheel(interaction);
break;
}
}
_handleMouseDown(interaction: MouseDownInteraction, viewRefs: ViewRefs) {
@@ -183,11 +170,11 @@ export class HorizontalPanAndZoomView extends View {
return;
}
const newState = translateState({
state: this._scrollState,
state: this._viewState.horizontalScrollState,
delta: interaction.payload.event.movementX,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
this._viewState.updateHorizontalScrollState(newState);
}
_handleMouseUp(interaction: MouseUpInteraction, viewRefs: ViewRefs) {
@@ -226,44 +213,26 @@ export class HorizontalPanAndZoomView extends View {
}
const newState = zoomState({
state: this._scrollState,
state: this._viewState.horizontalScrollState,
multiplier: 1 + 0.005 * -deltaY,
fixedPoint: location.x - this._scrollState.offset,
fixedPoint: location.x - this._viewState.horizontalScrollState.offset,
minContentLength: this._intrinsicContentWidth * MIN_ZOOM_LEVEL,
maxContentLength: this._intrinsicContentWidth * MAX_ZOOM_LEVEL,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
this._viewState.updateHorizontalScrollState(newState);
} else {
if (absDeltaX < MOVE_WHEEL_DELTA_THRESHOLD) {
return;
}
const newState = translateState({
state: this._scrollState,
state: this._viewState.horizontalScrollState,
delta: -deltaX,
containerLength: this.frame.size.width,
});
this._setStateAndInformCallbacksIfChanged(newState);
}
}
handleInteraction(interaction: Interaction, viewRefs: ViewRefs) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction, viewRefs);
break;
case 'mousemove':
this._handleMouseMove(interaction, viewRefs);
break;
case 'mouseup':
this._handleMouseUp(interaction, viewRefs);
break;
case 'wheel-plain':
case 'wheel-shift':
this._handleWheel(interaction);
break;
this._viewState.updateHorizontalScrollState(newState);
}
}
}
@@ -17,6 +17,7 @@ import type {
} from './useCanvasInteraction';
import type {Rect} from './geometry';
import type {ViewRefs} from './Surface';
import type {ViewState} from '../types';
import {BORDER_SIZE, COLORS} from '../content-views/constants';
import {drawText} from '../content-views/utils/text';
@@ -35,10 +36,10 @@ type ResizingState = $ReadOnly<{|
mouseY: number,
|}>;
type LayoutState = $ReadOnly<{|
type LayoutState = {|
/** Resize bar's vertical position relative to resize view's frame.origin.y */
barOffsetY: number,
|}>;
|};
const RESIZE_BAR_DOT_RADIUS = 1;
const RESIZE_BAR_DOT_SPACING = 4;
@@ -217,36 +218,33 @@ class ResizeBar extends View {
export class ResizableView extends View {
_canvasRef: {current: HTMLCanvasElement | null};
_layoutState: LayoutState;
_mutableViewStateKey: string;
_resizeBar: ResizeBar;
_resizingState: ResizingState | null = null;
_subview: View;
_viewState: ViewState;
constructor(
surface: Surface,
frame: Rect,
subview: View,
viewState: ViewState,
canvasRef: {current: HTMLCanvasElement | null},
label: string,
) {
super(surface, frame, noopLayout);
this._canvasRef = canvasRef;
this._layoutState = {barOffsetY: 0};
this._mutableViewStateKey = label + ':ResizableView';
this._subview = subview;
this._resizeBar = new ResizeBar(surface, frame, label);
this._viewState = viewState;
this.addSubview(this._subview);
this.addSubview(this._resizeBar);
const subviewDesiredSize = subview.desiredSize();
this._updateLayoutStateAndResizeBar(
subviewDesiredSize.maxInitialHeight != null
? Math.min(
subviewDesiredSize.maxInitialHeight,
subviewDesiredSize.height,
)
: subviewDesiredSize.height,
);
this._restoreMutableViewState();
}
desiredSize() {
@@ -274,6 +272,35 @@ export class ResizableView extends View {
super.layoutSubviews();
}
_restoreMutableViewState() {
if (
this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey)
) {
this._layoutState = ((this._viewState.viewToMutableViewStateMap.get(
this._mutableViewStateKey,
): any): LayoutState);
this._updateLayoutStateAndResizeBar(this._layoutState.barOffsetY);
} else {
this._viewState.viewToMutableViewStateMap.set(
this._mutableViewStateKey,
this._layoutState,
);
const subviewDesiredSize = this._subview.desiredSize();
this._updateLayoutStateAndResizeBar(
subviewDesiredSize.maxInitialHeight != null
? Math.min(
subviewDesiredSize.maxInitialHeight,
subviewDesiredSize.height,
)
: subviewDesiredSize.height,
);
}
this.setNeedsDisplay();
}
_shouldRenderResizeBar() {
const subviewDesiredSize = this._subview.desiredSize();
return subviewDesiredSize.hideScrollBarIfLessThanHeight != null
@@ -287,10 +314,7 @@ export class ResizableView extends View {
barOffsetY = 0;
}
this._layoutState = {
...this._layoutState,
barOffsetY,
};
this._layoutState.barOffsetY = barOffsetY;
this._resizeBar.showLabel = barOffsetY === 0;
}
@@ -341,6 +365,10 @@ export class ResizableView extends View {
}
_handleClick(interaction: ClickInteraction) {
if (!this._shouldRenderResizeBar()) {
return;
}
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
@@ -356,6 +384,10 @@ export class ResizableView extends View {
}
_handleDoubleClick(interaction: DoubleClickInteraction) {
if (!this._shouldRenderResizeBar()) {
return;
}
const cursorInView = rectContainsPoint(
interaction.payload.location,
this.frame,
@@ -17,6 +17,7 @@ import type {
import type {Rect} from './geometry';
import type {ScrollState} from './utils/scrollState';
import type {ViewRefs} from './Surface';
import type {ViewState} from '../types';
import {Surface} from './Surface';
import {View} from './View';
@@ -34,13 +35,33 @@ const CARET_WIDTH = 5;
const CARET_HEIGHT = 3;
export class VerticalScrollView extends View {
_scrollState: ScrollState = {offset: 0, length: 0};
_isPanning = false;
_contentView: View;
_isPanning: boolean;
_mutableViewStateKey: string;
_scrollState: ScrollState;
_viewState: ViewState;
constructor(surface: Surface, frame: Rect, contentView: View) {
constructor(
surface: Surface,
frame: Rect,
contentView: View,
viewState: ViewState,
label: string,
) {
super(surface, frame);
this._contentView = contentView;
this._isPanning = false;
this._mutableViewStateKey = label + ':VerticalScrollView';
this._scrollState = {
offset: 0,
length: 0,
};
this._viewState = viewState;
this.addSubview(contentView);
this._setScrollState(this._scrollState);
this._restoreMutableViewState();
}
setFrame(newFrame: Rect) {
@@ -102,14 +123,6 @@ export class VerticalScrollView extends View {
}
}
/**
* Reference to the content view. This view is also the only view in
* `this.subviews`.
*/
get _contentView() {
return this.subviews[0];
}
layoutSubviews() {
const {offset} = this._scrollState;
const desiredSize = this._contentView.desiredSize();
@@ -133,6 +146,23 @@ export class VerticalScrollView extends View {
super.layoutSubviews();
}
handleInteraction(interaction: Interaction) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction);
break;
case 'mousemove':
this._handleMouseMove(interaction);
break;
case 'mouseup':
this._handleMouseUp(interaction);
break;
case 'wheel-shift':
this._handleWheelShift(interaction);
break;
}
}
_handleMouseDown(interaction: MouseDownInteraction) {
if (rectContainsPoint(interaction.payload.location, this.frame)) {
this._isPanning = true;
@@ -184,21 +214,21 @@ export class VerticalScrollView extends View {
this._setScrollState(newState);
}
handleInteraction(interaction: Interaction) {
switch (interaction.type) {
case 'mousedown':
this._handleMouseDown(interaction);
break;
case 'mousemove':
this._handleMouseMove(interaction);
break;
case 'mouseup':
this._handleMouseUp(interaction);
break;
case 'wheel-shift':
this._handleWheelShift(interaction);
break;
_restoreMutableViewState() {
if (
this._viewState.viewToMutableViewStateMap.has(this._mutableViewStateKey)
) {
this._scrollState = ((this._viewState.viewToMutableViewStateMap.get(
this._mutableViewStateKey,
): any): ScrollState);
} else {
this._viewState.viewToMutableViewStateMap.set(
this._mutableViewStateKey,
this._scrollState,
);
}
this.setNeedsDisplay();
}
/**
@@ -212,10 +242,11 @@ export class VerticalScrollView extends View {
maxContentLength: height,
containerLength: this.frame.size.height,
});
if (areScrollStatesEqual(clampedState, this._scrollState)) {
return;
if (!areScrollStatesEqual(clampedState, this._scrollState)) {
this._scrollState.offset = clampedState.offset;
this._scrollState.length = clampedState.length;
this.setNeedsDisplay();
}
this._scrollState = clampedState;
this.setNeedsDisplay();
}
}
@@ -18,10 +18,10 @@ import {clamp} from './clamp';
* |<-------------------length------------------->|
* ```
*/
export type ScrollState = $ReadOnly<{|
export type ScrollState = {|
offset: number,
length: number,
|}>;
|};
function clampOffset(state: ScrollState, containerLength: number): ScrollState {
return {