diff --git a/src/appState.ts b/src/appState.ts index 104fbcbf50..64059c0014 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -98,6 +98,7 @@ export const getDefaultAppState = (): Omit< pendingImageElementId: null, showHyperlinkPopup: false, selectedLinearElement: null, + scrollConstraints: null, }; }; @@ -204,6 +205,7 @@ const APP_STATE_STORAGE_CONF = (< pendingImageElementId: { browser: false, export: false, server: false }, showHyperlinkPopup: { browser: false, export: false, server: false }, selectedLinearElement: { browser: true, export: false, server: false }, + scrollConstraints: { browser: true, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/src/components/App.tsx b/src/components/App.tsx index 7f747dbd41..2fab4122da 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -506,6 +506,7 @@ class App extends React.Component { resetCursor: this.resetCursor, updateFrameRendering: this.updateFrameRendering, toggleSidebar: this.toggleSidebar, + setScrollConstraints: this.setScrollConstraints, } as const; if (typeof excalidrawRef === "function") { excalidrawRef(api); @@ -1728,7 +1729,8 @@ class App extends React.Component { } const scrolledOutside = // hide when editing text - isTextElement(this.state.editingElement) + isTextElement(this.state.editingElement) || + this.state.scrollConstraints ? false : !atLeastOneVisibleElement && renderingElements.length > 0; if (this.state.scrolledOutside !== scrolledOutside) { @@ -2369,7 +2371,25 @@ class App extends React.Component { state, ) => { this.cancelInProgresAnimation?.(); - this.setState(state); + + // When there are no scroll constraints, update the state directly + if (!this.state.scrollConstraints) { + this.setState(state); + } + + // If state is a function, we generate the new state and then apply scroll constraints + if (typeof state === "function") { + this.setState((prevState, props) => { + // Generate new state + const newState = state(prevState, props); + + // Apply scroll constraints to the new state + return this.constrainScroll(newState); + }); + } else { + // If state is not a function, apply scroll constraints directly before updating the state + this.setState(this.constrainScroll(state)); + } }; setToast = ( @@ -7607,6 +7627,74 @@ class App extends React.Component { await setLanguage(currentLang); this.setAppState({}); } + + /** + * Sets the scroll constraints of the application state. + * + * @param scrollConstraints - The new scroll constraints. + */ + public setScrollConstraints = ( + scrollConstraints: AppState["scrollConstraints"], + ) => { + this.setState({ + scrollConstraints, + }); + }; + + /** + * Constrains the scroll position of the app state within the defined scroll constraints. + * The scroll position is adjusted based on the application's current state including zoom level and viewport dimensions. + * + * @param nextState - The next state of the application, or a subset of the application state. + * @returns The modified next state with scrollX and scrollY constrained to the scroll constraints. + */ + private constrainScroll = ( + nextState: AppState | Pick | null, + ) => { + const { scrollX, scrollY, scrollConstraints, width, height, zoom } = + this.state; + + // When no scroll constraints are set, return the nextState as is + if (!scrollConstraints || !nextState) { + return nextState; + } + + // Calculate scaled width and height + const scaledWidth = width / zoom.value; + const scaledHeight = height / zoom.value; + + // Set default constrainedScrollX and constrainedScrollY values + let constrainedScrollX = scrollX; + let constrainedScrollY = scrollY; + + // If scrollX is part of the nextState, constrain it within the scroll constraints + if ("scrollX" in nextState) { + constrainedScrollX = Math.min( + scrollConstraints.x, + Math.max( + nextState.scrollX, + scrollConstraints.x - scrollConstraints.width + scaledWidth, + ), + ); + } + + // If scrollY is part of the nextState, constrain it within the scroll constraints + if ("scrollY" in nextState) { + constrainedScrollY = Math.min( + scrollConstraints.y, + Math.max( + nextState.scrollY, + scrollConstraints.y - scrollConstraints.height + scaledHeight, + ), + ); + } + + return { + ...nextState, + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + }; + }; } // ----------------------------------------------------------------------------- diff --git a/src/types.ts b/src/types.ts index 40f54831dd..86cc9c412f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -223,6 +223,12 @@ export type AppState = { pendingImageElementId: ExcalidrawImageElement["id"] | null; showHyperlinkPopup: false | "info" | "editor"; selectedLinearElement: LinearElementEditor | null; + scrollConstraints: { + x: number; + y: number; + width: number; + height: number; + } | null; }; export type UIAppState = Omit< @@ -549,6 +555,7 @@ export type ExcalidrawImperativeAPI = { * used in conjunction with view mode (props.viewModeEnabled). */ updateFrameRendering: InstanceType["updateFrameRendering"]; + setScrollConstraints: InstanceType["setScrollConstraints"]; }; export type Device = Readonly<{