Compare commits

...

32 Commits

Author SHA1 Message Date
dwelle
b0cdd00c2a [debug] 2023-08-02 17:54:04 +02:00
dwelle
6711735b27 Merge branch 'master' into arnost/scroll-in-read-only-links 2023-08-02 17:53:30 +02:00
dwelle
803e14ada1 Revert "[debug]"
This reverts commit 71eb3023b2fd267db44fc5d11c4fb6814b4206a6.
2023-08-02 17:41:32 +02:00
Arnošt Pleskot
4469c02191
chore: move this.scene.getElementsIncludingDeleted() result into const 2023-08-01 16:52:59 +02:00
Arnošt Pleskot
04e23e1d29
fix: do not animate empty scene 2023-08-01 16:30:45 +02:00
Arnošt Pleskot
d24a032dbb
feat: set scroll constraints on initial scene state 2023-07-31 18:33:39 +02:00
Arnošt Pleskot
76d3930983
fix: typo 2023-07-31 09:50:47 +02:00
Arnost Pleskot
af6e64ffc2
Merge branch 'master' into arnost/scroll-in-read-only-links 2023-07-31 09:26:14 +02:00
Arnošt Pleskot
4e9039e850
feat: simplify memoization logic 2023-07-15 12:44:34 +02:00
Arnošt Pleskot
132750f753
feat: splitting logic, memoization 2023-07-15 12:44:33 +02:00
Arnošt Pleskot
71eb3023b2
[debug] 2023-07-15 12:44:32 +02:00
Arnošt Pleskot
6d165971fc
feat: set view mode when constrains set via props 2023-07-15 12:44:31 +02:00
Arnošt Pleskot
9562e4309f
feat: add zoom lock and viewportZoomFactor 2023-07-15 12:44:30 +02:00
Arnošt Pleskot
e8e391e465
refactor: split constrainScroll into smaller functions 2023-07-15 12:44:29 +02:00
Arnošt Pleskot
92be92071a
feat: disable animation on zooming 2023-07-15 12:44:28 +02:00
Arnošt Pleskot
71918e57a8
feat: cleanup 2023-07-15 12:44:27 +02:00
Arnošt Pleskot
c0bd9027cb
feat: animate the scroll to constrained area 2023-07-15 12:44:26 +02:00
Arnošt Pleskot
7336b1c276
test: update snapshot 2023-07-15 12:44:25 +02:00
Arnošt Pleskot
7fb6c23715
fix: remove forgotten console.log 2023-07-15 12:44:24 +02:00
Arnošt Pleskot
82014fe670
chore: comments and variable renaming 2023-07-15 12:44:23 +02:00
Arnošt Pleskot
bc44c3f947
feat: add overscroll when constrained area is smaller than viewport 2023-07-15 12:44:21 +02:00
Arnošt Pleskot
19ba107041
feat: pass scrollConstraints via props 2023-07-15 12:44:18 +02:00
Arnošt Pleskot
381ef93956
feat: remove zoom limit 2023-07-15 12:42:46 +02:00
Arnošt Pleskot
f82363aae9
feat: enforce constrains on setting constrains 2023-07-15 12:42:45 +02:00
Arnošt Pleskot
485c57fd59
chore: remove console.log 2023-07-15 12:42:44 +02:00
Arnošt Pleskot
35b43c14d8
feat: allow scroll over constraints while mouse down 2023-07-15 12:42:43 +02:00
Arnošt Pleskot
f7e8056abe
feat: update constraints on window resize 2023-07-15 12:42:42 +02:00
Arnošt Pleskot
71f7960606
test: fix test snapshots 2023-07-15 12:42:41 +02:00
Arnošt Pleskot
2998573e79
feat: limit scroll in componentDidUpdate 2023-07-15 12:42:40 +02:00
Arnošt Pleskot
209934c90a
feat: center constrained area on zoom out 2023-07-15 12:42:39 +02:00
Arnošt Pleskot
a8158691b7
feat: limit zoom by translateCanvas 2023-07-15 12:42:38 +02:00
Arnošt Pleskot
75f8e904cc
feat: add possibility to limit scroll area 2023-07-15 12:42:37 +02:00
12 changed files with 632 additions and 20 deletions

View File

@ -16,7 +16,7 @@ export type ActionResult =
elements?: readonly ExcalidrawElement[] | null; elements?: readonly ExcalidrawElement[] | null;
appState?: MarkOptional< appState?: MarkOptional<
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
> | null; > | null;
files?: BinaryFiles | null; files?: BinaryFiles | null;
commitToHistory: boolean; commitToHistory: boolean;

View File

@ -17,7 +17,7 @@ const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio)
export const getDefaultAppState = (): Omit< export const getDefaultAppState = (): Omit<
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
> => { > => {
return { return {
showWelcomeScreen: false, showWelcomeScreen: false,
@ -206,6 +206,7 @@ const APP_STATE_STORAGE_CONF = (<
pendingImageElementId: { browser: false, export: false, server: false }, pendingImageElementId: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false },
selectedLinearElement: { browser: true, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false },
scrollConstraints: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

@ -209,7 +209,11 @@ import {
isSomeElementSelected, isSomeElementSelected,
} from "../scene"; } from "../scene";
import Scene from "../scene/Scene"; import Scene from "../scene/Scene";
import { RenderConfig, ScrollBars } from "../scene/types"; import {
RenderConfig,
ScrollBars,
ConstrainedScrollValues,
} from "../scene/types";
import { getStateForZoom } from "../scene/zoom"; import { getStateForZoom } from "../scene/zoom";
import { findShapeByKey, SHAPES } from "../shapes"; import { findShapeByKey, SHAPES } from "../shapes";
import { import {
@ -229,6 +233,7 @@ import {
FrameNameBoundsCache, FrameNameBoundsCache,
SidebarName, SidebarName,
SidebarTabName, SidebarTabName,
ScrollConstraints,
} from "../types"; } from "../types";
import { import {
debounce, debounce,
@ -257,6 +262,7 @@ import {
muteFSAbortError, muteFSAbortError,
isTestEnv, isTestEnv,
easeOut, easeOut,
isShallowEqual,
} from "../utils"; } from "../utils";
import { import {
embeddableURLValidator, embeddableURLValidator,
@ -380,6 +386,7 @@ const ExcalidrawAppStateContext = React.createContext<AppState>({
height: 0, height: 0,
offsetLeft: 0, offsetLeft: 0,
offsetTop: 0, offsetTop: 0,
scrollConstraints: null,
}); });
ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext";
@ -478,6 +485,9 @@ 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: ReturnType<
App["calculateConstraints"]
> | null = null;
constructor(props: AppProps) { constructor(props: AppProps) {
super(props); super(props);
@ -489,7 +499,9 @@ class App extends React.Component<AppProps, AppState> {
gridModeEnabled = false, gridModeEnabled = false,
theme = defaultAppState.theme, theme = defaultAppState.theme,
name = defaultAppState.name, name = defaultAppState.name,
scrollConstraints,
} = props; } = props;
this.state = { this.state = {
...defaultAppState, ...defaultAppState,
theme, theme,
@ -501,6 +513,7 @@ class App extends React.Component<AppProps, AppState> {
name, name,
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
scrollConstraints: scrollConstraints ?? null,
}; };
this.id = nanoid(); this.id = nanoid();
this.library = new Library(this); this.library = new Library(this);
@ -532,6 +545,7 @@ class App extends React.Component<AppProps, AppState> {
resetCursor: this.resetCursor, resetCursor: this.resetCursor,
updateFrameRendering: this.updateFrameRendering, updateFrameRendering: this.updateFrameRendering,
toggleSidebar: this.toggleSidebar, toggleSidebar: this.toggleSidebar,
setScrollConstraints: this.setScrollConstraints,
} as const; } as const;
if (typeof excalidrawRef === "function") { if (typeof excalidrawRef === "function") {
excalidrawRef(api); excalidrawRef(api);
@ -1546,7 +1560,15 @@ class App extends React.Component<AppProps, AppState> {
isLoading: false, isLoading: false,
toast: this.state.toast, toast: this.state.toast,
}; };
if (initialData?.scrollToContent) { if (this.props.scrollConstraints) {
scene.appState = {
...scene.appState,
...this.calculateConstrainedScrollCenter(
this.props.scrollConstraints,
scene.appState,
),
};
} else if (initialData?.scrollToContent) {
scene.appState = { scene.appState = {
...scene.appState, ...scene.appState,
...calculateScrollCenter( ...calculateScrollCenter(
@ -1557,6 +1579,7 @@ class App extends React.Component<AppProps, AppState> {
height: this.state.height, height: this.state.height,
offsetTop: this.state.offsetTop, offsetTop: this.state.offsetTop,
offsetLeft: this.state.offsetLeft, offsetLeft: this.state.offsetLeft,
scrollConstraints: this.state.scrollConstraints,
}, },
null, null,
), ),
@ -1843,10 +1866,8 @@ class App extends React.Component<AppProps, AppState> {
componentDidUpdate(prevProps: AppProps, prevState: AppState) { componentDidUpdate(prevProps: AppProps, prevState: AppState) {
this.updateEmbeddables(); this.updateEmbeddables();
if ( const elementsIncludingDeleted = this.scene.getElementsIncludingDeleted();
!this.state.showWelcomeScreen && if (!this.state.showWelcomeScreen && !elementsIncludingDeleted.length) {
!this.scene.getElementsIncludingDeleted().length
) {
this.setState({ showWelcomeScreen: true }); this.setState({ showWelcomeScreen: true });
} }
@ -1973,7 +1994,73 @@ class App extends React.Component<AppProps, AppState> {
), ),
); );
} }
this.renderScene();
let constraintedScrollState;
if (
this.state.scrollConstraints &&
!this.state.scrollConstraints.isAnimating
) {
const {
scrollX,
scrollY,
width,
height,
scrollConstraints,
zoom,
cursorButton,
} = this.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 && !!this.memoizedScrollConstraints
? this.memoizedScrollConstraints
: this.calculateConstraints({
scrollConstraints,
width,
height,
zoom,
cursorButton,
});
this.memoizedScrollConstraints = 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 &&
elementsIncludingDeleted.length > 0, // Do not animate when app is initialized but scene is empty - this would cause flickering
});
}
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
@ -1989,7 +2076,7 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
private renderScene = () => { private renderScene = (constrainedScroll?: ConstrainedScrollValues) => {
const cursorButton: { const cursorButton: {
[id: string]: string | undefined; [id: string]: string | undefined;
} = {}; } = {};
@ -2060,10 +2147,10 @@ class App extends React.Component<AppProps, AppState> {
canvas: this.canvas!, canvas: this.canvas!,
renderConfig: { renderConfig: {
selectionColor, selectionColor,
scrollX: this.state.scrollX, scrollX: constrainedScroll?.scrollX ?? this.state.scrollX,
scrollY: this.state.scrollY, scrollY: constrainedScroll?.scrollY ?? this.state.scrollY,
viewBackgroundColor: this.state.viewBackgroundColor, viewBackgroundColor: this.state.viewBackgroundColor,
zoom: this.state.zoom, zoom: constrainedScroll?.zoom ?? this.state.zoom,
remotePointerViewportCoords: pointerViewportCoords, remotePointerViewportCoords: pointerViewportCoords,
remotePointerButton: cursorButton, remotePointerButton: cursorButton,
remoteSelectedElementIds, remoteSelectedElementIds,
@ -2081,7 +2168,8 @@ class App extends React.Component<AppProps, AppState> {
} }
const scrolledOutside = const scrolledOutside =
// hide when editing text // hide when editing text
isTextElement(this.state.editingElement) isTextElement(this.state.editingElement) ||
this.state.scrollConstraints
? false ? false
: !atLeastOneVisibleElement && renderingElements.length > 0; : !atLeastOneVisibleElement && renderingElements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) { if (this.state.scrolledOutside !== scrolledOutside) {
@ -2619,8 +2707,8 @@ class App extends React.Component<AppProps, AppState> {
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */ /** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
value: number, value: number,
) => { ) => {
this.setState({ this.setState(
...getStateForZoom( getStateForZoom(
{ {
viewportX: this.state.width / 2 + this.state.offsetLeft, viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop, viewportY: this.state.height / 2 + this.state.offsetTop,
@ -2628,7 +2716,7 @@ class App extends React.Component<AppProps, AppState> {
}, },
this.state, this.state,
), ),
}); );
}; };
private cancelInProgresAnimation: (() => void) | null = null; private cancelInProgresAnimation: (() => void) | null = null;
@ -8153,6 +8241,424 @@ class App extends React.Component<AppProps, AppState> {
await setLanguage(currentLang); await setLanguage(currentLang);
this.setAppState({}); this.setAppState({});
} }
/**
* Sets the scroll constraints of the application state.
*
* @param scrollConstraints - The new scroll constraints.
*/
public setScrollConstraints = (
scrollConstraints: ScrollConstraints | null,
) => {
if (scrollConstraints) {
const { scrollX, scrollY, width, height, zoom, cursorButton } =
this.state;
const constrainedScrollValues = this.constrainScrollValues({
...this.calculateConstraints({
scrollConstraints,
zoom,
cursorButton,
width,
height,
}),
scrollX,
scrollY,
});
this.animateConstrainedScroll({
...constrainedScrollValues,
opts: {
onEndCallback: () => {
this.setState({
scrollConstraints,
viewModeEnabled: true,
});
},
},
});
} else {
this.setState({
scrollConstraints: null,
viewModeEnabled: false,
});
}
};
/**
* Calculates the scroll center coordinates and the optimal zoom level to fit the constrained scrollable area within the viewport.
*
* This method first calculates the necessary zoom level to fit the entire constrained scrollable area within the viewport.
* Then it calculates the constraints for the viewport given the new zoom level and the current scrollable area dimensions.
* The function returns an object containing the optimal scroll positions and zoom level.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param appState - An object containing the current horizontal and vertical scroll positions.
* @returns An object containing the calculated optimal horizontal and vertical scroll positions and zoom level.
*
* @example
*
* const { scrollX, scrollY, zoom } = this.calculateConstrainedScrollCenter(scrollConstraints, { scrollX, scrollY });
*/
public calculateConstrainedScrollCenter = (
scrollConstraints: AppState["scrollConstraints"],
{ scrollX, scrollY }: Pick<AppState, "scrollX" | "scrollY">,
): {
scrollX: AppState["scrollX"];
scrollY: AppState["scrollY"];
zoom: AppState["zoom"];
} => {
const { width, height, zoom } = this.state;
if (!scrollConstraints) {
return { scrollX, scrollY, zoom };
}
const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel(
scrollConstraints,
width,
height,
);
// The zoom level to contain the whole constrained area in view
const _zoom = {
value: getNormalizedZoom(
maxZoomLevel ?? Math.min(zoomLevelX, zoomLevelY),
),
};
const constraints = this.calculateConstraints({
scrollConstraints,
width,
height,
zoom: _zoom,
cursorButton: "up",
});
return {
scrollX: constraints.minScrollX,
scrollY: constraints.minScrollY,
zoom: constraints.constrainedZoom,
};
};
/**
* Calculates the zoom levels necessary to fit the constrained scrollable area within the viewport on the X and Y axes.
*
* The function considers the dimensions of the scrollable area, the dimensions of the viewport, the viewport zoom factor,
* and whether the zoom should be locked. It then calculates the necessary zoom levels for the X and Y axes separately.
* If the zoom should be locked, it calculates the maximum zoom level that fits the scrollable area within the viewport,
* factoring in the viewport zoom factor. If the zoom should not be locked, the maximum zoom level is set to null.
*
* @param scrollConstraints - The constraints of the scrollable area including width, height, and position.
* @param width - The width of the viewport.
* @param height - The height of the viewport.
* @returns An object containing the calculated zoom levels for the X and Y axes, and the maximum zoom level if applicable.
*/
private calculateZoomLevel = (
scrollConstraints: ScrollConstraints,
width: AppState["width"],
height: AppState["height"],
) => {
const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.7;
const lockZoom = scrollConstraints.lockZoom ?? false;
const viewportZoomFactor = scrollConstraints.viewportZoomFactor
? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1))
: DEFAULT_VIEWPORT_ZOOM_FACTOR;
const scrollableWidth = scrollConstraints.width;
const scrollableHeight = scrollConstraints.height;
const zoomLevelX = width / scrollableWidth;
const zoomLevelY = height / scrollableHeight;
const maxZoomLevel = lockZoom
? getNormalizedZoom(Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor)
: null;
return { zoomLevelX, zoomLevelY, maxZoomLevel };
};
private calculateConstraints = ({
scrollConstraints,
width,
height,
zoom,
cursorButton,
}: {
scrollConstraints: ScrollConstraints;
width: AppState["width"];
height: AppState["height"];
zoom: AppState["zoom"];
cursorButton: AppState["cursorButton"];
}) => {
// Set the overscroll allowance percentage
const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2;
/**
* Calculates the center position of the constrained scroll area.
* @returns The X and Y coordinates of the center position.
*/
const calculateConstrainedScrollCenter = (zoom: number) => {
const constrainedScrollCenterX =
scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2;
const constrainedScrollCenterY =
scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2;
return { constrainedScrollCenterX, constrainedScrollCenterY };
};
/**
* Calculates the overscroll allowance values for the constrained area.
* @returns The overscroll allowance values for the X and Y axes.
*/
const calculateOverscrollAllowance = () => {
const overscrollAllowanceX =
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.width;
const overscrollAllowanceY =
OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.height;
return { overscrollAllowanceX, overscrollAllowanceY };
};
/**
* Calculates the minimum and maximum scroll values based on the current state.
* @param shouldAdjustForCenteredView - Whether the view should be adjusted for centered view - when constrained area fits the viewport.
* @param overscrollAllowanceX - The overscroll allowance value for the X axis.
* @param overscrollAllowanceY - The overscroll allowance value for the Y axis.
* @param constrainedScrollCenterX - The X coordinate of the constrained scroll area center.
* @param constrainedScrollCenterY - The Y coordinate of the constrained scroll area center.
* @returns The minimum and maximum scroll values for the X and Y axes.
*/
const calculateMinMaxScrollValues = (
shouldAdjustForCenteredView: boolean,
overscrollAllowanceX: number,
overscrollAllowanceY: number,
constrainedScrollCenterX: number,
constrainedScrollCenterY: number,
zoom: number,
) => {
let maxScrollX;
let minScrollX;
let maxScrollY;
let minScrollY;
if (cursorButton === "down" && shouldAdjustForCenteredView) {
maxScrollX = constrainedScrollCenterX + overscrollAllowanceX;
minScrollX = constrainedScrollCenterX - overscrollAllowanceX;
maxScrollY = constrainedScrollCenterY + overscrollAllowanceY;
minScrollY = constrainedScrollCenterY - overscrollAllowanceY;
} else if (cursorButton === "down" && !shouldAdjustForCenteredView) {
maxScrollX = scrollConstraints.x + overscrollAllowanceX;
minScrollX =
scrollConstraints.x -
scrollConstraints.width +
width / zoom -
overscrollAllowanceX;
maxScrollY = scrollConstraints.y + overscrollAllowanceY;
minScrollY =
scrollConstraints.y -
scrollConstraints.height +
height / zoom -
overscrollAllowanceY;
} else if (cursorButton !== "down" && shouldAdjustForCenteredView) {
maxScrollX = constrainedScrollCenterX;
minScrollX = constrainedScrollCenterX;
maxScrollY = constrainedScrollCenterY;
minScrollY = constrainedScrollCenterY;
} else {
maxScrollX = scrollConstraints.x;
minScrollX =
scrollConstraints.x - scrollConstraints.width + width / zoom;
maxScrollY = scrollConstraints.y;
minScrollY =
scrollConstraints.y - scrollConstraints.height + height / zoom;
}
return { maxScrollX, minScrollX, maxScrollY, minScrollY };
};
const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel(
scrollConstraints,
width,
height,
);
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.
* @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 animateConstrainedScroll = ({
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.animateConstrainedScroll({
constrainedScrollX,
constrainedScrollY,
});
return null;
}
const constrainedState = {
scrollX: constrainedScrollX,
scrollY: constrainedScrollY,
zoom: constrainedZoom,
};
this.setState(constrainedState);
return constrainedState;
}
return null;
};
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------

View File

@ -46,7 +46,7 @@ import { normalizeLink } from "./url";
type RestoredAppState = Omit< type RestoredAppState = Omit<
AppState, AppState,
"offsetTop" | "offsetLeft" | "width" | "height" "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints"
>; >;
export const AllowedExcalidrawActiveTools: Record< export const AllowedExcalidrawActiveTools: Record<

View File

@ -731,6 +731,13 @@ const ExcalidrawWrapper = () => {
/> />
); );
}} }}
scrollConstraints={{
x: 0,
y: 0,
width: 2560,
height: 1300,
lockZoom: true,
}}
> >
<AppMainMenu <AppMainMenu
setCollabDialogShown={setCollabDialogShown} setCollabDialogShown={setCollabDialogShown}

View File

@ -65,6 +65,7 @@ const canvas = exportToCanvas(
offsetLeft: 0, offsetLeft: 0,
width: 0, width: 0,
height: 0, height: 0,
scrollConstraints: null,
}, },
{}, // files {}, // files
{ {

View File

@ -42,6 +42,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerDown, onPointerDown,
onScrollChange, onScrollChange,
children, children,
scrollConstraints,
validateEmbeddable, validateEmbeddable,
renderEmbeddable, renderEmbeddable,
} = props; } = props;
@ -100,7 +101,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onPointerUpdate={onPointerUpdate} onPointerUpdate={onPointerUpdate}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
langCode={langCode} langCode={langCode}
viewModeEnabled={viewModeEnabled} viewModeEnabled={viewModeEnabled /* || !!scrollConstraints */}
zenModeEnabled={zenModeEnabled} zenModeEnabled={zenModeEnabled}
gridModeEnabled={gridModeEnabled} gridModeEnabled={gridModeEnabled}
libraryReturnUrl={libraryReturnUrl} libraryReturnUrl={libraryReturnUrl}
@ -117,6 +118,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
onLinkOpen={onLinkOpen} onLinkOpen={onLinkOpen}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onScrollChange={onScrollChange} onScrollChange={onScrollChange}
scrollConstraints={scrollConstraints}
validateEmbeddable={validateEmbeddable} validateEmbeddable={validateEmbeddable}
renderEmbeddable={renderEmbeddable} renderEmbeddable={renderEmbeddable}
> >

View File

@ -64,7 +64,14 @@ export const exportToCanvas = ({
const { exportBackground, viewBackgroundColor } = restoredAppState; const { exportBackground, viewBackgroundColor } = restoredAppState;
return _exportToCanvas( return _exportToCanvas(
passElementsSafely(restoredElements), passElementsSafely(restoredElements),
{ ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, {
...restoredAppState,
offsetTop: 0,
offsetLeft: 0,
width: 0,
height: 0,
scrollConstraints: null,
},
files || {}, files || {},
{ exportBackground, exportPadding, viewBackgroundColor }, { exportBackground, exportPadding, viewBackgroundColor },
(width: number, height: number) => { (width: number, height: number) => {

View File

@ -60,3 +60,8 @@ export type ScrollBars = {
height: number; height: number;
} | null; } | null;
}; };
export type ConstrainedScrollValues = Pick<
AppState,
"scrollX" | "scrollY" | "zoom"
> | null;

View File

@ -346,6 +346,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -539,6 +540,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -738,6 +740,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -1111,6 +1114,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -1484,6 +1488,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -1683,6 +1688,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -1919,6 +1925,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2222,6 +2229,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2609,6 +2617,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -3488,6 +3497,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -3861,6 +3871,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -4236,6 +4247,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -4968,6 +4980,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -5548,6 +5561,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -6050,6 +6064,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -6446,6 +6461,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -6820,6 +6836,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,

View File

@ -74,6 +74,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -527,6 +528,7 @@ exports[`given element A and group of elements B and given both are selected whe
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -978,6 +980,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -1808,6 +1811,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2018,6 +2022,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2469,6 +2474,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2706,6 +2712,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -2873,6 +2880,7 @@ exports[`regression tests > can drag element that covers another element, while
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -3312,6 +3320,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -3608,6 +3617,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -3850,6 +3860,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -4103,6 +4114,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -4342,6 +4354,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -4712,6 +4725,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -5006,6 +5020,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -5271,6 +5286,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -5464,6 +5480,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -5627,6 +5644,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -6079,6 +6097,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -6390,6 +6409,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -8457,6 +8477,7 @@ exports[`regression tests > given a group of selected elements with an element t
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -8797,6 +8818,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9037,6 +9059,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9233,6 +9256,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9499,6 +9523,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9664,6 +9689,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9829,6 +9855,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -9994,6 +10021,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -10197,6 +10225,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -10400,6 +10429,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -10583,6 +10613,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -10786,6 +10817,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -10951,6 +10983,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -11154,6 +11187,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -11319,6 +11353,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -11502,6 +11537,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -11671,6 +11707,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -12330,6 +12367,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -12567,6 +12605,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": -2.916666666666668, "scrollX": -2.916666666666668,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -12687,6 +12726,7 @@ exports[`regression tests > rerenders UI on language change > [end of test] appS
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -12809,6 +12849,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -12975,6 +13016,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -13290,6 +13332,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -13844,6 +13887,7 @@ exports[`regression tests > should show fill icons when element has non transpar
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -14057,6 +14101,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"id6": true, "id6": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -14905,6 +14950,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 60, "scrollX": 60,
"scrollY": 60, "scrollY": 60,
"scrolledOutside": false, "scrolledOutside": false,
@ -15027,6 +15073,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"id0": true, "id0": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -15840,6 +15887,7 @@ exports[`regression tests > switches from group of selected elements to another
"id2": true, "id2": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -16236,6 +16284,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"id1": true, "id1": true,
}, },
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -16501,6 +16550,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 10, "scrollX": 10,
"scrollY": -10, "scrollY": -10,
"scrolledOutside": false, "scrolledOutside": false,
@ -16621,6 +16671,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -17101,6 +17152,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,
@ -17221,6 +17273,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"pendingImageElementId": null, "pendingImageElementId": null,
"previousSelectedElementIds": {}, "previousSelectedElementIds": {},
"resizingElement": null, "resizingElement": null,
"scrollConstraints": null,
"scrollX": 0, "scrollX": 0,
"scrollY": 0, "scrollY": 0,
"scrolledOutside": false, "scrolledOutside": false,

View File

@ -238,6 +238,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 | null;
}; };
export type UIAppState = Omit< export type UIAppState = Omit<
@ -376,6 +377,7 @@ export interface ExcalidrawProps {
) => void; ) => void;
onScrollChange?: (scrollX: number, scrollY: number) => void; onScrollChange?: (scrollX: number, scrollY: number) => void;
children?: React.ReactNode; children?: React.ReactNode;
scrollConstraints?: AppState["scrollConstraints"];
validateEmbeddable?: validateEmbeddable?:
| boolean | boolean
| string[] | string[]
@ -574,6 +576,7 @@ export type ExcalidrawImperativeAPI = {
* used in conjunction with view mode (props.viewModeEnabled). * used in conjunction with view mode (props.viewModeEnabled).
*/ */
updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"]; updateFrameRendering: InstanceType<typeof App>["updateFrameRendering"];
setScrollConstraints: InstanceType<typeof App>["setScrollConstraints"];
}; };
export type Device = Readonly<{ export type Device = Readonly<{
@ -602,3 +605,13 @@ export type FrameNameBoundsCache = {
} }
>; >;
}; };
export type ScrollConstraints = {
x: number;
y: number;
width: number;
height: number;
isAnimating?: boolean;
viewportZoomFactor?: number;
lockZoom?: boolean;
};