diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index cdbce6b307..d3362687fe 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -417,6 +417,7 @@ import { scrollToElements, constrainScrollState, animateToConstraints, + isViewportOverscrolled, } from "../scroll"; import { setEraserCursor, @@ -5647,6 +5648,12 @@ class App extends React.Component { return; } + // while rubberband-overscrolled past the scroll constraints, suppress + // zooming until the viewport has snapped back inside the box + if (isViewportOverscrolled(this.state)) { + return; + } + const initialScale = gesture.initialScale; if (initialScale) { this.translateCanvas((state) => ({ @@ -6897,7 +6904,11 @@ class App extends React.Component { ? 1 : distance / gesture.initialDistance; - const nextZoom = scaleFactor + // while rubberband-overscrolled past the scroll constraints, pin the zoom + // (still allowing the pan below) until the viewport has snapped back + const nextZoom = isViewportOverscrolled(this.state) + ? this.state.zoom.value + : scaleFactor ? getNormalizedZoom(initialScale * scaleFactor) : this.state.zoom.value; @@ -12791,6 +12802,12 @@ class App extends React.Component { const { deltaX, deltaY } = event; // note that event.ctrlKey is necessary to handle pinch zooming if (event.metaKey || event.ctrlKey) { + // while rubberband-overscrolled past the scroll constraints, suppress + // zooming until the viewport has snapped back inside the box + if (isViewportOverscrolled(this.state)) { + return; + } + const sign = Math.sign(deltaY); const MAX_STEP = ZOOM_STEP * 100; const absDelta = Math.abs(deltaY); diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index 17848d7710..71308b5f5f 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -142,6 +142,31 @@ export const constrainScrollState = ( }; }; +/** + * Whether the viewport currently sits outside the hard constraint bounds, i.e. + * the user is rubberband-overscrolled (panned past an edge or zoomed out below + * the fit zoom). Always `false` when there are no constraints. Used to suppress + * zooming until the viewport has settled back inside the box. + */ +export const isViewportOverscrolled = ( + state: Pick< + AppState, + "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" + >, +): boolean => { + if (!state.scrollConstraints) { + return false; + } + + const target = constrainScrollState(state); // hard clamp (tolerance 0) + + return ( + target.scrollX !== state.scrollX || + target.scrollY !== state.scrollY || + target.zoom.value !== state.zoom.value + ); +}; + /** * Rubberband snap-back: animates the viewport from its current (possibly * overscrolled) position back inside the constraint box via the shared diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 28faad393a..5af951834c 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -14,6 +14,7 @@ import { animateToConstraints, constrainScrollState, getMinZoomForConstraints, + isViewportOverscrolled, SCROLL_TO_CONTENT_ANIMATION_KEY, } from "../scroll"; import { AnimationController } from "../renderer/animation"; @@ -271,6 +272,47 @@ describe("rubberband tolerance (pure)", () => { }); }); +describe("isViewportOverscrolled (pure)", () => { + const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 }; + + it("is false when there are no constraints", () => { + expect( + isViewportOverscrolled( + makeState({ scrollX: 9999, scrollConstraints: null }), + ), + ).toBe(false); + }); + + it("is false when the viewport is within the hard bounds", () => { + expect( + isViewportOverscrolled( + makeState({ scrollX: -100, scrollY: -100, scrollConstraints: box }), + ), + ).toBe(false); + }); + + it("is true when panned past an edge (rubberband overscroll)", () => { + // scrollX 30 is past the hard max of 0 + expect( + isViewportOverscrolled( + makeState({ scrollX: 30, scrollConstraints: box }), + ), + ).toBe(true); + }); + + it("is true when zoomed out below the fit zoom", () => { + // fit zoom for this box is 0.2; 0.1 is below it + expect( + isViewportOverscrolled( + makeState({ + zoom: { value: getNormalizedZoom(0.1) }, + scrollConstraints: box, + }), + ), + ).toBe(true); + }); +}); + describe("animateToConstraints (rubberband snap-back)", () => { afterEach(() => { AnimationController.reset();