feat: splitting logic, memoization

This commit is contained in:
Arnošt Pleskot 2023-07-14 20:46:48 +02:00
parent 71eb3023b2
commit 132750f753
No known key found for this signature in database
3 changed files with 300 additions and 161 deletions

View File

@ -223,6 +223,7 @@ import {
FrameNameBoundsCache, FrameNameBoundsCache,
SidebarName, SidebarName,
SidebarTabName, SidebarTabName,
ScrollConstraints,
NormalizedZoomValue, NormalizedZoomValue,
} from "../types"; } from "../types";
import { import {
@ -251,6 +252,7 @@ import {
easeToValuesRAF, easeToValuesRAF,
muteFSAbortError, muteFSAbortError,
easeOut, easeOut,
isShallowEqual,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@ -457,6 +459,13 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDown: React.PointerEvent<HTMLElement> | null = null; lastPointerDown: React.PointerEvent<HTMLElement> | null = null;
lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null; lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
private memoizedScrollConstraints: {
input: {
scrollConstraints: AppState["scrollConstraints"];
values: Omit<Partial<AppState>, "zoom"> & { zoom: NormalizedZoomValue };
};
result: ReturnType<App["calculateConstraints"]>;
} | null = null;
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
@ -1632,9 +1641,74 @@ class App extends React.Component<AppProps, AppState> {
); );
} }
const constraintedScroll = this.constrainScroll(prevState); let constraintedScrollState;
if (
this.state.scrollConstraints &&
!this.state.scrollConstraints.isAnimating
) {
const {
scrollX,
scrollY,
width,
height,
scrollConstraints,
zoom,
cursorButton,
} = this.state;
this.renderScene(constraintedScroll); // TODO: this could be replaced with memoization function like _.memoize()
const calculatedConstraints =
isShallowEqual(
scrollConstraints,
this.memoizedScrollConstraints?.input.scrollConstraints ?? {},
) &&
isShallowEqual(
{ width, height, zoom: zoom.value, cursorButton },
this.memoizedScrollConstraints?.input.values ?? {},
) &&
this.memoizedScrollConstraints
? this.memoizedScrollConstraints.result
: this.calculateConstraints({
scrollConstraints,
width,
height,
zoom,
cursorButton,
});
this.memoizedScrollConstraints = {
input: {
scrollConstraints,
values: { width, height, zoom: zoom.value, cursorButton },
},
result: calculatedConstraints,
};
const constrainedScrollValues = this.constrainScrollValues({
...calculatedConstraints,
scrollX,
scrollY,
});
const isViewportOutsideOfConstrainedArea =
this.isViewportOutsideOfConstrainedArea({
scrollX,
scrollY,
width,
height,
scrollConstraints,
});
constraintedScrollState = this.handleConstrainedScrollStateChange({
...constrainedScrollValues,
shouldAnimate:
isViewportOutsideOfConstrainedArea &&
this.state.cursorButton !== "down" &&
prevState.zoom.value === this.state.zoom.value,
});
}
this.renderScene(constraintedScrollState);
this.history.record(this.state, this.scene.getElementsIncludingDeleted()); this.history.record(this.state, this.scene.getElementsIncludingDeleted());
// Do not notify consumers if we're still loading the scene. Among other // Do not notify consumers if we're still loading the scene. Among other
@ -7629,55 +7703,59 @@ class App extends React.Component<AppProps, AppState> {
* @param scrollConstraints - The new scroll constraints. * @param scrollConstraints - The new scroll constraints.
*/ */
public setScrollConstraints = ( public setScrollConstraints = (
scrollConstraints: AppState["scrollConstraints"], scrollConstraints: ScrollConstraints | null,
) => { ) => {
this.setState({ if (scrollConstraints) {
const { scrollX, scrollY, width, height, zoom, cursorButton } =
this.state;
const constrainedScrollValues = this.constrainScrollValues({
...this.calculateConstraints({
scrollConstraints, scrollConstraints,
viewModeEnabled: !!scrollConstraints, zoom,
}); cursorButton,
}; width,
height,
/** }),
* Constrains the scroll position of the app state within the defined scroll constraints.
* The scroll position is adjusted based on the application's current state including zoom level and viewport dimensions.
*
* @param nextState - The next state of the application, or a subset of the application state.
* @returns The modified next state with scrollX and scrollY constrained to the scroll constraints.
*/
private constrainScroll = (prevState: AppState): ConstrainedScrollValues => {
const {
scrollX, scrollX,
scrollY, scrollY,
});
this.animateConstainedScroll({
...constrainedScrollValues,
opts: {
onEndCallback: () => {
this.setState({
scrollConstraints,
viewModeEnabled: true,
});
},
},
});
} else {
this.setState({
scrollConstraints: null,
viewModeEnabled: false,
});
}
};
private calculateConstraints = ({
scrollConstraints, scrollConstraints,
width, width,
height, height,
zoom: currentZoom, zoom,
cursorButton, cursorButton,
} = this.state; }: {
scrollConstraints: ScrollConstraints;
if (!scrollConstraints || scrollConstraints.isAnimating) { width: AppState["width"];
return null; height: AppState["height"];
} zoom: AppState["zoom"];
cursorButton: AppState["cursorButton"];
// Check if the state has changed since the last render }) => {
const stateUnchanged =
currentZoom.value === prevState.zoom.value &&
scrollX === prevState.scrollX &&
scrollY === prevState.scrollY &&
width === prevState.width &&
height === prevState.height &&
cursorButton === prevState.cursorButton;
// If the state hasn't changed and scrollConstraints didn't just get defined, return null
if (stateUnchanged && prevState.scrollConstraints) {
return null;
}
// Set the overscroll allowance percentage // Set the overscroll allowance percentage
const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2;
const lockZoom = scrollConstraints.opts?.lockZoom ?? false; const lockZoom = scrollConstraints.lockZoom ?? false;
const viewportZoomFactor = scrollConstraints.opts?.viewportZoomFactor const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? Math.min(1, Math.max(scrollConstraints.opts.viewportZoomFactor, 0.1)) ? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1))
: 0.9; : 0.9;
/** /**
@ -7776,6 +7854,37 @@ class App extends React.Component<AppProps, AppState> {
return { maxScrollX, minScrollX, maxScrollY, minScrollY }; return { maxScrollX, minScrollX, maxScrollY, minScrollY };
}; };
const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel();
const constrainedZoom = getNormalizedZoom(
maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value,
);
const { constrainedScrollCenterX, constrainedScrollCenterY } =
calculateConstrainedScrollCenter(constrainedZoom);
const { overscrollAllowanceX, overscrollAllowanceY } =
calculateOverscrollAllowance();
const shouldAdjustForCenteredView =
constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY;
const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
calculateMinMaxScrollValues(
shouldAdjustForCenteredView,
overscrollAllowanceX,
overscrollAllowanceY,
constrainedScrollCenterX,
constrainedScrollCenterY,
constrainedZoom,
);
return {
maxScrollX,
minScrollX,
maxScrollY,
minScrollY,
constrainedZoom: {
value: constrainedZoom,
},
};
};
/** /**
* Constrains the scroll values within the constrained area. * Constrains the scroll values within the constrained area.
* @param maxScrollX - The maximum scroll value for the X axis. * @param maxScrollX - The maximum scroll value for the X axis.
@ -7784,12 +7893,23 @@ class App extends React.Component<AppProps, AppState> {
* @param minScrollY - The minimum scroll value for the Y axis. * @param minScrollY - The minimum scroll value for the Y axis.
* @returns The constrained scroll values for the X and Y axes. * @returns The constrained scroll values for the X and Y axes.
*/ */
const constrainScrollValues = ( private constrainScrollValues = ({
maxScrollX: number, scrollX,
minScrollX: number, scrollY,
maxScrollY: number, maxScrollX,
minScrollY: number, minScrollX,
) => { maxScrollY,
minScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
maxScrollX: number;
minScrollX: number;
maxScrollY: number;
minScrollY: number;
constrainedZoom: AppState["zoom"];
}) => {
const constrainedScrollX = Math.min( const constrainedScrollX = Math.min(
maxScrollX, maxScrollX,
Math.max(scrollX, minScrollX), Math.max(scrollX, minScrollX),
@ -7798,33 +7918,32 @@ class App extends React.Component<AppProps, AppState> {
maxScrollY, maxScrollY,
Math.max(scrollY, minScrollY), Math.max(scrollY, minScrollY),
); );
return { constrainedScrollX, constrainedScrollY }; return { constrainedScrollX, constrainedScrollY, constrainedZoom };
}; };
/** /**
* Handles the state change based on the constrained scroll values. * Animate the scroll values to the constrained area
* Also handles the animation to the constrained area when the viewport is outside of constrained area.
* @param constrainedScrollX - The constrained scroll value for the X axis.
* @param constrainedScrollY - The constrained scroll value for the Y axis.
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
*/ */
const handleStateChange = ( private animateConstainedScroll = ({
constrainedScrollX: number, constrainedScrollX,
constrainedScrollY: number, constrainedScrollY,
) => { opts,
const isStateChanged = }: {
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; constrainedScrollX: number;
constrainedScrollY: number;
opts?: {
onStartCallback?: () => void;
onEndCallback?: () => void;
};
}) => {
const { scrollX, scrollY, scrollConstraints } = this.state;
const { onStartCallback, onEndCallback } = opts || {};
if (!scrollConstraints) {
return null;
}
if (isStateChanged) {
if (
(scrollX < scrollConstraints.x ||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
scrollY < scrollConstraints.y ||
scrollY + height >
scrollConstraints.y + scrollConstraints.height) &&
cursorButton !== "down" &&
currentZoom.value === prevState.zoom.value
) {
easeToValuesRAF({ easeToValuesRAF({
fromValues: { scrollX, scrollY }, fromValues: { scrollX, scrollY },
toValues: { toValues: {
@ -7838,28 +7957,77 @@ class App extends React.Component<AppProps, AppState> {
this.setState({ this.setState({
scrollConstraints: { ...scrollConstraints, isAnimating: true }, scrollConstraints: { ...scrollConstraints, isAnimating: true },
}); });
onStartCallback && onStartCallback();
}, },
onEnd: () => { onEnd: () => {
this.setState({ this.setState({
scrollConstraints: { ...scrollConstraints, isAnimating: false }, scrollConstraints: { ...scrollConstraints, isAnimating: false },
}); });
onEndCallback && onEndCallback();
}, },
}); });
};
private isViewportOutsideOfConstrainedArea = ({
scrollX,
scrollY,
width,
height,
scrollConstraints,
}: {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
width: AppState["width"];
height: AppState["height"];
scrollConstraints: AppState["scrollConstraints"];
}) => {
if (!scrollConstraints) {
return false;
}
return (
scrollX < scrollConstraints.x ||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
scrollY < scrollConstraints.y ||
scrollY + height > scrollConstraints.y + scrollConstraints.height
);
};
/**
* Handles the state change based on the constrained scroll values.
* Also handles the animation to the constrained area when the viewport is outside of constrained area.
* @param constrainedScrollX - The constrained scroll value for the X axis.
* @param constrainedScrollY - The constrained scroll value for the Y axis.
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
*/
private handleConstrainedScrollStateChange = ({
constrainedScrollX,
constrainedScrollY,
constrainedZoom,
shouldAnimate,
}: {
constrainedScrollX: number;
constrainedScrollY: number;
constrainedZoom: AppState["zoom"];
shouldAnimate?: boolean;
}) => {
const { scrollX, scrollY } = this.state;
const isStateChanged =
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
if (isStateChanged) {
if (shouldAnimate) {
this.animateConstainedScroll({
constrainedScrollX,
constrainedScrollY,
});
return null; return null;
} }
const constrainedState = { const constrainedState = {
scrollX: constrainedScrollX, scrollX: constrainedScrollX,
scrollY: constrainedScrollY, scrollY: constrainedScrollY,
zoom: { zoom: constrainedZoom,
value: maxZoomLevel
? (Math.max(
currentZoom.value,
maxZoomLevel,
) as NormalizedZoomValue)
: currentZoom.value,
},
}; };
this.setState(constrainedState); this.setState(constrainedState);
@ -7868,36 +8036,6 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
}; };
// Compute the constrained scroll values.
const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel();
const zoom = maxZoomLevel
? Math.max(maxZoomLevel, currentZoom.value)
: currentZoom.value;
const { constrainedScrollCenterX, constrainedScrollCenterY } =
calculateConstrainedScrollCenter(zoom);
const { overscrollAllowanceX, overscrollAllowanceY } =
calculateOverscrollAllowance();
const shouldAdjustForCenteredView =
zoom <= zoomLevelX || zoom <= zoomLevelY;
const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
calculateMinMaxScrollValues(
shouldAdjustForCenteredView,
overscrollAllowanceX,
overscrollAllowanceY,
constrainedScrollCenterX,
constrainedScrollCenterY,
zoom,
);
const { constrainedScrollX, constrainedScrollY } = constrainScrollValues(
maxScrollX,
minScrollX,
maxScrollY,
minScrollY,
);
return handleStateChange(constrainedScrollX, constrainedScrollY);
};
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -701,7 +701,8 @@ const ExcalidrawWrapper = () => {
y: 0, y: 0,
width: 2560, width: 2560,
height: 1300, height: 1300,
opts: { lockZoom: true, viewportZoomFactor: 0.1 }, lockZoom: true,
viewportZoomFactor: 0.1,
}} }}
> >
<AppMainMenu <AppMainMenu

View File

@ -223,17 +223,7 @@ export type AppState = {
pendingImageElementId: ExcalidrawImageElement["id"] | null; pendingImageElementId: ExcalidrawImageElement["id"] | null;
showHyperlinkPopup: false | "info" | "editor"; showHyperlinkPopup: false | "info" | "editor";
selectedLinearElement: LinearElementEditor | null; selectedLinearElement: LinearElementEditor | null;
scrollConstraints: { scrollConstraints: ScrollConstraints | null;
x: number;
y: number;
width: number;
height: number;
isAnimating?: boolean;
opts?: {
viewportZoomFactor?: number;
lockZoom?: boolean;
};
} | null;
}; };
export type UIAppState = Omit< export type UIAppState = Omit<
@ -590,3 +580,13 @@ export type FrameNameBoundsCache = {
} }
>; >;
}; };
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
isAnimating?: boolean;
viewportZoomFactor?: number;
lockZoom?: boolean;
};