mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: make constrained scroll working with split canvases
This commit is contained in:
parent
53a88d4c7a
commit
84b19a77d7
2 changed files with 115 additions and 195 deletions
|
@ -355,8 +355,9 @@ import { StaticCanvas, InteractiveCanvas } from "./canvases";
|
||||||
import { Renderer } from "../scene/Renderer";
|
import { Renderer } from "../scene/Renderer";
|
||||||
import { ShapeCache } from "../scene/ShapeCache";
|
import { ShapeCache } from "../scene/ShapeCache";
|
||||||
import {
|
import {
|
||||||
setScrollConstraints,
|
|
||||||
calculateConstrainedScrollCenter,
|
calculateConstrainedScrollCenter,
|
||||||
|
constrainScrollState,
|
||||||
|
isViewportOutsideOfConstrainedArea,
|
||||||
} from "../scene/scrollConstraints";
|
} from "../scene/scrollConstraints";
|
||||||
|
|
||||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||||
|
@ -2004,6 +2005,58 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
this.files,
|
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 = ({
|
private renderInteractiveSceneCallback = ({
|
||||||
|
@ -2669,9 +2722,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
|
|
||||||
/** use when changing scrollX/scrollY/zoom based on user interaction */
|
/** use when changing scrollX/scrollY/zoom based on user interaction */
|
||||||
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
private translateCanvas: React.Component<any, AppState>["setState"] = (
|
||||||
state,
|
stateUpdate,
|
||||||
) => {
|
) => {
|
||||||
this.cancelInProgresAnimation?.();
|
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);
|
this.setState(state);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -8136,12 +8207,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||||
scrollConstraints: ScrollConstraints | null,
|
scrollConstraints: ScrollConstraints | null,
|
||||||
) => {
|
) => {
|
||||||
if (scrollConstraints) {
|
if (scrollConstraints) {
|
||||||
setScrollConstraints(scrollConstraints, this.state, () =>
|
this.setState({
|
||||||
this.setState({
|
scrollConstraints,
|
||||||
scrollConstraints,
|
viewModeEnabled: true,
|
||||||
viewModeEnabled: true,
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
this.setState({
|
this.setState({
|
||||||
scrollConstraints: null,
|
scrollConstraints: null,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { AppState, ScrollConstraints } from "../types";
|
import { AppState, ScrollConstraints } from "../types";
|
||||||
import { easeToValuesRAF, isShallowEqual } from "../utils";
|
|
||||||
import { getNormalizedZoom } from "./zoom";
|
import { getNormalizedZoom } from "./zoom";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -274,162 +273,41 @@ const constrainScrollValues = ({
|
||||||
maxScrollY,
|
maxScrollY,
|
||||||
Math.max(scrollY, minScrollY),
|
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 = ({
|
export const isViewportOutsideOfConstrainedArea = (state: AppState) => {
|
||||||
state,
|
if (!state.scrollConstraints) {
|
||||||
constrainedScrollX,
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
easeToValuesRAF({
|
const { scrollX, scrollY, width, height, scrollConstraints } = state;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
scrollX < scrollConstraints.x ||
|
scrollX > scrollConstraints.x ||
|
||||||
scrollX + width > scrollConstraints.x + scrollConstraints.width ||
|
scrollX - width < scrollConstraints.x - scrollConstraints.width ||
|
||||||
scrollY < scrollConstraints.y ||
|
scrollY < scrollConstraints.y ||
|
||||||
scrollY + height > scrollConstraints.y + scrollConstraints.height
|
scrollY + height > scrollConstraints.y + scrollConstraints.height
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the state change based on the constrained scroll values.
|
* Constrains the AppState scroll values within the defined scroll constraints.
|
||||||
* 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 state - The original AppState with the current scroll position, dimensions, and constraints.
|
||||||
* @param constrainedScrollY - The constrained scroll value for the Y axis.
|
* @returns A new AppState object with scroll values constrained as per the defined constraints.
|
||||||
* @returns The constrained state if the state has changed, when needs to be passed into render function, otherwise null.
|
|
||||||
*/
|
*/
|
||||||
const handleConstrainedScrollStateChange = ({
|
export const constrainScrollState = (state: AppState): AppState => {
|
||||||
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) => {
|
|
||||||
if (!state.scrollConstraints || state.scrollConstraints.isAnimating) {
|
if (!state.scrollConstraints || state.scrollConstraints.isAnimating) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -443,55 +321,28 @@ export const constrainScrollState = (state: AppState, prevState: AppState) => {
|
||||||
cursorButton,
|
cursorButton,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
const canUseMemoizedConstraints =
|
const constrainedValues = constrainScrollValues({
|
||||||
isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) &&
|
...calculateConstraints({
|
||||||
isShallowEqual(
|
scrollConstraints,
|
||||||
{ width, height, zoom: zoom.value, cursorButton },
|
width,
|
||||||
{
|
height,
|
||||||
width: prevState.width,
|
zoom,
|
||||||
height: prevState.height,
|
cursorButton,
|
||||||
zoom: prevState.zoom.value,
|
}),
|
||||||
cursorButton: prevState.cursorButton,
|
|
||||||
} ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
const calculatedConstraints =
|
|
||||||
canUseMemoizedConstraints && !!memoizedScrollConstraints
|
|
||||||
? memoizedScrollConstraints
|
|
||||||
: calculateConstraints({
|
|
||||||
scrollConstraints,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
zoom,
|
|
||||||
cursorButton,
|
|
||||||
});
|
|
||||||
|
|
||||||
memoizedScrollConstraints = calculatedConstraints;
|
|
||||||
|
|
||||||
const constrainedScrollValues = constrainScrollValues({
|
|
||||||
...calculatedConstraints,
|
|
||||||
scrollX,
|
scrollX,
|
||||||
scrollY,
|
scrollY,
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewportOutsideOfConstrainedArea = isViewportOutsideOfConstrainedArea({
|
const isStateChanged =
|
||||||
scrollX,
|
constrainedValues.scrollX !== scrollX ||
|
||||||
scrollY,
|
constrainedValues.scrollY !== scrollY;
|
||||||
width,
|
|
||||||
height,
|
|
||||||
scrollConstraints,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shouldAnimate =
|
if (isStateChanged) {
|
||||||
viewportOutsideOfConstrainedArea &&
|
return {
|
||||||
state.cursorButton !== "down" &&
|
...state,
|
||||||
prevState.cursorButton === "down" &&
|
...constrainedValues,
|
||||||
prevState.zoom.value === state.zoom.value &&
|
};
|
||||||
!state.isLoading; // Do not animate when app is initialized but scene is empty - it would cause flickering
|
}
|
||||||
|
|
||||||
return handleConstrainedScrollStateChange({
|
return state;
|
||||||
state,
|
|
||||||
...constrainedScrollValues,
|
|
||||||
shouldAnimate,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue