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 { 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
scrollConstraints: null,
|
||||
|
|
|
@ -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({
|
||||
const constrainedValues = constrainScrollValues({
|
||||
...calculateConstraints({
|
||||
scrollConstraints,
|
||||
width,
|
||||
height,
|
||||
zoom,
|
||||
cursorButton,
|
||||
});
|
||||
|
||||
memoizedScrollConstraints = calculatedConstraints;
|
||||
|
||||
const constrainedScrollValues = constrainScrollValues({
|
||||
...calculatedConstraints,
|
||||
}),
|
||||
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
|
||||
|
||||
return handleConstrainedScrollStateChange({
|
||||
state,
|
||||
...constrainedScrollValues,
|
||||
shouldAnimate,
|
||||
});
|
||||
if (isStateChanged) {
|
||||
return {
|
||||
...state,
|
||||
...constrainedValues,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue