diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8e6fb53735..cdbce6b307 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4432,7 +4432,7 @@ class App extends React.Component { /** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when * unconstrained). Runs as a queued update, so it sees the preceding change. - * `tolerance` (0–1) relaxes the bounds for rubberbanding. */ + * `tolerance` (screen px) relaxes the bounds for rubberbanding. */ private constrainViewportToScrollConstraints = (tolerance = 0) => { this.setState((prevState) => prevState.scrollConstraints diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index 7f30a3e831..90a5d2622b 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -90,9 +90,10 @@ const constrainScrollAxis = ( * inside the box, any zoom anchor (which lives within the viewport) is also * guaranteed to stay inside the box. * - * `tolerance` (0–1, a fraction of the viewport) relaxes the bounds for - * rubberbanding: the viewport may pan past the edges / zoom out below the fit - * zoom by that fraction. Pass `0` (default) for a hard clamp. + * `tolerance` is the rubberband overscroll allowance, in screen pixels: the + * viewport may pan past the box edges (and zoom out below the fit zoom) until + * each edge has crossed the box by up to `tolerance` pixels on screen, + * independent of the current zoom. Pass `0` (default) for a hard clamp. */ export const constrainScrollState = ( state: Pick< @@ -107,31 +108,35 @@ export const constrainScrollState = ( return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom }; } - tolerance = clamp(tolerance, 0, 1); + tolerance = Math.max(tolerance, 0); - const minZoom = getMinZoomForConstraints(scrollConstraints, { - width, - height, + // relax the min zoom so the user can briefly zoom out past the fit zoom, + // letting each viewport edge cross the box by up to `tolerance` screen px + const relaxedMinZoom = getMinZoomForConstraints(scrollConstraints, { + width: width - 2 * tolerance, + height: height - 2 * tolerance, }); - // relax the min zoom so the user can briefly zoom out past the fit zoom const zoomValue = getNormalizedZoom( - clamp(state.zoom.value, minZoom * (1 - tolerance), MAX_ZOOM), + clamp(state.zoom.value, relaxedMinZoom, MAX_ZOOM), ); + // `tolerance` screen px expressed in scene coords at the current zoom + const overscroll = tolerance / zoomValue; + return { scrollX: constrainScrollAxis( state.scrollX, scrollConstraints.x, scrollConstraints.width, width / zoomValue, - (tolerance * width) / zoomValue, + overscroll, ), scrollY: constrainScrollAxis( state.scrollY, scrollConstraints.y, scrollConstraints.height, height / zoomValue, - (tolerance * height) / zoomValue, + overscroll, ), zoom: { value: zoomValue }, }; diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 468d6a9e5a..28faad393a 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -221,19 +221,38 @@ describe("setScrollConstraints (integration)", () => { describe("rubberband tolerance (pure)", () => { const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 }; - it("allows scroll overscroll up to `tolerance` fraction of the viewport", () => { - const tolerance = 0.25; - // hard max for scrollX is -box.x = 0; soft adds tolerance * width / zoom + it("allows scroll overscroll up to `tolerance` screen pixels", () => { + const tolerance = 30; // screen px + // hard max for scrollX is -box.x = 0; soft adds tolerance / zoom scene px const result = constrainScrollState( makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }), tolerance, ); - expect(result.scrollX).toBeCloseTo((tolerance * VIEWPORT.width) / 1); // 50 + expect(result.scrollX).toBeCloseTo(tolerance / 1); // 30 (zoom 1) }); - it("relaxes the minimum zoom by the `tolerance` fraction", () => { - const tolerance = 0.25; - const minZoom = getMinZoomForConstraints(box, VIEWPORT); // 0.2 + it("keeps the overscroll a fixed screen distance regardless of zoom", () => { + const tolerance = 30; // screen px + const result = constrainScrollState( + makeState({ + scrollX: 999, + zoom: { value: getNormalizedZoom(2) }, + scrollConstraints: { ...box, tolerance }, + }), + tolerance, + ); + // 30 screen px at zoom 2 -> 15 scene px of overscroll + expect(result.scrollX).toBeCloseTo(tolerance / 2); // 15 + }); + + it("relaxes the minimum zoom by the `tolerance` screen pixels", () => { + const tolerance = 25; // screen px + // relaxed min zoom lets the box shrink within the viewport by `tolerance` + // px on each side: max((200-50)/1000, (100-50)/1000) = 0.15 + const expected = getMinZoomForConstraints(box, { + width: VIEWPORT.width - 2 * tolerance, + height: VIEWPORT.height - 2 * tolerance, + }); const result = constrainScrollState( makeState({ zoom: { value: getNormalizedZoom(0.01) }, @@ -241,7 +260,7 @@ describe("rubberband tolerance (pure)", () => { }), tolerance, ); - expect(result.zoom.value).toBeCloseTo(minZoom * (1 - tolerance)); // 0.15 + expect(result.zoom.value).toBeCloseTo(expected); // 0.15 }); it("hard-clamps when tolerance is 0 (default)", () => { @@ -305,7 +324,7 @@ describe("rubberband tolerance (integration)", () => { y: 0, width: 1000, height: 1000, - tolerance: 0.25, + tolerance: 25, }); }); @@ -315,8 +334,10 @@ describe("rubberband tolerance (integration)", () => { // page-up pans up, pushing scrollY past the hard edge into the overscroll Keyboard.keyPress(KEYS.PAGE_UP); - // overscrolled past 0, but bounded by tolerance * height = 25 + // overscrolled past 0, but bounded by tolerance (25 screen px / zoom 1) expect(h.state.scrollY).toBeGreaterThan(0); - expect(h.state.scrollY).toBeLessThanOrEqual(0.25 * h.state.height + 0.001); + expect(h.state.scrollY).toBeLessThanOrEqual( + 25 / h.state.zoom.value + 0.001, + ); }); });