feat: make constrained scroll working with split canvases

This commit is contained in:
Arnošt Pleskot 2023-09-11 17:07:07 +02:00
parent 53a88d4c7a
commit 84b19a77d7
No known key found for this signature in database
2 changed files with 115 additions and 195 deletions

View file

@ -355,8 +355,9 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
import { Renderer } from "../scene/Renderer";
import { ShapeCache } from "../scene/ShapeCache";
import {
setScrollConstraints,
calculateConstrainedScrollCenter,
constrainScrollState,
isViewportOutsideOfConstrainedArea,
} from "../scene/scrollConstraints";
const AppContext = React.createContext<AppClassProperties>(null!);
@ -2004,6 +2005,58 @@ class App extends React.Component<AppProps, AppState> {
this.files,
);
}
if (
this.state.scrollConstraints &&
isViewportOutsideOfConstrainedArea(this.state) &&
prevState.cursorButton === "down" &&
this.state.cursorButton !== "down" &&
!this.state.isLoading
) {
const state = constrainScrollState(this.state);
const cancel = easeToValuesRAF({
fromValues: {
scrollX: this.state.scrollX,
scrollY: this.state.scrollY,
zoom: this.state.zoom.value,
},
toValues: {
scrollX: state.scrollX,
scrollY: state.scrollY,
zoom: state.zoom.value,
},
interpolateValue: (from, to, progress, key) => {
// for zoom, use different easing
if (key === "zoom") {
return from * Math.pow(to / from, easeOut(progress));
}
// handle using default
return undefined;
},
onStep: ({ scrollX, scrollY, zoom }) => {
this.setState({
scrollX,
scrollY,
zoom: { value: getNormalizedZoom(zoom) },
});
},
onStart: () => {
this.setState({ shouldCacheIgnoreZoom: true });
},
onEnd: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
onCancel: () => {
this.setState({ shouldCacheIgnoreZoom: false });
},
duration: 500,
});
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
};
}
}
private renderInteractiveSceneCallback = ({
@ -2669,9 +2722,27 @@ class App extends React.Component<AppProps, AppState> {
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
stateUpdate,
) => {
this.cancelInProgresAnimation?.();
const partialNewState =
typeof stateUpdate === "function"
? (
stateUpdate as (
prevState: Readonly<AppState>,
props: Readonly<AppProps>,
) => AppState
)(this.state, this.props)
: stateUpdate;
const newState: AppState = {
...this.state,
...partialNewState,
};
const state = constrainScrollState(newState);
this.setState(state);
};
@ -8136,12 +8207,10 @@ class App extends React.Component<AppProps, AppState> {
scrollConstraints: ScrollConstraints | null,
) => {
if (scrollConstraints) {
setScrollConstraints(scrollConstraints, this.state, () =>
this.setState({
scrollConstraints,
viewModeEnabled: true,
}),
);
this.setState({
scrollConstraints,
viewModeEnabled: true,
});
} else {
this.setState({
scrollConstraints: null,

View file

@ -1,5 +1,4 @@
import { AppState, ScrollConstraints } from "../types";
import { easeToValuesRAF, isShallowEqual } from "../utils";
import { getNormalizedZoom } from "./zoom";
/**
@ -274,162 +273,41 @@ const constrainScrollValues = ({
maxScrollY,
Math.max(scrollY, minScrollY),
);
return { constrainedScrollX, constrainedScrollY, constrainedZoom };
return {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
};
/**
* Animate the scroll values to the constrained area
* Determines whether the current viewport is outside the constrained area defined in the AppState.
*
* @param state - The application state containing scroll, zoom, and constraint information.
* @returns True if the viewport is outside the constrained area; otherwise undefined.
*/
const animateConstrainedScroll = ({
state,
constrainedScrollX,
constrainedScrollY,
opts,
}: {
state: AppState;
constrainedScrollX: number;
constrainedScrollY: number;
opts?: {
onStartCallback?: () => void;
onEndCallback?: () => void;
};
}) => {
const { scrollX, scrollY, scrollConstraints } = state;
const { onStartCallback, onEndCallback } = opts || {};
if (!scrollConstraints) {
return null;
export const isViewportOutsideOfConstrainedArea = (state: AppState) => {
if (!state.scrollConstraints) {
return;
}
easeToValuesRAF({
fromValues: { scrollX, scrollY },
toValues: {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
},
onStep: ({ scrollX, scrollY }) => {
// TODO: this.setState({ scrollX, scrollY });
},
onStart: () => {
// TODO: this.setState({
// scrollConstraints: { ...scrollConstraints, isAnimating: true },
// });
onStartCallback && onStartCallback();
},
onEnd: () => {
// TODO: this.setState({
// scrollConstraints: { ...scrollConstraints, isAnimating: false },
// });
onEndCallback && onEndCallback();
},
});
};
const 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;
}
const { scrollX, scrollY, width, height, scrollConstraints } = state;
return (
scrollX < scrollConstraints.x ||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
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.
* Constrains the AppState scroll values within the defined scroll constraints.
*
* @param state - The original AppState with the current scroll position, dimensions, and constraints.
* @returns A new AppState object with scroll values constrained as per the defined constraints.
*/
const handleConstrainedScrollStateChange = ({
state,
constrainedScrollX,
constrainedScrollY,
constrainedZoom,
shouldAnimate,
}: {
constrainedScrollX: number;
constrainedScrollY: number;
constrainedZoom: AppState["zoom"];
shouldAnimate?: boolean;
state: AppState;
}) => {
const { scrollX, scrollY } = state;
const isStateChanged =
constrainedScrollX !== scrollX || constrainedScrollY !== scrollY;
if (isStateChanged) {
if (shouldAnimate) {
animateConstrainedScroll({
state,
constrainedScrollX,
constrainedScrollY,
});
return null;
}
const constrainedState = {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
// TODO: this.setState(constrainedState);
return constrainedState;
}
return null;
};
export const setScrollConstraints = (
scrollConstraints: ScrollConstraints,
state: AppState,
onAnimteEndCallback?: () => void,
) => {
const { scrollX, scrollY, width, height, zoom, cursorButton } = state;
const constrainedScrollValues = constrainScrollValues({
...calculateConstraints({
scrollConstraints,
zoom,
cursorButton,
width,
height,
}),
scrollX,
scrollY,
});
animateConstrainedScroll({
state,
...constrainedScrollValues,
opts: {
onEndCallback: () => {
onAnimteEndCallback && onAnimteEndCallback();
},
},
});
};
let memoizedScrollConstraints: ReturnType<typeof calculateConstraints> | null =
null;
export const constrainScrollState = (state: AppState, prevState: AppState) => {
export const constrainScrollState = (state: AppState): AppState => {
if (!state.scrollConstraints || state.scrollConstraints.isAnimating) {
return state;
}
@ -443,55 +321,28 @@ export const constrainScrollState = (state: AppState, prevState: AppState) => {
cursorButton,
} = state;
const canUseMemoizedConstraints =
isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) &&
isShallowEqual(
{ width, height, zoom: zoom.value, cursorButton },
{
width: prevState.width,
height: prevState.height,
zoom: prevState.zoom.value,
cursorButton: prevState.cursorButton,
} ?? {},
);
const calculatedConstraints =
canUseMemoizedConstraints && !!memoizedScrollConstraints
? memoizedScrollConstraints
: calculateConstraints({
scrollConstraints,
width,
height,
zoom,
cursorButton,
});
memoizedScrollConstraints = calculatedConstraints;
const constrainedScrollValues = constrainScrollValues({
...calculatedConstraints,
const constrainedValues = constrainScrollValues({
...calculateConstraints({
scrollConstraints,
width,
height,
zoom,
cursorButton,
}),
scrollX,
scrollY,
});
const viewportOutsideOfConstrainedArea = isViewportOutsideOfConstrainedArea({
scrollX,
scrollY,
width,
height,
scrollConstraints,
});
const isStateChanged =
constrainedValues.scrollX !== scrollX ||
constrainedValues.scrollY !== scrollY;
const shouldAnimate =
viewportOutsideOfConstrainedArea &&
state.cursorButton !== "down" &&
prevState.cursorButton === "down" &&
prevState.zoom.value === state.zoom.value &&
!state.isLoading; // Do not animate when app is initialized but scene is empty - it would cause flickering
if (isStateChanged) {
return {
...state,
...constrainedValues,
};
}
return handleConstrainedScrollStateChange({
state,
...constrainedScrollValues,
shouldAnimate,
});
return state;
};