fix: Disable zoom on overscroll

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-26 15:50:43 +00:00
parent a2303cb121
commit d56794024f
3 changed files with 85 additions and 1 deletions
+18 -1
View File
@@ -417,6 +417,7 @@ import {
scrollToElements,
constrainScrollState,
animateToConstraints,
isViewportOverscrolled,
} from "../scroll";
import {
setEraserCursor,
@@ -5647,6 +5648,12 @@ class App extends React.Component<AppProps, AppState> {
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<AppProps, AppState> {
? 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<AppProps, AppState> {
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);
+25
View File
@@ -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
@@ -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();