From 75f8e904cc2690bd1fa5aa8f75756edea62e6343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Fri, 30 Jun 2023 01:27:53 +0200 Subject: [PATCH 01/57] feat: add possibility to limit scroll area --- src/appState.ts | 2 + src/components/App.tsx | 92 +++++++++++++++++++++++++++++++++++++++++- src/types.ts | 7 ++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/appState.ts b/src/appState.ts index 104fbcbf5..64059c001 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 7f747dbd4..2fab4122d 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 40f54831d..86cc9c412 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<{ From a8158691b799df433e092c2eed4c3c3d797b5cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Fri, 30 Jun 2023 17:24:20 +0200 Subject: [PATCH 02/57] feat: limit zoom by translateCanvas --- src/components/App.tsx | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 2fab4122d..71af8ddd4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2249,16 +2249,18 @@ class App extends React.Component { /** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */ value: number, ) => { - this.setState({ - ...getStateForZoom( - { - viewportX: this.state.width / 2 + this.state.offsetLeft, - viewportY: this.state.height / 2 + this.state.offsetTop, - nextZoom: getNormalizedZoom(value), - }, - this.state, - ), - }); + this.setState( + this.constrainScroll({ + ...getStateForZoom( + { + viewportX: this.state.width / 2 + this.state.offsetLeft, + viewportY: this.state.height / 2 + this.state.offsetTop, + nextZoom: getNormalizedZoom(value), + }, + this.state, + ), + }), + ); }; private cancelInProgresAnimation: (() => void) | null = null; @@ -7663,9 +7665,13 @@ class App extends React.Component { const scaledWidth = width / zoom.value; const scaledHeight = height / zoom.value; + const maxZoomX = width / scrollConstraints.width; + const maxZoomY = height / scrollConstraints.height; + // Set default constrainedScrollX and constrainedScrollY values let constrainedScrollX = scrollX; let constrainedScrollY = scrollY; + let constrainedZoom = zoom; // If scrollX is part of the nextState, constrain it within the scroll constraints if ("scrollX" in nextState) { @@ -7689,10 +7695,19 @@ class App extends React.Component { ); } + // If zoom is part of the nextState, constrain it within the scroll constraints + if ("zoom" in nextState) { + const zoomLimit = Math.min(maxZoomX, maxZoomY); + constrainedZoom = { + value: getNormalizedZoom(Math.max(nextState.zoom.value, zoomLimit)), + }; + } + return { ...nextState, scrollX: constrainedScrollX, scrollY: constrainedScrollY, + zoom: constrainedZoom, }; }; } From 209934c90a3fd4be7fb6c667d8754e3814105b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 3 Jul 2023 22:34:26 +0200 Subject: [PATCH 03/57] feat: center constrained area on zoom out --- src/components/App.tsx | 43 +++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 71af8ddd4..27da3e8be 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7640,6 +7640,7 @@ class App extends React.Component { ) => { this.setState({ scrollConstraints, + viewModeEnabled: !!scrollConstraints, }); }; @@ -7661,25 +7662,42 @@ class App extends React.Component { return nextState; } - // Calculate scaled width and height - const scaledWidth = width / zoom.value; - const scaledHeight = height / zoom.value; - + // Calculate maximum zoom for both X and Y axis based on width and height of viewport and scrollable area const maxZoomX = width / scrollConstraints.width; const maxZoomY = height / scrollConstraints.height; + // The smallest zoom out of maxZoomX and maxZoomY is our zoom limit + const zoomLimit = Math.min(maxZoomX, maxZoomY); + // Set default constrainedScrollX and constrainedScrollY values let constrainedScrollX = scrollX; let constrainedScrollY = scrollY; let constrainedZoom = zoom; + // Function to adjust scroll position for centered view depending on the zoom value + const adjustScrollForCenteredView = (zoomValue: number) => { + // If zoom value is less than or equal to maxZoomX, adjust scrollX to ensure the view is centered on the X axis + if (zoomValue <= maxZoomX) { + const centeredScrollX = + (scrollConstraints.width - width / zoomValue) / -2; + constrainedScrollX = scrollConstraints.x + centeredScrollX; + } + + // If zoom value is less than or equal to maxZoomY, adjust scrollY to ensure the view is centered on the Y axis + if (zoomValue <= maxZoomY) { + const centeredScrollY = + (scrollConstraints.height - height / zoomValue) / -2; + constrainedScrollY = scrollConstraints.y + centeredScrollY; + } + }; + // 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, + scrollConstraints.x - scrollConstraints.width + width / zoom.value, ), ); } @@ -7690,19 +7708,26 @@ class App extends React.Component { scrollConstraints.y, Math.max( nextState.scrollY, - scrollConstraints.y - scrollConstraints.height + scaledHeight, + scrollConstraints.y - scrollConstraints.height + height / zoom.value, ), ); } - // If zoom is part of the nextState, constrain it within the scroll constraints - if ("zoom" in nextState) { - const zoomLimit = Math.min(maxZoomX, maxZoomY); + // If zoom is part of the nextState, constrain it within the scroll constraints and adjust for centered view + if ( + "zoom" in nextState && + typeof nextState.zoom === "object" && + nextState.zoom !== null + ) { constrainedZoom = { value: getNormalizedZoom(Math.max(nextState.zoom.value, zoomLimit)), }; } + // Call function to adjust scroll position for centered view depending on the current zoom value + adjustScrollForCenteredView(constrainedZoom.value); + + // Return the nextState with constrained scrollX, scrollY, and zoom values return { ...nextState, scrollX: constrainedScrollX, From 2998573e79207034be0812752db4104e5f95a34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 16:49:00 +0200 Subject: [PATCH 04/57] feat: limit scroll in componentDidUpdate --- src/components/App.tsx | 148 +++++++++++++++++++---------------------- src/scene/types.ts | 5 ++ 2 files changed, 72 insertions(+), 81 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 27da3e8be..55d1edb03 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -199,7 +199,11 @@ import { isSomeElementSelected, } from "../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 { findShapeByKey, SHAPES } from "../shapes"; import { @@ -1621,7 +1625,10 @@ class App extends React.Component { ), ); } - this.renderScene(); + + const constraintedScroll = this.constrainScroll(prevState); + + this.renderScene(constraintedScroll); this.history.record(this.state, this.scene.getElementsIncludingDeleted()); // Do not notify consumers if we're still loading the scene. Among other @@ -1637,7 +1644,7 @@ class App extends React.Component { } } - private renderScene = () => { + private renderScene = (constrainedScroll?: ConstrainedScrollValues) => { const cursorButton: { [id: string]: string | undefined; } = {}; @@ -1708,10 +1715,10 @@ class App extends React.Component { canvas: this.canvas!, renderConfig: { selectionColor, - scrollX: this.state.scrollX, - scrollY: this.state.scrollY, + scrollX: constrainedScroll?.scrollX ?? this.state.scrollX, + scrollY: constrainedScroll?.scrollY ?? this.state.scrollY, viewBackgroundColor: this.state.viewBackgroundColor, - zoom: this.state.zoom, + zoom: constrainedScroll?.zoom ?? this.state.zoom, remotePointerViewportCoords: pointerViewportCoords, remotePointerButton: cursorButton, remoteSelectedElementIds, @@ -2250,16 +2257,14 @@ class App extends React.Component { value: number, ) => { this.setState( - this.constrainScroll({ - ...getStateForZoom( - { - viewportX: this.state.width / 2 + this.state.offsetLeft, - viewportY: this.state.height / 2 + this.state.offsetTop, - nextZoom: getNormalizedZoom(value), - }, - this.state, - ), - }), + getStateForZoom( + { + viewportX: this.state.width / 2 + this.state.offsetLeft, + viewportY: this.state.height / 2 + this.state.offsetTop, + nextZoom: getNormalizedZoom(value), + }, + this.state, + ), ); }; @@ -2373,25 +2378,7 @@ class App extends React.Component { state, ) => { this.cancelInProgresAnimation?.(); - - // 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)); - } + this.setState(state); }; setToast = ( @@ -7651,15 +7638,17 @@ class App extends React.Component { * @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, - ) => { + private constrainScroll = (prevState: AppState): ConstrainedScrollValues => { 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; + if ( + !scrollConstraints || + (this.state.zoom.value === prevState.zoom.value && + this.state.scrollX === prevState.scrollX && + this.state.scrollY === prevState.scrollY) + ) { + return null; } // Calculate maximum zoom for both X and Y axis based on width and height of viewport and scrollable area @@ -7691,49 +7680,46 @@ class App extends React.Component { } }; - // 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 + width / zoom.value, - ), - ); - } + // Constrain scrollX and scrollY within the scroll constraints + constrainedScrollX = Math.min( + scrollConstraints.x, + Math.max( + scrollX, + scrollConstraints.x - scrollConstraints.width + width / zoom.value, + ), + ); - // 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 + height / zoom.value, - ), - ); - } + constrainedScrollY = Math.min( + scrollConstraints.y, + Math.max( + scrollY, + scrollConstraints.y - scrollConstraints.height + height / zoom.value, + ), + ); - // If zoom is part of the nextState, constrain it within the scroll constraints and adjust for centered view - if ( - "zoom" in nextState && - typeof nextState.zoom === "object" && - nextState.zoom !== null - ) { - constrainedZoom = { - value: getNormalizedZoom(Math.max(nextState.zoom.value, zoomLimit)), - }; - } - - // Call function to adjust scroll position for centered view depending on the current zoom value - adjustScrollForCenteredView(constrainedZoom.value); - - // Return the nextState with constrained scrollX, scrollY, and zoom values - return { - ...nextState, - scrollX: constrainedScrollX, - scrollY: constrainedScrollY, - zoom: constrainedZoom, + // Constrain zoom within the scroll constraints and adjust for centered view + constrainedZoom = { + value: getNormalizedZoom(Math.max(zoom.value, zoomLimit)), }; + + adjustScrollForCenteredView(zoom.value); + + // If any of the values have changed, set new state + if ( + constrainedScrollX !== this.state.scrollX || + constrainedScrollY !== this.state.scrollY || + constrainedZoom.value !== this.state.zoom.value + ) { + const constrainedState = { + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + zoom: constrainedZoom, + }; + this.setState(constrainedState); + return constrainedState; + } + + return null; }; } diff --git a/src/scene/types.ts b/src/scene/types.ts index a54b02b26..4a8463e1a 100644 --- a/src/scene/types.ts +++ b/src/scene/types.ts @@ -60,3 +60,8 @@ export type ScrollBars = { height: number; } | null; }; + +export type ConstrainedScrollValues = Pick< + AppState, + "scrollX" | "scrollY" | "zoom" +> | null; From 71f7960606999e68ad51ecd199f503283c39864f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 16:52:59 +0200 Subject: [PATCH 05/57] test: fix test snapshots --- .../__snapshots__/contextmenu.test.tsx.snap | 17 ++++++ .../regressionTests.test.tsx.snap | 53 +++++++++++++++++++ .../packages/__snapshots__/utils.test.ts.snap | 1 + 3 files changed, 71 insertions(+) diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 583c809a2..962ae3ade 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -345,6 +345,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -537,6 +538,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -735,6 +737,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1107,6 +1110,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1479,6 +1483,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1677,6 +1682,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1912,6 +1918,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2214,6 +2221,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2600,6 +2608,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3478,6 +3487,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3850,6 +3860,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4224,6 +4235,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4955,6 +4967,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5534,6 +5547,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6035,6 +6049,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6430,6 +6445,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6803,6 +6819,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, diff --git a/src/tests/__snapshots__/regressionTests.test.tsx.snap b/src/tests/__snapshots__/regressionTests.test.tsx.snap index fd2c2f5d7..450dab3b7 100644 --- a/src/tests/__snapshots__/regressionTests.test.tsx.snap +++ b/src/tests/__snapshots__/regressionTests.test.tsx.snap @@ -73,6 +73,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -625,6 +626,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -1175,6 +1177,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2108,6 +2111,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2349,6 +2353,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -2899,6 +2904,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3199,6 +3205,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3397,6 +3404,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -3931,6 +3939,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4258,6 +4267,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4499,6 +4509,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -4783,6 +4794,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5085,6 +5097,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5550,6 +5563,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -5907,6 +5921,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6235,6 +6250,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6459,6 +6475,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -6653,6 +6670,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7206,6 +7224,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -7580,6 +7599,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -9998,6 +10018,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10433,6 +10454,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10736,6 +10758,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -10995,6 +11018,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11324,6 +11348,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11520,6 +11545,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11716,6 +11742,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -11912,6 +11939,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12161,6 +12189,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12410,6 +12439,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12646,6 +12676,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -12895,6 +12926,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13091,6 +13123,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13340,6 +13373,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13536,6 +13570,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13772,6 +13807,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -13972,6 +14008,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -14834,6 +14871,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -15134,6 +15172,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": -2.916666666666668, "scrollY": 0, "scrolledOutside": false, @@ -15253,6 +15292,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -15374,6 +15414,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -15571,6 +15612,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -15949,6 +15991,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -16598,6 +16641,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -16842,6 +16886,7 @@ Object { "id6": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -17829,6 +17874,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 60, "scrollY": 60, "scrolledOutside": false, @@ -17950,6 +17996,7 @@ Object { "id0": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -18866,6 +18913,7 @@ Object { "id2": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -19357,6 +19405,7 @@ Object { "id1": true, }, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -19685,6 +19734,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 10, "scrollY": -10, "scrolledOutside": false, @@ -19804,6 +19854,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -20397,6 +20448,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, @@ -20516,6 +20568,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index 34085498f..61e456b04 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -67,6 +67,7 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, + "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, From f7e8056abeb55d1359f07a47c17a8e78faba3451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 17:10:20 +0200 Subject: [PATCH 06/57] feat: update constraints on window resize --- src/components/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 55d1edb03..15ee6c61a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7642,11 +7642,15 @@ class App extends React.Component { const { scrollX, scrollY, scrollConstraints, width, height, zoom } = this.state; + // Skip if scroll constraints are not defined or if the zoom level or viewport dimensions have not changed. + // Constrains and scene will update on change of viewport dimensions. if ( !scrollConstraints || (this.state.zoom.value === prevState.zoom.value && this.state.scrollX === prevState.scrollX && - this.state.scrollY === prevState.scrollY) + this.state.scrollY === prevState.scrollY && + this.state.width === prevState.width && + this.state.height === prevState.height) ) { return null; } From 35b43c14d89a37b782b2aa0863fce7f76ca1cadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 18:20:01 +0200 Subject: [PATCH 07/57] feat: allow scroll over constraints while mouse down --- src/components/App.tsx | 157 ++++++++++++++++++++++++----------------- 1 file changed, 94 insertions(+), 63 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 15ee6c61a..27f44ddc8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1495,6 +1495,7 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { + console.log(this.state.cursorButton); if ( !this.state.showWelcomeScreen && !this.scene.getElementsIncludingDeleted().length @@ -7639,86 +7640,116 @@ class App extends React.Component { * @returns The modified next state with scrollX and scrollY constrained to the scroll constraints. */ private constrainScroll = (prevState: AppState): ConstrainedScrollValues => { - const { scrollX, scrollY, scrollConstraints, width, height, zoom } = - this.state; + const { + scrollX, + scrollY, + scrollConstraints, + width, + height, + zoom, + cursorButton, + } = this.state; - // Skip if scroll constraints are not defined or if the zoom level or viewport dimensions have not changed. - // Constrains and scene will update on change of viewport dimensions. - if ( - !scrollConstraints || - (this.state.zoom.value === prevState.zoom.value && - this.state.scrollX === prevState.scrollX && - this.state.scrollY === prevState.scrollY && - this.state.width === prevState.width && - this.state.height === prevState.height) - ) { + // Set the overscroll allowance percentage + const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.1; + + // Check if the state has changed since the last render + const stateUnchanged = + zoom.value === prevState.zoom.value && + scrollX === prevState.scrollX && + scrollY === prevState.scrollY && + width === prevState.width && + height === prevState.height && + cursorButton === prevState.cursorButton; + + // If the state hasn't changed or the scroll constraints are not defined, return null + if (!scrollConstraints || stateUnchanged) { return null; } - // Calculate maximum zoom for both X and Y axis based on width and height of viewport and scrollable area - const maxZoomX = width / scrollConstraints.width; - const maxZoomY = height / scrollConstraints.height; - - // The smallest zoom out of maxZoomX and maxZoomY is our zoom limit + // Calculate the maximum possible zoom based on the viewport and scrollable area sizes + const scrollableWidth = scrollConstraints.width; + const scrollableHeight = scrollConstraints.height; + const maxZoomX = width / scrollableWidth; + const maxZoomY = height / scrollableHeight; const zoomLimit = Math.min(maxZoomX, maxZoomY); - // Set default constrainedScrollX and constrainedScrollY values + // Default scroll and zoom values let constrainedScrollX = scrollX; let constrainedScrollY = scrollY; - let constrainedZoom = zoom; - - // Function to adjust scroll position for centered view depending on the zoom value - const adjustScrollForCenteredView = (zoomValue: number) => { - // If zoom value is less than or equal to maxZoomX, adjust scrollX to ensure the view is centered on the X axis - if (zoomValue <= maxZoomX) { - const centeredScrollX = - (scrollConstraints.width - width / zoomValue) / -2; - constrainedScrollX = scrollConstraints.x + centeredScrollX; - } - - // If zoom value is less than or equal to maxZoomY, adjust scrollY to ensure the view is centered on the Y axis - if (zoomValue <= maxZoomY) { - const centeredScrollY = - (scrollConstraints.height - height / zoomValue) / -2; - constrainedScrollY = scrollConstraints.y + centeredScrollY; - } - }; - - // Constrain scrollX and scrollY within the scroll constraints - constrainedScrollX = Math.min( - scrollConstraints.x, - Math.max( - scrollX, - scrollConstraints.x - scrollConstraints.width + width / zoom.value, - ), - ); - - constrainedScrollY = Math.min( - scrollConstraints.y, - Math.max( - scrollY, - scrollConstraints.y - scrollConstraints.height + height / zoom.value, - ), - ); - - // Constrain zoom within the scroll constraints and adjust for centered view - constrainedZoom = { + const constrainedZoom = { value: getNormalizedZoom(Math.max(zoom.value, zoomLimit)), }; - adjustScrollForCenteredView(zoom.value); + // Calculate the overscroll allowance for each axis + const overscrollAllowanceX = + OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollableWidth; + const overscrollAllowanceY = + OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollableHeight; - // If any of the values have changed, set new state - if ( - constrainedScrollX !== this.state.scrollX || - constrainedScrollY !== this.state.scrollY || - constrainedZoom.value !== this.state.zoom.value - ) { + // Define the maximum and minimum scroll for each axis based on the cursor button state + const maxScrollX = + cursorButton === "down" + ? scrollConstraints.x + overscrollAllowanceX + : scrollConstraints.x; + const minScrollX = + cursorButton === "down" + ? scrollConstraints.x - + scrollableWidth + + width / zoom.value - + overscrollAllowanceX + : scrollConstraints.x - scrollableWidth + width / zoom.value; + + const maxScrollY = + cursorButton === "down" + ? scrollConstraints.y + overscrollAllowanceY + : scrollConstraints.y; + const minScrollY = + cursorButton === "down" + ? scrollConstraints.y - + scrollableHeight + + height / zoom.value - + overscrollAllowanceY + : scrollConstraints.y - scrollableHeight + height / zoom.value; + + // Constrain the scroll within the scroll constraints, with overscroll allowance if the cursor button is down + constrainedScrollX = Math.min(maxScrollX, Math.max(scrollX, minScrollX)); + constrainedScrollY = Math.min(maxScrollY, Math.max(scrollY, minScrollY)); + + // Check if the zoom value requires adjustment for a centered view + const shouldAdjustForCenteredView = + zoom.value <= maxZoomX || zoom.value <= maxZoomY; + if (shouldAdjustForCenteredView) { + // Adjust the scroll position for a centered view based on the zoom value + const adjustScrollForCenteredView = (zoomValue: number) => { + if (zoomValue <= maxZoomX) { + const centeredScrollX = (scrollableWidth - width / zoomValue) / -2; + constrainedScrollX = scrollConstraints.x + centeredScrollX; + } + + if (zoomValue <= maxZoomY) { + const centeredScrollY = (scrollableHeight - height / zoomValue) / -2; + constrainedScrollY = scrollConstraints.y + centeredScrollY; + } + }; + + adjustScrollForCenteredView(zoom.value); + } + + // Check if the new state differs from the old state + const stateChanged = + constrainedScrollX !== scrollX || + constrainedScrollY !== scrollY || + constrainedZoom.value !== zoom.value; + + // If the state has changed, update the state and return the new state + if (stateChanged) { const constrainedState = { scrollX: constrainedScrollX, scrollY: constrainedScrollY, zoom: constrainedZoom, }; + this.setState(constrainedState); return constrainedState; } From 485c57fd593c95e2a333e9d9dc6476180ac07e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 18:23:40 +0200 Subject: [PATCH 08/57] chore: remove console.log --- src/components/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 27f44ddc8..993141d21 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1495,7 +1495,6 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { - console.log(this.state.cursorButton); if ( !this.state.showWelcomeScreen && !this.scene.getElementsIncludingDeleted().length From f82363aae9da535e05d2b6cf3f7b7b0d83eff80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 4 Jul 2023 19:01:05 +0200 Subject: [PATCH 09/57] feat: enforce constrains on setting constrains --- src/components/App.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 993141d21..daebe197c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7661,8 +7661,11 @@ class App extends React.Component { height === prevState.height && cursorButton === prevState.cursorButton; - // If the state hasn't changed or the scroll constraints are not defined, return null - if (!scrollConstraints || stateUnchanged) { + // If the state hasn't changed and scrollConstraints didn't just get defined, return null + if ( + !scrollConstraints || + (stateUnchanged && (prevState.scrollConstraints || !scrollConstraints)) + ) { return null; } From 381ef9395627ff6db1c5068d8ea68ca1fe6d42d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Wed, 5 Jul 2023 14:45:36 +0200 Subject: [PATCH 10/57] feat: remove zoom limit --- src/components/App.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index daebe197c..686c638cf 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7650,7 +7650,7 @@ class App extends React.Component { } = this.state; // Set the overscroll allowance percentage - const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.1; + const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; // Check if the state has changed since the last render const stateUnchanged = @@ -7674,14 +7674,10 @@ class App extends React.Component { const scrollableHeight = scrollConstraints.height; const maxZoomX = width / scrollableWidth; const maxZoomY = height / scrollableHeight; - const zoomLimit = Math.min(maxZoomX, maxZoomY); // Default scroll and zoom values let constrainedScrollX = scrollX; let constrainedScrollY = scrollY; - const constrainedZoom = { - value: getNormalizedZoom(Math.max(zoom.value, zoomLimit)), - }; // Calculate the overscroll allowance for each axis const overscrollAllowanceX = @@ -7740,16 +7736,14 @@ class App extends React.Component { // Check if the new state differs from the old state const stateChanged = - constrainedScrollX !== scrollX || - constrainedScrollY !== scrollY || - constrainedZoom.value !== zoom.value; + constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; // If the state has changed, update the state and return the new state if (stateChanged) { const constrainedState = { scrollX: constrainedScrollX, scrollY: constrainedScrollY, - zoom: constrainedZoom, + zoom, }; this.setState(constrainedState); From 19ba1070410cc91dc1cb9296792ce9e7b8f59121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Fri, 7 Jul 2023 15:35:10 +0200 Subject: [PATCH 11/57] feat: pass scrollConstraints via props --- src/actions/types.ts | 2 +- src/appState.ts | 5 ++--- src/components/App.tsx | 5 +++++ src/data/restore.ts | 2 +- src/index-node.ts | 1 + src/packages/excalidraw/index.tsx | 2 ++ src/packages/utils.ts | 9 ++++++++- src/types.ts | 1 + 8 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/actions/types.ts b/src/actions/types.ts index ab20a896b..0a6271a36 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -16,7 +16,7 @@ export type ActionResult = elements?: readonly ExcalidrawElement[] | null; appState?: MarkOptional< AppState, - "offsetTop" | "offsetLeft" | "width" | "height" + "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints" > | null; files?: BinaryFiles | null; commitToHistory: boolean; diff --git a/src/appState.ts b/src/appState.ts index 64059c001..ad05fca4f 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -17,7 +17,7 @@ const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) export const getDefaultAppState = (): Omit< AppState, - "offsetTop" | "offsetLeft" | "width" | "height" + "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints" > => { return { showWelcomeScreen: false, @@ -98,7 +98,6 @@ export const getDefaultAppState = (): Omit< pendingImageElementId: null, showHyperlinkPopup: false, selectedLinearElement: null, - scrollConstraints: null, }; }; @@ -205,7 +204,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 }, + scrollConstraints: { browser: false, export: false, server: false }, }); const _clearAppStateForStorage = < diff --git a/src/components/App.tsx b/src/components/App.tsx index 686c638cf..d26b13175 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -366,6 +366,7 @@ const ExcalidrawAppStateContext = React.createContext({ height: 0, offsetLeft: 0, offsetTop: 0, + scrollConstraints: null, }); ExcalidrawAppStateContext.displayName = "ExcalidrawAppStateContext"; @@ -466,7 +467,9 @@ class App extends React.Component { gridModeEnabled = false, theme = defaultAppState.theme, name = defaultAppState.name, + scrollConstraints, } = props; + this.state = { ...defaultAppState, theme, @@ -478,6 +481,7 @@ class App extends React.Component { name, width: window.innerWidth, height: window.innerHeight, + scrollConstraints: scrollConstraints ?? null, }; this.id = nanoid(); @@ -1209,6 +1213,7 @@ class App extends React.Component { height: this.state.height, offsetTop: this.state.offsetTop, offsetLeft: this.state.offsetLeft, + scrollConstraints: this.state.scrollConstraints, }, null, ), diff --git a/src/data/restore.ts b/src/data/restore.ts index 5f2adc004..4d8212ba5 100644 --- a/src/data/restore.ts +++ b/src/data/restore.ts @@ -45,7 +45,7 @@ import { normalizeLink } from "./url"; type RestoredAppState = Omit< AppState, - "offsetTop" | "offsetLeft" | "width" | "height" + "offsetTop" | "offsetLeft" | "width" | "height" | "scrollConstraints" >; export const AllowedExcalidrawActiveTools: Record< diff --git a/src/index-node.ts b/src/index-node.ts index e966b1d52..bd049802a 100644 --- a/src/index-node.ts +++ b/src/index-node.ts @@ -65,6 +65,7 @@ const canvas = exportToCanvas( offsetLeft: 0, width: 0, height: 0, + scrollConstraints: null, }, {}, // files { diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 8ee2956bb..02056bbb5 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -42,6 +42,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onPointerDown, onScrollChange, children, + scrollConstraints, } = props; const canvasActions = props.UIOptions?.canvasActions; @@ -115,6 +116,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onLinkOpen={onLinkOpen} onPointerDown={onPointerDown} onScrollChange={onScrollChange} + scrollConstraints={scrollConstraints} > {children} diff --git a/src/packages/utils.ts b/src/packages/utils.ts index d9365895e..629b2dee7 100644 --- a/src/packages/utils.ts +++ b/src/packages/utils.ts @@ -64,7 +64,14 @@ export const exportToCanvas = ({ const { exportBackground, viewBackgroundColor } = restoredAppState; return _exportToCanvas( passElementsSafely(restoredElements), - { ...restoredAppState, offsetTop: 0, offsetLeft: 0, width: 0, height: 0 }, + { + ...restoredAppState, + offsetTop: 0, + offsetLeft: 0, + width: 0, + height: 0, + scrollConstraints: null, + }, files || {}, { exportBackground, exportPadding, viewBackgroundColor }, (width: number, height: number) => { diff --git a/src/types.ts b/src/types.ts index 86cc9c412..25f42872b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -367,6 +367,7 @@ export interface ExcalidrawProps { ) => void; onScrollChange?: (scrollX: number, scrollY: number) => void; children?: React.ReactNode; + scrollConstraints?: AppState["scrollConstraints"]; } export type SceneData = { From bc44c3f947ac478ae17c2bf0e770c061fad6a03c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 11:56:23 +0200 Subject: [PATCH 12/57] feat: add overscroll when constrained area is smaller than viewport --- src/components/App.tsx | 107 ++++++++++++++++++++++++----------------- 1 file changed, 64 insertions(+), 43 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index d26b13175..9316a83e5 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7690,55 +7690,76 @@ class App extends React.Component { const overscrollAllowanceY = OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollableHeight; - // Define the maximum and minimum scroll for each axis based on the cursor button state - const maxScrollX = - cursorButton === "down" - ? scrollConstraints.x + overscrollAllowanceX - : scrollConstraints.x; - const minScrollX = - cursorButton === "down" - ? scrollConstraints.x - - scrollableWidth + - width / zoom.value - - overscrollAllowanceX - : scrollConstraints.x - scrollableWidth + width / zoom.value; - - const maxScrollY = - cursorButton === "down" - ? scrollConstraints.y + overscrollAllowanceY - : scrollConstraints.y; - const minScrollY = - cursorButton === "down" - ? scrollConstraints.y - - scrollableHeight + - height / zoom.value - - overscrollAllowanceY - : scrollConstraints.y - scrollableHeight + height / zoom.value; - - // Constrain the scroll within the scroll constraints, with overscroll allowance if the cursor button is down - constrainedScrollX = Math.min(maxScrollX, Math.max(scrollX, minScrollX)); - constrainedScrollY = Math.min(maxScrollY, Math.max(scrollY, minScrollY)); - - // Check if the zoom value requires adjustment for a centered view + // When we are zoomed out enough to contain constrained area in the viewport we will center the view const shouldAdjustForCenteredView = zoom.value <= maxZoomX || zoom.value <= maxZoomY; - if (shouldAdjustForCenteredView) { - // Adjust the scroll position for a centered view based on the zoom value - const adjustScrollForCenteredView = (zoomValue: number) => { - if (zoomValue <= maxZoomX) { - const centeredScrollX = (scrollableWidth - width / zoomValue) / -2; - constrainedScrollX = scrollConstraints.x + centeredScrollX; - } - if (zoomValue <= maxZoomY) { - const centeredScrollY = (scrollableHeight - height / zoomValue) / -2; - constrainedScrollY = scrollConstraints.y + centeredScrollY; - } - }; + // When viewport is smaller than the scrollable area, user can pan freely within the constrained area, + // otherwilse the viewport is centered to the center of the scrollable area + let maxScrollX; + let minScrollX; + let maxScrollY; + let minScrollY; - adjustScrollForCenteredView(zoom.value); + // Get center of scrollable area + const constrainedScrollCenterX = + scrollConstraints.x + (scrollableWidth - width / zoom.value) / -2; + const constrainedScrollCenterY = + scrollConstraints.y + (scrollableHeight - height / zoom.value) / -2; + + switch (true) { + case cursorButton === "down" && shouldAdjustForCenteredView: + // case when cursor button is down and we should adjust for centered view + + maxScrollX = constrainedScrollCenterX + overscrollAllowanceX; + minScrollX = constrainedScrollCenterX - overscrollAllowanceX; + + maxScrollY = constrainedScrollCenterY + overscrollAllowanceY; + minScrollY = constrainedScrollCenterY - overscrollAllowanceY; + break; + + case cursorButton === "down" && !shouldAdjustForCenteredView: + // case when cursor button is down and we should not adjust for centered view + maxScrollX = scrollConstraints.x + overscrollAllowanceX; + minScrollX = + scrollConstraints.x - + scrollableWidth + + width / zoom.value - + overscrollAllowanceX; + + maxScrollY = scrollConstraints.y + overscrollAllowanceY; + minScrollY = + scrollConstraints.y - + scrollableHeight + + height / zoom.value - + overscrollAllowanceY; + break; + + case cursorButton !== "down" && shouldAdjustForCenteredView: + // case when cursor button is not down and we should adjust for centered view + + maxScrollX = constrainedScrollCenterX; + minScrollX = constrainedScrollCenterX; + + maxScrollY = constrainedScrollCenterY; + minScrollY = constrainedScrollCenterY; + break; + + default: + // case when cursor button is not down and we should not adjust for centered view + maxScrollX = scrollConstraints.x; + minScrollX = scrollConstraints.x - scrollableWidth + width / zoom.value; + + maxScrollY = scrollConstraints.y; + minScrollY = + scrollConstraints.y - scrollableHeight + height / zoom.value; + break; } + // Constrain the scroll within the scroll constraints + constrainedScrollX = Math.min(maxScrollX, Math.max(scrollX, minScrollX)); + constrainedScrollY = Math.min(maxScrollY, Math.max(scrollY, minScrollY)); + // Check if the new state differs from the old state const stateChanged = constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; From 82014fe670540a8c4880a468780485fa9cdbcc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 12:07:12 +0200 Subject: [PATCH 13/57] chore: comments and variable renaming --- src/components/App.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 9316a83e5..3b15d5639 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7667,13 +7667,12 @@ class App extends React.Component { cursorButton === prevState.cursorButton; // If the state hasn't changed and scrollConstraints didn't just get defined, return null - if ( - !scrollConstraints || - (stateUnchanged && (prevState.scrollConstraints || !scrollConstraints)) - ) { + if (!scrollConstraints || (stateUnchanged && prevState.scrollConstraints)) { return null; } + console.log("fired"); + // Calculate the maximum possible zoom based on the viewport and scrollable area sizes const scrollableWidth = scrollConstraints.width; const scrollableHeight = scrollConstraints.height; @@ -7707,6 +7706,10 @@ class App extends React.Component { const constrainedScrollCenterY = scrollConstraints.y + (scrollableHeight - height / zoom.value) / -2; + // We're using a `switch(true)` construction here to handle a set of conditions + // that can't be easily grouped into a regular switch statement. + // Each case represents a unique combination of cursorButton state and + // whether or not we should adjust for a centered view (constrained area is smaller than viewport). switch (true) { case cursorButton === "down" && shouldAdjustForCenteredView: // case when cursor button is down and we should adjust for centered view @@ -7761,11 +7764,11 @@ class App extends React.Component { constrainedScrollY = Math.min(maxScrollY, Math.max(scrollY, minScrollY)); // Check if the new state differs from the old state - const stateChanged = + // and if the state has changed, update the state and return the new state + const isStateChanged = constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; - // If the state has changed, update the state and return the new state - if (stateChanged) { + if (isStateChanged) { const constrainedState = { scrollX: constrainedScrollX, scrollY: constrainedScrollY, From 7fb6c2371524e2e9a48c96df74ef054f71cff142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 12:14:38 +0200 Subject: [PATCH 14/57] fix: remove forgotten console.log --- src/components/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 3b15d5639..9983a9591 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7671,8 +7671,6 @@ class App extends React.Component { return null; } - console.log("fired"); - // Calculate the maximum possible zoom based on the viewport and scrollable area sizes const scrollableWidth = scrollConstraints.width; const scrollableHeight = scrollConstraints.height; From 7336b1c27697ee35a7779783a0ed279e5422fea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 12:59:14 +0200 Subject: [PATCH 15/57] test: update snapshot --- src/tests/packages/__snapshots__/utils.test.ts.snap | 1 - 1 file changed, 1 deletion(-) diff --git a/src/tests/packages/__snapshots__/utils.test.ts.snap b/src/tests/packages/__snapshots__/utils.test.ts.snap index 61e456b04..34085498f 100644 --- a/src/tests/packages/__snapshots__/utils.test.ts.snap +++ b/src/tests/packages/__snapshots__/utils.test.ts.snap @@ -67,7 +67,6 @@ Object { "pendingImageElementId": null, "previousSelectedElementIds": Object {}, "resizingElement": null, - "scrollConstraints": null, "scrollX": 0, "scrollY": 0, "scrolledOutside": false, From c0bd9027cbe43edf1aff09691f7a5758bd805ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 21:04:34 +0200 Subject: [PATCH 16/57] feat: animate the scroll to constrained area --- src/components/App.tsx | 54 ++++++++++++++++++++++++++++++++++++++---- src/types.ts | 1 + 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 9983a9591..aa9590fc8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7657,6 +7657,10 @@ class App extends React.Component { // Set the overscroll allowance percentage const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; + if (!scrollConstraints || scrollConstraints.isAnimating) { + return null; + } + // Check if the state has changed since the last render const stateUnchanged = zoom.value === prevState.zoom.value && @@ -7671,11 +7675,11 @@ class App extends React.Component { return null; } - // Calculate the maximum possible zoom based on the viewport and scrollable area sizes + // Calculate the zoom level on which will constrained area fit the viewport for each axis const scrollableWidth = scrollConstraints.width; const scrollableHeight = scrollConstraints.height; - const maxZoomX = width / scrollableWidth; - const maxZoomY = height / scrollableHeight; + const zoomLevelX = width / scrollableWidth; + const zoomLevelY = height / scrollableHeight; // Default scroll and zoom values let constrainedScrollX = scrollX; @@ -7689,7 +7693,7 @@ class App extends React.Component { // When we are zoomed out enough to contain constrained area in the viewport we will center the view const shouldAdjustForCenteredView = - zoom.value <= maxZoomX || zoom.value <= maxZoomY; + zoom.value <= zoomLevelX || zoom.value <= zoomLevelY; // When viewport is smaller than the scrollable area, user can pan freely within the constrained area, // otherwilse the viewport is centered to the center of the scrollable area @@ -7767,6 +7771,48 @@ class App extends React.Component { constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; if (isStateChanged) { + // Animate the scroll position when the cursor button is not down and scroll position is outside of the scroll constraints + if ( + (scrollX < scrollConstraints.x || + scrollX + width > scrollConstraints.x + scrollConstraints.width || + scrollY < scrollConstraints.y || + scrollY + height > scrollConstraints.y + scrollConstraints.height) && + cursorButton !== "down" && + !scrollConstraints.isAnimating + ) { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: true }, + }); + + easeToValuesRAF({ + fromValues: { scrollX, scrollY }, + toValues: { + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + }, + onStep: ({ scrollX, scrollY }) => { + console.log("onStep"); + this.setState({ + scrollX, + scrollY, + }); + }, + onStart: () => { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: true }, + }); + }, + onEnd: () => { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: false }, + }); + }, + }); + + console.log("isAnimating"); + return null; + } + const constrainedState = { scrollX: constrainedScrollX, scrollY: constrainedScrollY, diff --git a/src/types.ts b/src/types.ts index 25f42872b..f048db85b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -228,6 +228,7 @@ export type AppState = { y: number; width: number; height: number; + isAnimating?: boolean; } | null; }; From 71918e57a84258bf82d0b0badbde3d3ec9314fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 21:09:10 +0200 Subject: [PATCH 17/57] feat: cleanup --- src/components/App.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index aa9590fc8..da46e27f7 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7777,8 +7777,7 @@ class App extends React.Component { scrollX + width > scrollConstraints.x + scrollConstraints.width || scrollY < scrollConstraints.y || scrollY + height > scrollConstraints.y + scrollConstraints.height) && - cursorButton !== "down" && - !scrollConstraints.isAnimating + cursorButton !== "down" ) { this.setState({ scrollConstraints: { ...scrollConstraints, isAnimating: true }, @@ -7791,7 +7790,6 @@ class App extends React.Component { scrollY: constrainedScrollY, }, onStep: ({ scrollX, scrollY }) => { - console.log("onStep"); this.setState({ scrollX, scrollY, @@ -7809,7 +7807,6 @@ class App extends React.Component { }, }); - console.log("isAnimating"); return null; } From 92be92071a4a17c56cdd1b4c54ec8281166c880c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 23:26:26 +0200 Subject: [PATCH 18/57] feat: disable animation on zooming --- src/components/App.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index da46e27f7..fcd95499b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7771,13 +7771,15 @@ class App extends React.Component { constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; if (isStateChanged) { - // Animate the scroll position when the cursor button is not down and scroll position is outside of the scroll constraints + // Animate the scroll position when the cursor button is not down and scroll position is outside of the scroll constraints. + // We don't want to animate the scroll position when the user is dragging the canvas or zooiming in/out. if ( (scrollX < scrollConstraints.x || scrollX + width > scrollConstraints.x + scrollConstraints.width || scrollY < scrollConstraints.y || scrollY + height > scrollConstraints.y + scrollConstraints.height) && - cursorButton !== "down" + cursorButton !== "down" && + zoom.value === prevState.zoom.value ) { this.setState({ scrollConstraints: { ...scrollConstraints, isAnimating: true }, From e8e391e4650fdd8cd08b48dcc47a43d3dcc17b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 9 Jul 2023 23:50:59 +0200 Subject: [PATCH 19/57] refactor: split constrainScroll into smaller functions --- src/components/App.tsx | 298 +++++++++++++++++++++++++---------------- 1 file changed, 179 insertions(+), 119 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index fcd95499b..be56e086d 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7675,154 +7675,214 @@ class App extends React.Component { return null; } - // Calculate the zoom level on which will constrained area fit the viewport for each axis - const scrollableWidth = scrollConstraints.width; - const scrollableHeight = scrollConstraints.height; - const zoomLevelX = width / scrollableWidth; - const zoomLevelY = height / scrollableHeight; + /** + * Calculate the zoom levels on which will constrained area fits the viewport for each axis + * @returns The zoom levels for the X and Y axes. + */ + const calculateZoomLevel = () => { + const scrollableWidth = scrollConstraints.width; + const scrollableHeight = scrollConstraints.height; + const zoomLevelX = width / scrollableWidth; + const zoomLevelY = height / scrollableHeight; + return { zoomLevelX, zoomLevelY }; + }; - // Default scroll and zoom values - let constrainedScrollX = scrollX; - let constrainedScrollY = scrollY; + /** + * Calculates the center position of the constrained scroll area. + * @returns The X and Y coordinates of the center position. + */ + const calculateConstrainedScrollCenter = () => { + const constrainedScrollCenterX = + scrollConstraints.x + + (scrollConstraints.width - width / zoom.value) / -2; + const constrainedScrollCenterY = + scrollConstraints.y + + (scrollConstraints.height - height / zoom.value) / -2; + return { constrainedScrollCenterX, constrainedScrollCenterY }; + }; - // Calculate the overscroll allowance for each axis - const overscrollAllowanceX = - OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollableWidth; - const overscrollAllowanceY = - OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollableHeight; + /** + * 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 }; + }; - // When we are zoomed out enough to contain constrained area in the viewport we will center the view - const shouldAdjustForCenteredView = - zoom.value <= zoomLevelX || zoom.value <= zoomLevelY; - - // When viewport is smaller than the scrollable area, user can pan freely within the constrained area, - // otherwilse the viewport is centered to the center of the scrollable area - let maxScrollX; - let minScrollX; - let maxScrollY; - let minScrollY; - - // Get center of scrollable area - const constrainedScrollCenterX = - scrollConstraints.x + (scrollableWidth - width / zoom.value) / -2; - const constrainedScrollCenterY = - scrollConstraints.y + (scrollableHeight - height / zoom.value) / -2; - - // We're using a `switch(true)` construction here to handle a set of conditions - // that can't be easily grouped into a regular switch statement. - // Each case represents a unique combination of cursorButton state and - // whether or not we should adjust for a centered view (constrained area is smaller than viewport). - switch (true) { - case cursorButton === "down" && shouldAdjustForCenteredView: - // case when cursor button is down and we should adjust for centered view + /** + * 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, + ) => { + let maxScrollX; + let minScrollX; + let maxScrollY; + let minScrollY; + if (cursorButton === "down" && shouldAdjustForCenteredView) { maxScrollX = constrainedScrollCenterX + overscrollAllowanceX; minScrollX = constrainedScrollCenterX - overscrollAllowanceX; - maxScrollY = constrainedScrollCenterY + overscrollAllowanceY; minScrollY = constrainedScrollCenterY - overscrollAllowanceY; - break; - - case cursorButton === "down" && !shouldAdjustForCenteredView: - // case when cursor button is down and we should not adjust for centered view + } else if (cursorButton === "down" && !shouldAdjustForCenteredView) { maxScrollX = scrollConstraints.x + overscrollAllowanceX; minScrollX = scrollConstraints.x - - scrollableWidth + + scrollConstraints.width + width / zoom.value - overscrollAllowanceX; - maxScrollY = scrollConstraints.y + overscrollAllowanceY; minScrollY = scrollConstraints.y - - scrollableHeight + + scrollConstraints.height + height / zoom.value - overscrollAllowanceY; - break; - - case cursorButton !== "down" && shouldAdjustForCenteredView: - // case when cursor button is not down and we should adjust for centered view - + } else if (cursorButton !== "down" && shouldAdjustForCenteredView) { maxScrollX = constrainedScrollCenterX; minScrollX = constrainedScrollCenterX; - maxScrollY = constrainedScrollCenterY; minScrollY = constrainedScrollCenterY; - break; - - default: - // case when cursor button is not down and we should not adjust for centered view + } else { maxScrollX = scrollConstraints.x; - minScrollX = scrollConstraints.x - scrollableWidth + width / zoom.value; - + minScrollX = + scrollConstraints.x - scrollConstraints.width + width / zoom.value; maxScrollY = scrollConstraints.y; minScrollY = - scrollConstraints.y - scrollableHeight + height / zoom.value; - break; - } - - // Constrain the scroll within the scroll constraints - constrainedScrollX = Math.min(maxScrollX, Math.max(scrollX, minScrollX)); - constrainedScrollY = Math.min(maxScrollY, Math.max(scrollY, minScrollY)); - - // Check if the new state differs from the old state - // and if the state has changed, update the state and return the new state - const isStateChanged = - constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; - - if (isStateChanged) { - // Animate the scroll position when the cursor button is not down and scroll position is outside of the scroll constraints. - // We don't want to animate the scroll position when the user is dragging the canvas or zooiming in/out. - if ( - (scrollX < scrollConstraints.x || - scrollX + width > scrollConstraints.x + scrollConstraints.width || - scrollY < scrollConstraints.y || - scrollY + height > scrollConstraints.y + scrollConstraints.height) && - cursorButton !== "down" && - zoom.value === prevState.zoom.value - ) { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: true }, - }); - - easeToValuesRAF({ - fromValues: { scrollX, scrollY }, - toValues: { - scrollX: constrainedScrollX, - scrollY: constrainedScrollY, - }, - onStep: ({ scrollX, scrollY }) => { - this.setState({ - scrollX, - scrollY, - }); - }, - onStart: () => { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: true }, - }); - }, - onEnd: () => { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: false }, - }); - }, - }); - - return null; + scrollConstraints.y - scrollConstraints.height + height / zoom.value; } - const constrainedState = { - scrollX: constrainedScrollX, - scrollY: constrainedScrollY, - zoom, - }; + return { maxScrollX, minScrollX, maxScrollY, minScrollY }; + }; - this.setState(constrainedState); - return constrainedState; - } + /** + * 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. + */ + const constrainScrollValues = ( + maxScrollX: number, + minScrollX: number, + maxScrollY: number, + minScrollY: number, + ) => { + const constrainedScrollX = Math.min( + maxScrollX, + Math.max(scrollX, minScrollX), + ); + const constrainedScrollY = Math.min( + maxScrollY, + Math.max(scrollY, minScrollY), + ); + return { constrainedScrollX, constrainedScrollY }; + }; - return null; + /** + * 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. + */ + const handleStateChange = ( + constrainedScrollX: number, + constrainedScrollY: number, + ) => { + const isStateChanged = + constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; + + if (isStateChanged) { + if ( + (scrollX < scrollConstraints.x || + scrollX + width > scrollConstraints.x + scrollConstraints.width || + scrollY < scrollConstraints.y || + scrollY + height > + scrollConstraints.y + scrollConstraints.height) && + cursorButton !== "down" && + zoom.value === prevState.zoom.value + ) { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: true }, + }); + + easeToValuesRAF({ + fromValues: { scrollX, scrollY }, + toValues: { + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + }, + onStep: ({ scrollX, scrollY }) => { + this.setState({ scrollX, scrollY }); + }, + onStart: () => { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: true }, + }); + }, + onEnd: () => { + this.setState({ + scrollConstraints: { ...scrollConstraints, isAnimating: false }, + }); + }, + }); + + return null; + } + + const constrainedState = { + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + zoom, + }; + + this.setState(constrainedState); + return constrainedState; + } + + return null; + }; + + // Compute the constrained scroll values. + const { zoomLevelX, zoomLevelY } = calculateZoomLevel(); + const { constrainedScrollCenterX, constrainedScrollCenterY } = + calculateConstrainedScrollCenter(); + const { overscrollAllowanceX, overscrollAllowanceY } = + calculateOverscrollAllowance(); + const shouldAdjustForCenteredView = + zoom.value <= zoomLevelX || zoom.value <= zoomLevelY; + const { maxScrollX, minScrollX, maxScrollY, minScrollY } = + calculateMinMaxScrollValues( + shouldAdjustForCenteredView, + overscrollAllowanceX, + overscrollAllowanceY, + constrainedScrollCenterX, + constrainedScrollCenterY, + ); + const { constrainedScrollX, constrainedScrollY } = constrainScrollValues( + maxScrollX, + minScrollX, + maxScrollY, + minScrollY, + ); + + return handleStateChange(constrainedScrollX, constrainedScrollY); }; } From 9562e4309f84529ac852b3971c6672e2ee27550e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 10 Jul 2023 18:10:22 +0200 Subject: [PATCH 20/57] feat: add zoom lock and viewportZoomFactor --- src/components/App.tsx | 60 +++++++++++++++++++++++++++--------------- src/types.ts | 4 +++ 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index be56e086d..4deba45a9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -223,6 +223,7 @@ import { FrameNameBoundsCache, SidebarName, SidebarTabName, + NormalizedZoomValue, } from "../types"; import { debounce, @@ -7650,20 +7651,17 @@ class App extends React.Component { scrollConstraints, width, height, - zoom, + zoom: currentZoom, cursorButton, } = this.state; - // Set the overscroll allowance percentage - const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; - if (!scrollConstraints || scrollConstraints.isAnimating) { return null; } // Check if the state has changed since the last render const stateUnchanged = - zoom.value === prevState.zoom.value && + currentZoom.value === prevState.zoom.value && scrollX === prevState.scrollX && scrollY === prevState.scrollY && width === prevState.width && @@ -7671,10 +7669,17 @@ class App extends React.Component { cursorButton === prevState.cursorButton; // If the state hasn't changed and scrollConstraints didn't just get defined, return null - if (!scrollConstraints || (stateUnchanged && prevState.scrollConstraints)) { + if (stateUnchanged && prevState.scrollConstraints) { return null; } + // Set the overscroll allowance percentage + const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; + const lockZoom = scrollConstraints.opts?.lockZoom ?? false; + const viewportZoomFactor = scrollConstraints.opts?.viewportZoomFactor + ? Math.min(1, Math.max(scrollConstraints.opts.viewportZoomFactor, 0.1)) + : 0.9; + /** * Calculate the zoom levels on which will constrained area fits the viewport for each axis * @returns The zoom levels for the X and Y axes. @@ -7684,20 +7689,21 @@ class App extends React.Component { const scrollableHeight = scrollConstraints.height; const zoomLevelX = width / scrollableWidth; const zoomLevelY = height / scrollableHeight; - return { zoomLevelX, zoomLevelY }; + const maxZoomLevel = lockZoom + ? Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor + : null; + return { zoomLevelX, zoomLevelY, maxZoomLevel }; }; /** * Calculates the center position of the constrained scroll area. * @returns The X and Y coordinates of the center position. */ - const calculateConstrainedScrollCenter = () => { + const calculateConstrainedScrollCenter = (zoom: number) => { const constrainedScrollCenterX = - scrollConstraints.x + - (scrollConstraints.width - width / zoom.value) / -2; + scrollConstraints.x + (scrollConstraints.width - width / zoom) / -2; const constrainedScrollCenterY = - scrollConstraints.y + - (scrollConstraints.height - height / zoom.value) / -2; + scrollConstraints.y + (scrollConstraints.height - height / zoom) / -2; return { constrainedScrollCenterX, constrainedScrollCenterY }; }; @@ -7728,6 +7734,7 @@ class App extends React.Component { overscrollAllowanceY: number, constrainedScrollCenterX: number, constrainedScrollCenterY: number, + zoom: number, ) => { let maxScrollX; let minScrollX; @@ -7744,13 +7751,13 @@ class App extends React.Component { minScrollX = scrollConstraints.x - scrollConstraints.width + - width / zoom.value - + width / zoom - overscrollAllowanceX; maxScrollY = scrollConstraints.y + overscrollAllowanceY; minScrollY = scrollConstraints.y - scrollConstraints.height + - height / zoom.value - + height / zoom - overscrollAllowanceY; } else if (cursorButton !== "down" && shouldAdjustForCenteredView) { maxScrollX = constrainedScrollCenterX; @@ -7760,10 +7767,10 @@ class App extends React.Component { } else { maxScrollX = scrollConstraints.x; minScrollX = - scrollConstraints.x - scrollConstraints.width + width / zoom.value; + scrollConstraints.x - scrollConstraints.width + width / zoom; maxScrollY = scrollConstraints.y; minScrollY = - scrollConstraints.y - scrollConstraints.height + height / zoom.value; + scrollConstraints.y - scrollConstraints.height + height / zoom; } return { maxScrollX, minScrollX, maxScrollY, minScrollY }; @@ -7816,7 +7823,7 @@ class App extends React.Component { scrollY + height > scrollConstraints.y + scrollConstraints.height) && cursorButton !== "down" && - zoom.value === prevState.zoom.value + currentZoom.value === prevState.zoom.value ) { this.setState({ scrollConstraints: { ...scrollConstraints, isAnimating: true }, @@ -7849,7 +7856,14 @@ class App extends React.Component { const constrainedState = { scrollX: constrainedScrollX, scrollY: constrainedScrollY, - zoom, + zoom: { + value: maxZoomLevel + ? (Math.max( + currentZoom.value, + maxZoomLevel, + ) as NormalizedZoomValue) + : currentZoom.value, + }, }; this.setState(constrainedState); @@ -7860,13 +7874,16 @@ class App extends React.Component { }; // Compute the constrained scroll values. - const { zoomLevelX, zoomLevelY } = calculateZoomLevel(); + const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel(); + const zoom = maxZoomLevel + ? Math.max(maxZoomLevel, currentZoom.value) + : currentZoom.value; const { constrainedScrollCenterX, constrainedScrollCenterY } = - calculateConstrainedScrollCenter(); + calculateConstrainedScrollCenter(zoom); const { overscrollAllowanceX, overscrollAllowanceY } = calculateOverscrollAllowance(); const shouldAdjustForCenteredView = - zoom.value <= zoomLevelX || zoom.value <= zoomLevelY; + zoom <= zoomLevelX || zoom <= zoomLevelY; const { maxScrollX, minScrollX, maxScrollY, minScrollY } = calculateMinMaxScrollValues( shouldAdjustForCenteredView, @@ -7874,6 +7891,7 @@ class App extends React.Component { overscrollAllowanceY, constrainedScrollCenterX, constrainedScrollCenterY, + zoom, ); const { constrainedScrollX, constrainedScrollY } = constrainScrollValues( maxScrollX, diff --git a/src/types.ts b/src/types.ts index f048db85b..4b602f784 100644 --- a/src/types.ts +++ b/src/types.ts @@ -229,6 +229,10 @@ export type AppState = { width: number; height: number; isAnimating?: boolean; + opts?: { + viewportZoomFactor?: number; + lockZoom?: boolean; + }; } | null; }; From 6d165971fc355110b247da388d95b1197607d0d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 10 Jul 2023 19:48:55 +0200 Subject: [PATCH 21/57] feat: set view mode when constrains set via props --- src/components/App.tsx | 4 ---- src/packages/excalidraw/index.tsx | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 4deba45a9..fa1627060 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -7825,10 +7825,6 @@ class App extends React.Component { cursorButton !== "down" && currentZoom.value === prevState.zoom.value ) { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: true }, - }); - easeToValuesRAF({ fromValues: { scrollX, scrollY }, toValues: { diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 02056bbb5..295f1d8d5 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -99,7 +99,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { onPointerUpdate={onPointerUpdate} renderTopRightUI={renderTopRightUI} langCode={langCode} - viewModeEnabled={viewModeEnabled} + viewModeEnabled={viewModeEnabled || !!scrollConstraints} zenModeEnabled={zenModeEnabled} gridModeEnabled={gridModeEnabled} libraryReturnUrl={libraryReturnUrl} From 71eb3023b2fd267db44fc5d11c4fb6814b4206a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 10 Jul 2023 19:59:48 +0200 Subject: [PATCH 22/57] [debug] --- src/excalidraw-app/index.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 860437f3c..c13815f3c 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -696,6 +696,13 @@ const ExcalidrawWrapper = () => { /> ); }} + scrollConstraints={{ + x: 0, + y: 0, + width: 2560, + height: 1300, + opts: { lockZoom: true, viewportZoomFactor: 0.1 }, + }} > Date: Fri, 14 Jul 2023 20:46:48 +0200 Subject: [PATCH 23/57] feat: splitting logic, memoization --- src/components/App.tsx | 436 +++++++++++++++++++++++------------ src/excalidraw-app/index.tsx | 3 +- src/types.ts | 22 +- 3 files changed, 300 insertions(+), 161 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index fa1627060..a47aea270 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -223,6 +223,7 @@ import { FrameNameBoundsCache, SidebarName, SidebarTabName, + ScrollConstraints, NormalizedZoomValue, } from "../types"; import { @@ -251,6 +252,7 @@ import { easeToValuesRAF, muteFSAbortError, easeOut, + isShallowEqual, } from "../utils"; import { ContextMenu, @@ -457,6 +459,13 @@ class App extends React.Component { lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | PointerEvent | null = null; lastViewportPosition = { x: 0, y: 0 }; + private memoizedScrollConstraints: { + input: { + scrollConstraints: AppState["scrollConstraints"]; + values: Omit, "zoom"> & { zoom: NormalizedZoomValue }; + }; + result: ReturnType; + } | null = null; constructor(props: AppProps) { super(props); @@ -1632,9 +1641,74 @@ class App extends React.Component { ); } - const constraintedScroll = this.constrainScroll(prevState); + let constraintedScrollState; + if ( + this.state.scrollConstraints && + !this.state.scrollConstraints.isAnimating + ) { + const { + scrollX, + scrollY, + width, + height, + scrollConstraints, + zoom, + cursorButton, + } = this.state; - this.renderScene(constraintedScroll); + // TODO: this could be replaced with memoization function like _.memoize() + const calculatedConstraints = + isShallowEqual( + scrollConstraints, + this.memoizedScrollConstraints?.input.scrollConstraints ?? {}, + ) && + isShallowEqual( + { width, height, zoom: zoom.value, cursorButton }, + this.memoizedScrollConstraints?.input.values ?? {}, + ) && + this.memoizedScrollConstraints + ? this.memoizedScrollConstraints.result + : this.calculateConstraints({ + scrollConstraints, + width, + height, + zoom, + cursorButton, + }); + + this.memoizedScrollConstraints = { + input: { + scrollConstraints, + values: { width, height, zoom: zoom.value, cursorButton }, + }, + result: 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, + }); + } + + this.renderScene(constraintedScrollState); this.history.record(this.state, this.scene.getElementsIncludingDeleted()); // Do not notify consumers if we're still loading the scene. Among other @@ -7629,55 +7703,59 @@ class App extends React.Component { * @param scrollConstraints - The new scroll constraints. */ public setScrollConstraints = ( - scrollConstraints: AppState["scrollConstraints"], + scrollConstraints: ScrollConstraints | null, ) => { - this.setState({ - scrollConstraints, - viewModeEnabled: !!scrollConstraints, - }); + 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.animateConstainedScroll({ + ...constrainedScrollValues, + opts: { + onEndCallback: () => { + this.setState({ + scrollConstraints, + viewModeEnabled: true, + }); + }, + }, + }); + } else { + this.setState({ + scrollConstraints: null, + viewModeEnabled: false, + }); + } }; - /** - * 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 = (prevState: AppState): ConstrainedScrollValues => { - const { - scrollX, - scrollY, - scrollConstraints, - width, - height, - zoom: currentZoom, - cursorButton, - } = this.state; - - if (!scrollConstraints || scrollConstraints.isAnimating) { - return null; - } - - // Check if the state has changed since the last render - const stateUnchanged = - currentZoom.value === prevState.zoom.value && - scrollX === prevState.scrollX && - scrollY === prevState.scrollY && - width === prevState.width && - height === prevState.height && - cursorButton === prevState.cursorButton; - - // If the state hasn't changed and scrollConstraints didn't just get defined, return null - if (stateUnchanged && prevState.scrollConstraints) { - return null; - } - + 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; - const lockZoom = scrollConstraints.opts?.lockZoom ?? false; - const viewportZoomFactor = scrollConstraints.opts?.viewportZoomFactor - ? Math.min(1, Math.max(scrollConstraints.opts.viewportZoomFactor, 0.1)) + const lockZoom = scrollConstraints.lockZoom ?? false; + const viewportZoomFactor = scrollConstraints.viewportZoomFactor + ? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1)) : 0.9; /** @@ -7776,110 +7854,16 @@ class App extends React.Component { return { maxScrollX, minScrollX, maxScrollY, minScrollY }; }; - /** - * 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. - */ - const constrainScrollValues = ( - maxScrollX: number, - minScrollX: number, - maxScrollY: number, - minScrollY: number, - ) => { - const constrainedScrollX = Math.min( - maxScrollX, - Math.max(scrollX, minScrollX), - ); - const constrainedScrollY = Math.min( - maxScrollY, - Math.max(scrollY, minScrollY), - ); - return { constrainedScrollX, constrainedScrollY }; - }; - - /** - * 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. - */ - const handleStateChange = ( - constrainedScrollX: number, - constrainedScrollY: number, - ) => { - const isStateChanged = - constrainedScrollX !== scrollX || constrainedScrollY !== scrollY; - - if (isStateChanged) { - if ( - (scrollX < scrollConstraints.x || - scrollX + width > scrollConstraints.x + scrollConstraints.width || - scrollY < scrollConstraints.y || - scrollY + height > - scrollConstraints.y + scrollConstraints.height) && - cursorButton !== "down" && - currentZoom.value === prevState.zoom.value - ) { - easeToValuesRAF({ - fromValues: { scrollX, scrollY }, - toValues: { - scrollX: constrainedScrollX, - scrollY: constrainedScrollY, - }, - onStep: ({ scrollX, scrollY }) => { - this.setState({ scrollX, scrollY }); - }, - onStart: () => { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: true }, - }); - }, - onEnd: () => { - this.setState({ - scrollConstraints: { ...scrollConstraints, isAnimating: false }, - }); - }, - }); - - return null; - } - - const constrainedState = { - scrollX: constrainedScrollX, - scrollY: constrainedScrollY, - zoom: { - value: maxZoomLevel - ? (Math.max( - currentZoom.value, - maxZoomLevel, - ) as NormalizedZoomValue) - : currentZoom.value, - }, - }; - - this.setState(constrainedState); - return constrainedState; - } - - return null; - }; - - // Compute the constrained scroll values. const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel(); - const zoom = maxZoomLevel - ? Math.max(maxZoomLevel, currentZoom.value) - : currentZoom.value; + const constrainedZoom = getNormalizedZoom( + maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value, + ); const { constrainedScrollCenterX, constrainedScrollCenterY } = - calculateConstrainedScrollCenter(zoom); + calculateConstrainedScrollCenter(constrainedZoom); const { overscrollAllowanceX, overscrollAllowanceY } = calculateOverscrollAllowance(); const shouldAdjustForCenteredView = - zoom <= zoomLevelX || zoom <= zoomLevelY; + constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY; const { maxScrollX, minScrollX, maxScrollY, minScrollY } = calculateMinMaxScrollValues( shouldAdjustForCenteredView, @@ -7887,16 +7871,170 @@ class App extends React.Component { overscrollAllowanceY, constrainedScrollCenterX, constrainedScrollCenterY, - zoom, + constrainedZoom, ); - const { constrainedScrollX, constrainedScrollY } = constrainScrollValues( + + return { maxScrollX, minScrollX, maxScrollY, minScrollY, - ); + constrainedZoom: { + value: constrainedZoom, + }, + }; + }; - return handleStateChange(constrainedScrollX, constrainedScrollY); + /** + * 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 animateConstainedScroll = ({ + 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.animateConstainedScroll({ + constrainedScrollX, + constrainedScrollY, + }); + + return null; + } + const constrainedState = { + scrollX: constrainedScrollX, + scrollY: constrainedScrollY, + zoom: constrainedZoom, + }; + + this.setState(constrainedState); + return constrainedState; + } + + return null; }; } diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index c13815f3c..6da80f502 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -701,7 +701,8 @@ const ExcalidrawWrapper = () => { y: 0, width: 2560, height: 1300, - opts: { lockZoom: true, viewportZoomFactor: 0.1 }, + lockZoom: true, + viewportZoomFactor: 0.1, }} > ; }; + +export type ScrollConstraints = { + x: number; + y: number; + width: number; + height: number; + isAnimating?: boolean; + viewportZoomFactor?: number; + lockZoom?: boolean; +}; From 4e9039e850e01816aff3a91750fadb8d3a7b10a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Fri, 14 Jul 2023 23:23:41 +0200 Subject: [PATCH 24/57] feat: simplify memoization logic --- src/components/App.tsx | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index a47aea270..dd6df0cb5 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -224,7 +224,6 @@ import { SidebarName, SidebarTabName, ScrollConstraints, - NormalizedZoomValue, } from "../types"; import { debounce, @@ -459,13 +458,9 @@ class App extends React.Component { lastPointerDown: React.PointerEvent | null = null; lastPointerUp: React.PointerEvent | PointerEvent | null = null; lastViewportPosition = { x: 0, y: 0 }; - private memoizedScrollConstraints: { - input: { - scrollConstraints: AppState["scrollConstraints"]; - values: Omit, "zoom"> & { zoom: NormalizedZoomValue }; - }; - result: ReturnType; - } | null = null; + private memoizedScrollConstraints: ReturnType< + App["calculateConstraints"] + > | null = null; constructor(props: AppProps) { super(props); @@ -1657,17 +1652,21 @@ class App extends React.Component { } = this.state; // TODO: this could be replaced with memoization function like _.memoize() - const calculatedConstraints = - isShallowEqual( - scrollConstraints, - this.memoizedScrollConstraints?.input.scrollConstraints ?? {}, - ) && + const canUseMemoizedConstraints = + isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) && isShallowEqual( { width, height, zoom: zoom.value, cursorButton }, - this.memoizedScrollConstraints?.input.values ?? {}, - ) && - this.memoizedScrollConstraints - ? this.memoizedScrollConstraints.result + { + width: prevState.width, + height: prevState.height, + zoom: prevState.zoom.value, + cursorButton: prevState.cursorButton, + } ?? {}, + ); + + const calculatedConstraints = + canUseMemoizedConstraints && !!this.memoizedScrollConstraints + ? this.memoizedScrollConstraints : this.calculateConstraints({ scrollConstraints, width, @@ -1676,13 +1675,7 @@ class App extends React.Component { cursorButton, }); - this.memoizedScrollConstraints = { - input: { - scrollConstraints, - values: { width, height, zoom: zoom.value, cursorButton }, - }, - result: calculatedConstraints, - }; + this.memoizedScrollConstraints = calculatedConstraints; const constrainedScrollValues = this.constrainScrollValues({ ...calculatedConstraints, From 76d393098324e5e20496feb87efb89b8b06c3fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 31 Jul 2023 09:50:47 +0200 Subject: [PATCH 25/57] fix: typo --- src/components/App.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 4961af093..5baf7fbda 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8258,7 +8258,7 @@ class App extends React.Component { scrollX, scrollY, }); - this.animateConstainedScroll({ + this.animateConstrainedScroll({ ...constrainedScrollValues, opts: { onEndCallback: () => { @@ -8463,7 +8463,7 @@ class App extends React.Component { /** * Animate the scroll values to the constrained area */ - private animateConstainedScroll = ({ + private animateConstrainedScroll = ({ constrainedScrollX, constrainedScrollY, opts, @@ -8556,7 +8556,7 @@ class App extends React.Component { if (isStateChanged) { if (shouldAnimate) { - this.animateConstainedScroll({ + this.animateConstrainedScroll({ constrainedScrollX, constrainedScrollY, }); From d24a032dbb4f6edb11227985308eca34adbd38f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 31 Jul 2023 18:33:39 +0200 Subject: [PATCH 26/57] feat: set scroll constraints on initial scene state --- src/components/App.tsx | 128 ++++++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 21 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 5baf7fbda..3544c756f 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1560,7 +1560,15 @@ class App extends React.Component { isLoading: false, 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, ...calculateScrollCenter( @@ -8277,6 +8285,98 @@ class App extends React.Component { } }; + /** + * 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, + ): { + 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.2; + + 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, @@ -8292,25 +8392,6 @@ class App extends React.Component { }) => { // Set the overscroll allowance percentage const OVERSCROLL_ALLOWANCE_PERCENTAGE = 0.2; - const lockZoom = scrollConstraints.lockZoom ?? false; - const viewportZoomFactor = scrollConstraints.viewportZoomFactor - ? Math.min(1, Math.max(scrollConstraints.viewportZoomFactor, 0.1)) - : 0.9; - - /** - * Calculate the zoom levels on which will constrained area fits the viewport for each axis - * @returns The zoom levels for the X and Y axes. - */ - const calculateZoomLevel = () => { - const scrollableWidth = scrollConstraints.width; - const scrollableHeight = scrollConstraints.height; - const zoomLevelX = width / scrollableWidth; - const zoomLevelY = height / scrollableHeight; - const maxZoomLevel = lockZoom - ? Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor - : null; - return { zoomLevelX, zoomLevelY, maxZoomLevel }; - }; /** * Calculates the center position of the constrained scroll area. @@ -8393,7 +8474,12 @@ class App extends React.Component { return { maxScrollX, minScrollX, maxScrollY, minScrollY }; }; - const { zoomLevelX, zoomLevelY, maxZoomLevel } = calculateZoomLevel(); + const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel( + scrollConstraints, + width, + height, + ); + const constrainedZoom = getNormalizedZoom( maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value, ); From 04e23e1d299c9877eb75995d58938d047c4bed3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 1 Aug 2023 16:30:45 +0200 Subject: [PATCH 27/57] fix: do not animate empty scene --- src/components/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 3544c756f..1889a1f7b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2012,7 +2012,6 @@ class App extends React.Component { cursorButton, } = this.state; - // TODO: this could be replaced with memoization function like _.memoize() const canUseMemoizedConstraints = isShallowEqual(scrollConstraints, prevState.scrollConstraints ?? {}) && isShallowEqual( @@ -2058,7 +2057,8 @@ class App extends React.Component { shouldAnimate: isViewportOutsideOfConstrainedArea && this.state.cursorButton !== "down" && - prevState.zoom.value === this.state.zoom.value, + prevState.zoom.value === this.state.zoom.value && + this.scene.getElementsIncludingDeleted().length > 0, // Do not animate when app is initialized but scene is empty - this would cause flickering }); } From 4469c02191c6432efb5a09733575ca02d329d575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 1 Aug 2023 16:52:59 +0200 Subject: [PATCH 28/57] chore: move this.scene.getElementsIncludingDeleted() result into const --- src/components/App.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 1889a1f7b..7b8c49217 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1866,10 +1866,8 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); - if ( - !this.state.showWelcomeScreen && - !this.scene.getElementsIncludingDeleted().length - ) { + const elementsIncludingDeleted = this.scene.getElementsIncludingDeleted(); + if (!this.state.showWelcomeScreen && !elementsIncludingDeleted.length) { this.setState({ showWelcomeScreen: true }); } @@ -2058,7 +2056,7 @@ class App extends React.Component { isViewportOutsideOfConstrainedArea && this.state.cursorButton !== "down" && prevState.zoom.value === this.state.zoom.value && - this.scene.getElementsIncludingDeleted().length > 0, // Do not animate when app is initialized but scene is empty - this would cause flickering + elementsIncludingDeleted.length > 0, // Do not animate when app is initialized but scene is empty - this would cause flickering }); } From 803e14ada1d048acd7410f0fa7afe5f4e75f178f Mon Sep 17 00:00:00 2001 From: dwelle Date: Wed, 2 Aug 2023 17:41:32 +0200 Subject: [PATCH 29/57] Revert "[debug]" This reverts commit 71eb3023b2fd267db44fc5d11c4fb6814b4206a6. --- src/excalidraw-app/index.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 18a74d10d..cd719e276 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -731,14 +731,6 @@ const ExcalidrawWrapper = () => { /> ); }} - scrollConstraints={{ - x: 0, - y: 0, - width: 2560, - height: 1300, - lockZoom: true, - viewportZoomFactor: 0.1, - }} > Date: Wed, 2 Aug 2023 17:54:04 +0200 Subject: [PATCH 30/57] [debug] --- src/components/App.tsx | 2 +- src/excalidraw-app/index.tsx | 7 +++++++ src/packages/excalidraw/index.tsx | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 7b8c49217..1f81a2fc8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8358,7 +8358,7 @@ class App extends React.Component { width: AppState["width"], height: AppState["height"], ) => { - const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.2; + const DEFAULT_VIEWPORT_ZOOM_FACTOR = 0.7; const lockZoom = scrollConstraints.lockZoom ?? false; const viewportZoomFactor = scrollConstraints.viewportZoomFactor diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index cd719e276..b3952bf8b 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -731,6 +731,13 @@ const ExcalidrawWrapper = () => { /> ); }} + scrollConstraints={{ + x: 0, + y: 0, + width: 2560, + height: 1300, + lockZoom: true, + }} > { onPointerUpdate={onPointerUpdate} renderTopRightUI={renderTopRightUI} langCode={langCode} - viewModeEnabled={viewModeEnabled || !!scrollConstraints} + viewModeEnabled={viewModeEnabled /* || !!scrollConstraints */} zenModeEnabled={zenModeEnabled} gridModeEnabled={gridModeEnabled} libraryReturnUrl={libraryReturnUrl} From 806b1e9705b8d5f8717f56ff8c3963f8c56c37a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 3 Sep 2023 16:54:04 +0200 Subject: [PATCH 31/57] fix: prevent viewport jumping when panning by mouse wheel --- src/components/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/App.tsx b/src/components/App.tsx index 1f81a2fc8..2cc98c494 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2055,6 +2055,7 @@ class App extends React.Component { shouldAnimate: isViewportOutsideOfConstrainedArea && this.state.cursorButton !== "down" && + prevState.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 }); From f8ba86277431ba02c1582cb32907fa00c42aaed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Sun, 3 Sep 2023 23:47:48 +0200 Subject: [PATCH 32/57] feat: set initial zoom to viewportZoomFactor when lockZoom is false --- src/components/App.tsx | 44 +++++++++++++++++------------------- src/excalidraw-app/index.tsx | 2 +- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 2cc98c494..78c723fa3 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2050,14 +2050,16 @@ class App extends React.Component { scrollConstraints, }); + const shouldAnimate = + isViewportOutsideOfConstrainedArea && + this.state.cursorButton !== "down" && + prevState.cursorButton === "down" && + prevState.zoom.value === this.state.zoom.value && + !this.state.isLoading; // Do not animate when app is initialized but scene is empty - it would cause flickering + constraintedScrollState = this.handleConstrainedScrollStateChange({ ...constrainedScrollValues, - shouldAnimate: - isViewportOutsideOfConstrainedArea && - this.state.cursorButton !== "down" && - prevState.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 + shouldAnimate, }); } @@ -8313,16 +8315,13 @@ class App extends React.Component { return { scrollX, scrollY, zoom }; } - const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel( - scrollConstraints, - width, - height, - ); + const { zoomLevelX, zoomLevelY, initialZoomLevel } = + 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), + initialZoomLevel ?? Math.min(zoomLevelX, zoomLevelY), ), }; @@ -8361,7 +8360,6 @@ class App extends React.Component { ) => { 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; @@ -8370,10 +8368,10 @@ class App extends React.Component { 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 }; + const initialZoomLevel = getNormalizedZoom( + Math.min(zoomLevelX, zoomLevelY) * viewportZoomFactor, + ); + return { zoomLevelX, zoomLevelY, initialZoomLevel }; }; private calculateConstraints = ({ @@ -8473,14 +8471,13 @@ class App extends React.Component { return { maxScrollX, minScrollX, maxScrollY, minScrollY }; }; - const { zoomLevelX, zoomLevelY, maxZoomLevel } = this.calculateZoomLevel( - scrollConstraints, - width, - height, - ); + const { zoomLevelX, zoomLevelY, initialZoomLevel } = + this.calculateZoomLevel(scrollConstraints, width, height); const constrainedZoom = getNormalizedZoom( - maxZoomLevel ? Math.max(maxZoomLevel, zoom.value) : zoom.value, + scrollConstraints.lockZoom + ? Math.max(initialZoomLevel, zoom.value) + : zoom.value, ); const { constrainedScrollCenterX, constrainedScrollCenterY } = calculateConstrainedScrollCenter(constrainedZoom); @@ -8506,6 +8503,7 @@ class App extends React.Component { constrainedZoom: { value: constrainedZoom, }, + initialZoomLevel, }; }; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index b3952bf8b..ce4af5e32 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -736,7 +736,7 @@ const ExcalidrawWrapper = () => { y: 0, width: 2560, height: 1300, - lockZoom: true, + lockZoom: false, }} > Date: Mon, 4 Sep 2023 11:55:14 +0200 Subject: [PATCH 33/57] feat: use single overscrollAllowance for both axis --- src/components/App.tsx | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 78c723fa3..d6b3ed85a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8411,7 +8411,8 @@ class App extends React.Component { OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.width; const overscrollAllowanceY = OVERSCROLL_ALLOWANCE_PERCENTAGE * scrollConstraints.height; - return { overscrollAllowanceX, overscrollAllowanceY }; + + return Math.min(overscrollAllowanceX, overscrollAllowanceY); }; /** @@ -8425,8 +8426,7 @@ class App extends React.Component { */ const calculateMinMaxScrollValues = ( shouldAdjustForCenteredView: boolean, - overscrollAllowanceX: number, - overscrollAllowanceY: number, + overscrollAllowance: number, constrainedScrollCenterX: number, constrainedScrollCenterY: number, zoom: number, @@ -8437,23 +8437,23 @@ class App extends React.Component { let minScrollY; if (cursorButton === "down" && shouldAdjustForCenteredView) { - maxScrollX = constrainedScrollCenterX + overscrollAllowanceX; - minScrollX = constrainedScrollCenterX - overscrollAllowanceX; - maxScrollY = constrainedScrollCenterY + overscrollAllowanceY; - minScrollY = constrainedScrollCenterY - overscrollAllowanceY; + maxScrollX = constrainedScrollCenterX + overscrollAllowance; + minScrollX = constrainedScrollCenterX - overscrollAllowance; + maxScrollY = constrainedScrollCenterY + overscrollAllowance; + minScrollY = constrainedScrollCenterY - overscrollAllowance; } else if (cursorButton === "down" && !shouldAdjustForCenteredView) { - maxScrollX = scrollConstraints.x + overscrollAllowanceX; + maxScrollX = scrollConstraints.x + overscrollAllowance; minScrollX = scrollConstraints.x - scrollConstraints.width + width / zoom - - overscrollAllowanceX; - maxScrollY = scrollConstraints.y + overscrollAllowanceY; + overscrollAllowance; + maxScrollY = scrollConstraints.y + overscrollAllowance; minScrollY = scrollConstraints.y - scrollConstraints.height + height / zoom - - overscrollAllowanceY; + overscrollAllowance; } else if (cursorButton !== "down" && shouldAdjustForCenteredView) { maxScrollX = constrainedScrollCenterX; minScrollX = constrainedScrollCenterX; @@ -8481,15 +8481,13 @@ class App extends React.Component { ); const { constrainedScrollCenterX, constrainedScrollCenterY } = calculateConstrainedScrollCenter(constrainedZoom); - const { overscrollAllowanceX, overscrollAllowanceY } = - calculateOverscrollAllowance(); + const overscrollAllowance = calculateOverscrollAllowance(); const shouldAdjustForCenteredView = constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY; const { maxScrollX, minScrollX, maxScrollY, minScrollY } = calculateMinMaxScrollValues( shouldAdjustForCenteredView, - overscrollAllowanceX, - overscrollAllowanceY, + overscrollAllowance, constrainedScrollCenterX, constrainedScrollCenterY, constrainedZoom, From 10900f39ee18572b5cc1721f01ed9c26ee181911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 5 Sep 2023 17:32:51 +0200 Subject: [PATCH 34/57] fix: allow to scroll on one axis when other is fully in view --- src/components/App.tsx | 67 ++++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index d6b3ed85a..4cf72073e 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -8417,7 +8417,8 @@ class App extends React.Component { /** * 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 shouldAdjustForCenteredViewX - Whether the view should be adjusted for centered view on X axis - when constrained area width fits the viewport. + * @param shouldAdjustForCenteredViewY - Whether the view should be adjusted for centered view on Y axis - when constrained area height 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. @@ -8425,7 +8426,8 @@ class App extends React.Component { * @returns The minimum and maximum scroll values for the X and Y axes. */ const calculateMinMaxScrollValues = ( - shouldAdjustForCenteredView: boolean, + shouldAdjustForCenteredViewX: boolean, + shouldAdjustForCenteredViewY: boolean, overscrollAllowance: number, constrainedScrollCenterX: number, constrainedScrollCenterY: number, @@ -8436,33 +8438,45 @@ class App extends React.Component { let maxScrollY; let minScrollY; - if (cursorButton === "down" && shouldAdjustForCenteredView) { - maxScrollX = constrainedScrollCenterX + overscrollAllowance; - minScrollX = constrainedScrollCenterX - overscrollAllowance; - maxScrollY = constrainedScrollCenterY + overscrollAllowance; - minScrollY = constrainedScrollCenterY - overscrollAllowance; - } else if (cursorButton === "down" && !shouldAdjustForCenteredView) { - maxScrollX = scrollConstraints.x + overscrollAllowance; - minScrollX = - scrollConstraints.x - - scrollConstraints.width + - width / zoom - - overscrollAllowance; - maxScrollY = scrollConstraints.y + overscrollAllowance; - minScrollY = - scrollConstraints.y - - scrollConstraints.height + - height / zoom - - overscrollAllowance; - } else if (cursorButton !== "down" && shouldAdjustForCenteredView) { + // Handling the X-axis + if (cursorButton === "down") { + if (shouldAdjustForCenteredViewX) { + maxScrollX = constrainedScrollCenterX + overscrollAllowance; + minScrollX = constrainedScrollCenterX - overscrollAllowance; + } else { + maxScrollX = scrollConstraints.x + overscrollAllowance; + minScrollX = + scrollConstraints.x - + scrollConstraints.width + + width / zoom - + overscrollAllowance; + } + } else if (shouldAdjustForCenteredViewX) { maxScrollX = constrainedScrollCenterX; minScrollX = constrainedScrollCenterX; - maxScrollY = constrainedScrollCenterY; - minScrollY = constrainedScrollCenterY; } else { maxScrollX = scrollConstraints.x; minScrollX = scrollConstraints.x - scrollConstraints.width + width / zoom; + } + + // Handling the Y-axis + if (cursorButton === "down") { + if (shouldAdjustForCenteredViewY) { + maxScrollY = constrainedScrollCenterY + overscrollAllowance; + minScrollY = constrainedScrollCenterY - overscrollAllowance; + } else { + maxScrollY = scrollConstraints.y + overscrollAllowance; + minScrollY = + scrollConstraints.y - + scrollConstraints.height + + height / zoom - + overscrollAllowance; + } + } else if (shouldAdjustForCenteredViewY) { + maxScrollY = constrainedScrollCenterY; + minScrollY = constrainedScrollCenterY; + } else { maxScrollY = scrollConstraints.y; minScrollY = scrollConstraints.y - scrollConstraints.height + height / zoom; @@ -8482,11 +8496,12 @@ class App extends React.Component { const { constrainedScrollCenterX, constrainedScrollCenterY } = calculateConstrainedScrollCenter(constrainedZoom); const overscrollAllowance = calculateOverscrollAllowance(); - const shouldAdjustForCenteredView = - constrainedZoom <= zoomLevelX || constrainedZoom <= zoomLevelY; + const shouldAdjustForCenteredViewX = constrainedZoom <= zoomLevelX; + const shouldAdjustForCenteredViewY = constrainedZoom <= zoomLevelY; const { maxScrollX, minScrollX, maxScrollY, minScrollY } = calculateMinMaxScrollValues( - shouldAdjustForCenteredView, + shouldAdjustForCenteredViewX, + shouldAdjustForCenteredViewY, overscrollAllowance, constrainedScrollCenterX, constrainedScrollCenterY, From 84b19a77d7b319fc81b372182fa4f06a6d5d6edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Mon, 11 Sep 2023 17:07:07 +0200 Subject: [PATCH 35/57] feat: make constrained scroll working with split canvases --- src/components/App.tsx | 85 +++++++++++-- src/scene/scrollConstraints.ts | 225 ++++++--------------------------- 2 files changed, 115 insertions(+), 195 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index 963f4fb34..199f3f31b 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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(null!); @@ -2004,6 +2005,58 @@ class App extends React.Component { 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 { /** use when changing scrollX/scrollY/zoom based on user interaction */ private translateCanvas: React.Component["setState"] = ( - state, + stateUpdate, ) => { this.cancelInProgresAnimation?.(); + + const partialNewState = + typeof stateUpdate === "function" + ? ( + stateUpdate as ( + prevState: Readonly, + props: Readonly, + ) => 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 { scrollConstraints: ScrollConstraints | null, ) => { if (scrollConstraints) { - setScrollConstraints(scrollConstraints, this.state, () => - this.setState({ - scrollConstraints, - viewModeEnabled: true, - }), - ); + this.setState({ + scrollConstraints, + viewModeEnabled: true, + }); } else { this.setState({ scrollConstraints: null, diff --git a/src/scene/scrollConstraints.ts b/src/scene/scrollConstraints.ts index 9b440c0fd..e5895cf3a 100644 --- a/src/scene/scrollConstraints.ts +++ b/src/scene/scrollConstraints.ts @@ -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 | 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; }; From b99bf74c3d502e819c19b72d21e45208c788eaf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arno=C5=A1t=20Pleskot?= Date: Tue, 12 Sep 2023 12:23:13 +0200 Subject: [PATCH 36/57] feat: hide scroll back to content button --- src/components/LayerUI.tsx | 2 +- src/components/MobileMenu.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 26be77aef..67dada50f 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -461,7 +461,7 @@ const LayerUI = ({ renderCustomStats={renderCustomStats} /> )} - {appState.scrolledOutside && ( + {appState.scrolledOutside && !appState.scrollConstraints && (