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) {
scrollConstraints, const { scrollX, scrollY, width, height, zoom, cursorButton } =
viewModeEnabled: !!scrollConstraints, this.state;
}); const constrainedScrollValues = this.constrainScrollValues({
...this.calculateConstraints({
scrollConstraints,
zoom,
cursorButton,
width,
height,
}),
scrollX,
scrollY,
});
this.animateConstainedScroll({
...constrainedScrollValues,
opts: {
onEndCallback: () => {
this.setState({
scrollConstraints,
viewModeEnabled: true,
});
},
},
});
} else {
this.setState({
scrollConstraints: null,
viewModeEnabled: false,
});
}
}; };
/** private calculateConstraints = ({
* Constrains the scroll position of the app state within the defined scroll constraints. scrollConstraints,
* The scroll position is adjusted based on the application's current state including zoom level and viewport dimensions. width,
* height,
* @param nextState - The next state of the application, or a subset of the application state. zoom,
* @returns The modified next state with scrollX and scrollY constrained to the scroll constraints. cursorButton,
*/ }: {
private constrainScroll = (prevState: AppState): ConstrainedScrollValues => { scrollConstraints: ScrollConstraints;
const { width: AppState["width"];
scrollX, height: AppState["height"];
scrollY, zoom: AppState["zoom"];
scrollConstraints, cursorButton: AppState["cursorButton"];
width, }) => {
height,
zoom: currentZoom,
cursorButton,
} = this.state;
if (!scrollConstraints || scrollConstraints.isAnimating) {
return null;
}
// 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,110 +7854,16 @@ class App extends React.Component<AppProps, AppState> {
return { maxScrollX, minScrollX, maxScrollY, minScrollY }; return { maxScrollX, minScrollX, maxScrollY, minScrollY };
}; };
/**
* Constrains the scroll values within the constrained area.
* @param maxScrollX - The maximum scroll value for the X axis.
* @param minScrollX - The minimum scroll value for the X axis.
* @param maxScrollY - The maximum 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.
*/
const constrainScrollValues = (
maxScrollX: number,
minScrollX: number,
maxScrollY: number,
minScrollY: number,
) => {
const constrainedScrollX = Math.min(
maxScrollX,
Math.max(scrollX, minScrollX),
);
const constrainedScrollY = Math.min(
maxScrollY,
Math.max(scrollY, minScrollY),
);
return { constrainedScrollX, constrainedScrollY };
};
/**
* 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.
*/
const handleStateChange = (
constrainedScrollX: number,
constrainedScrollY: number,
) => {
const isStateChanged =
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
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({
fromValues: { scrollX, scrollY },
toValues: {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
},
onStep: ({ scrollX, scrollY }) => {
this.setState({ scrollX, scrollY });
},
onStart: () => {
this.setState({
scrollConstraints: { ...scrollConstraints, isAnimating: true },
});
},
onEnd: () => {
this.setState({
scrollConstraints: { ...scrollConstraints, isAnimating: false },
});
},
});
return null;
}
const constrainedState = {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: {
value: maxZoomLevel
? (Math.max(
currentZoom.value,
maxZoomLevel,
) as NormalizedZoomValue)
: currentZoom.value,
},
};
this.setState(constrainedState);
return constrainedState;
}
return null;
};
// Compute the constrained scroll values.
const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel(); const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel();
const zoom = maxZoomLevel const constrainedZoom = getNormalizedZoom(
? Math.max(maxZoomLevel, currentZoom.value) maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value,
: currentZoom.value; );
const { constrainedScrollCenterX, constrainedScrollCenterY } = const { constrainedScrollCenterX, constrainedScrollCenterY } =
calculateConstrainedScrollCenter(zoom); calculateConstrainedScrollCenter(constrainedZoom);
const { overscrollAllowanceX, overscrollAllowanceY } = const { overscrollAllowanceX, overscrollAllowanceY } =
calculateOverscrollAllowance(); calculateOverscrollAllowance();
const shouldAdjustForCenteredView = const shouldAdjustForCenteredView =
zoom <= zoomLevelX || zoom <= zoomLevelY; constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY;
const { maxScrollX, minScrollX, maxScrollY, minScrollY } = const { maxScrollX, minScrollX, maxScrollY, minScrollY } =
calculateMinMaxScrollValues( calculateMinMaxScrollValues(
shouldAdjustForCenteredView, shouldAdjustForCenteredView,
@ -7887,16 +7871,170 @@ class App extends React.Component<AppProps, AppState> {
overscrollAllowanceY, overscrollAllowanceY,
constrainedScrollCenterX, constrainedScrollCenterX,
constrainedScrollCenterY, constrainedScrollCenterY,
zoom, constrainedZoom,
); );
const { constrainedScrollX, constrainedScrollY } = constrainScrollValues(
return {
maxScrollX, maxScrollX,
minScrollX, minScrollX,
maxScrollY, maxScrollY,
minScrollY, minScrollY,
); constrainedZoom: {
value: constrainedZoom,
},
};
};
return handleStateChange(constrainedScrollX, constrainedScrollY); /**
* Constrains the scroll values within the constrained area.
* @param maxScrollX - The maximum scroll value for the X axis.
* @param minScrollX - The minimum scroll value for the X axis.
* @param maxScrollY - The maximum 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.
*/
private constrainScrollValues = ({
scrollX,
scrollY,
maxScrollX,
minScrollX,
maxScrollY,
minScrollY,
constrainedZoom,
}: {
scrollX: number;
scrollY: number;
maxScrollX: number;
minScrollX: number;
maxScrollY: number;
minScrollY: number;
constrainedZoom: AppState["zoom"];
}) => {
const constrainedScrollX = Math.min(
maxScrollX,
Math.max(scrollX, minScrollX),
);
const constrainedScrollY = Math.min(
maxScrollY,
Math.max(scrollY, minScrollY),
);
return { constrainedScrollX, constrainedScrollY, constrainedZoom };
};
/**
* Animate the scroll values to the constrained area
*/
private animateConstainedScroll = ({
constrainedScrollX,
constrainedScrollY,
opts,
}: {
constrainedScrollX: number;
constrainedScrollY: number;
opts?: {
onStartCallback?: () => void;
onEndCallback?: () => void;
};
}) => {
const { scrollX, scrollY, scrollConstraints } = this.state;
const { onStartCallback, onEndCallback } = opts || {};
if (!scrollConstraints) {
return null;
}
easeToValuesRAF({
fromValues: { scrollX, scrollY },
toValues: {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
},
onStep: ({ scrollX, scrollY }) => {
this.setState({ scrollX, scrollY });
},
onStart: () => {
this.setState({
scrollConstraints: { ...scrollConstraints, isAnimating: true },
});
onStartCallback && onStartCallback();
},
onEnd: () => {
this.setState({
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;
}
const constrainedState = {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
this.setState(constrainedState);
return constrainedState;
}
return null;
}; };
} }

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