diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 389a13b494..128a5f2934 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -416,6 +416,7 @@ import { SCROLL_TO_CONTENT_ANIMATION_KEY, scrollToElements, constrainScrollState, + animateToConstraints, } from "../scroll"; import { setEraserCursor, @@ -610,6 +611,10 @@ const YOUTUBE_VIDEO_STATES = new Map< const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4; +/** how long after the last pan/zoom we animate the rubberband back into the + * scroll constraints box */ +const SCROLL_CONSTRAINTS_SNAP_BACK_DELAY = 200; + let IS_PLAIN_PASTE = false; let IS_PLAIN_PASTE_TIMER = 0; let PLAIN_PASTE_TOAST_SHOWN = false; @@ -4394,17 +4399,39 @@ class App extends React.Component { this.setState({ shouldCacheIgnoreZoom: false }); this.maybeUnfollowRemoteUser(); this.setState(state); - this.constrainViewportToScrollConstraints(); + + // with rubberband tolerance, allow a soft overscroll while interacting and + // animate back to the box once the interaction settles; otherwise hard-clamp + const tolerance = this.state.scrollConstraints?.tolerance ?? 0; + this.constrainViewportToScrollConstraints(tolerance); + if (tolerance > 0) { + this.snapBackToScrollConstraintsDebounced(); + } }; /** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when - * unconstrained). Runs as a queued update, so it sees the preceding change. */ - private constrainViewportToScrollConstraints = () => { + * unconstrained). Runs as a queued update, so it sees the preceding change. + * `tolerance` (0–1) relaxes the bounds for rubberbanding. */ + private constrainViewportToScrollConstraints = (tolerance = 0) => { this.setState((prevState) => - prevState.scrollConstraints ? constrainScrollState(prevState) : null, + prevState.scrollConstraints + ? constrainScrollState(prevState, tolerance) + : null, ); }; + /** animates an overscrolled viewport back inside the constraint box */ + private snapBackToScrollConstraints = () => { + if (!this.unmounted) { + animateToConstraints(this.state, (viewport) => this.setState(viewport)); + } + }; + + private snapBackToScrollConstraintsDebounced = debounce( + this.snapBackToScrollConstraints, + SCROLL_CONSTRAINTS_SNAP_BACK_DELAY, + ); + /** * Constrains pan & zoom to a scene-coordinate box, so the viewport can't be * scrolled or zoomed out past it. Pass `null` to remove the constraint. When diff --git a/packages/excalidraw/scroll.ts b/packages/excalidraw/scroll.ts index bc1ac93772..30c49ae2b4 100644 --- a/packages/excalidraw/scroll.ts +++ b/packages/excalidraw/scroll.ts @@ -67,17 +67,19 @@ export const getMinZoomForConstraints = ( /** * Clamps a single scroll axis so the visible scene span stays inside the box. * The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within - * `[boxStart, boxStart + boxSize]`. When the box can't cover the viewport on - * this axis (only at the MAX_ZOOM cap for a tiny box) we center the box instead. + * `[boxStart, boxStart + boxSize]`, expanded by `overscroll` on each side (for + * rubberbanding). When the box can't cover the viewport on this axis (only at + * the MAX_ZOOM cap for a tiny box) we center the box instead. */ const constrainScrollAxis = ( scroll: number, boxStart: number, boxSize: number, visibleSize: number, + overscroll: number, ): number => { - const max = -boxStart; - const min = visibleSize - (boxStart + boxSize); + const max = -boxStart + overscroll; + const min = visibleSize - (boxStart + boxSize) - overscroll; return min > max ? (min + max) / 2 : clamp(scroll, min, max); }; @@ -87,12 +89,17 @@ const constrainScrollAxis = ( * unchanged when there are no constraints. Because the whole viewport is kept * 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. */ export const constrainScrollState = ( state: Pick< AppState, "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" >, + tolerance = 0, ): Viewport => { const { scrollConstraints, width, height } = state; @@ -100,12 +107,15 @@ export const constrainScrollState = ( return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom }; } + tolerance = clamp(tolerance, 0, 1); + const minZoom = getMinZoomForConstraints(scrollConstraints, { width, height, }); + // relax the min zoom so the user can briefly zoom out past the fit zoom const zoomValue = getNormalizedZoom( - clamp(state.zoom.value, minZoom, MAX_ZOOM), + clamp(state.zoom.value, minZoom * (1 - tolerance), MAX_ZOOM), ); return { @@ -114,17 +124,51 @@ export const constrainScrollState = ( scrollConstraints.x, scrollConstraints.width, width / zoomValue, + (tolerance * width) / zoomValue, ), scrollY: constrainScrollAxis( state.scrollY, scrollConstraints.y, scrollConstraints.height, height / zoomValue, + (tolerance * height) / zoomValue, ), zoom: { value: zoomValue }, }; }; +/** + * Rubberband snap-back: animates the viewport from its current (possibly + * overscrolled) position back inside the constraint box via the shared + * AnimationController. No-op when already within the hard bounds (or when there + * are no constraints). + */ +export const animateToConstraints = ( + state: Pick< + AppState, + "scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints" + >, + onFrame: ( + viewport: Pick< + AppState, + "scrollX" | "scrollY" | "zoom" | "shouldCacheIgnoreZoom" + >, + ) => void, + duration = DEFAULT_ANIMATION_DURATION, +) => { + const target = constrainScrollState(state); // hard clamp (tolerance 0) + + if ( + target.scrollX === state.scrollX && + target.scrollY === state.scrollY && + target.zoom.value === state.zoom.value + ) { + return; + } + + animateToViewport(state, target, duration, onFrame); +}; + /** * Scrolls (and optionally zooms) the viewport so that the given target is in * view, optionally animating the transition. diff --git a/packages/excalidraw/tests/scrollConstraints.test.tsx b/packages/excalidraw/tests/scrollConstraints.test.tsx index 63001485b7..468d6a9e5a 100644 --- a/packages/excalidraw/tests/scrollConstraints.test.tsx +++ b/packages/excalidraw/tests/scrollConstraints.test.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { vi } from "vitest"; import { KEYS, MAX_ZOOM } from "@excalidraw/common"; @@ -9,7 +10,13 @@ import { actionZoomToFitSelection, } from "../actions/actionCanvas"; import { getNormalizedZoom } from "../scene"; -import { constrainScrollState, getMinZoomForConstraints } from "../scroll"; +import { + animateToConstraints, + constrainScrollState, + getMinZoomForConstraints, + SCROLL_TO_CONTENT_ANIMATION_KEY, +} from "../scroll"; +import { AnimationController } from "../renderer/animation"; import { Keyboard } from "./helpers/ui"; import { @@ -210,3 +217,106 @@ 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 + const result = constrainScrollState( + makeState({ scrollX: 999, scrollConstraints: { ...box, tolerance } }), + tolerance, + ); + expect(result.scrollX).toBeCloseTo((tolerance * VIEWPORT.width) / 1); // 50 + }); + + it("relaxes the minimum zoom by the `tolerance` fraction", () => { + const tolerance = 0.25; + const minZoom = getMinZoomForConstraints(box, VIEWPORT); // 0.2 + const result = constrainScrollState( + makeState({ + zoom: { value: getNormalizedZoom(0.01) }, + scrollConstraints: { ...box, tolerance }, + }), + tolerance, + ); + expect(result.zoom.value).toBeCloseTo(minZoom * (1 - tolerance)); // 0.15 + }); + + it("hard-clamps when tolerance is 0 (default)", () => { + const result = constrainScrollState( + makeState({ scrollX: 999, scrollConstraints: box }), + ); + expect(result.scrollX).toBeCloseTo(0); + }); +}); + +describe("animateToConstraints (rubberband snap-back)", () => { + afterEach(() => { + AnimationController.reset(); + }); + + const box: ScrollConstraints = { x: 0, y: 0, width: 1000, height: 1000 }; + + it("starts an animation toward the box when overscrolled", () => { + const onFrame = vi.fn(); + // scrollX 200 is outside the hard range [-800, 0] + animateToConstraints( + makeState({ scrollX: 200, scrollConstraints: box }), + onFrame, + ); + expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe( + true, + ); + expect(onFrame).toHaveBeenCalled(); + }); + + it("is a no-op when already within the box", () => { + const onFrame = vi.fn(); + animateToConstraints( + makeState({ scrollX: -100, scrollConstraints: box }), + onFrame, + ); + expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe( + false, + ); + expect(onFrame).not.toHaveBeenCalled(); + }); +}); + +describe("rubberband tolerance (integration)", () => { + beforeEach(() => { + mockBoundingClientRect(); + }); + + afterEach(() => { + restoreOriginalGetBoundingClientRect(); + AnimationController.reset(); + }); + + it("lets the user pan past the box edge (bounded by tolerance)", async () => { + await render(); + await waitFor(() => expect(h.state.width).toBe(200)); + + React.act(() => { + h.app.setScrollConstraints({ + x: 0, + y: 0, + width: 1000, + height: 1000, + tolerance: 0.25, + }); + }); + + // snapped to the top edge (hard max scrollY = 0) + expect(h.state.scrollY).toBe(0); + + // 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 + expect(h.state.scrollY).toBeGreaterThan(0); + expect(h.state.scrollY).toBeLessThanOrEqual(0.25 * h.state.height + 0.001); + }); +}); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index f93adf11cf..7a130257ef 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -279,6 +279,7 @@ export type ScrollConstraints = { y: number; width: number; height: number; + tolerance?: number; }; export interface AppState {