@@ -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<AppProps, AppState> {
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,6 +279,7 @@ export type ScrollConstraints = {
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
tolerance?: number;
|
||||
};
|
||||
|
||||
export interface AppState {
|
||||
|
||||
Reference in New Issue
Block a user