@@ -416,6 +416,7 @@ import {
|
|||||||
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
SCROLL_TO_CONTENT_ANIMATION_KEY,
|
||||||
scrollToElements,
|
scrollToElements,
|
||||||
constrainScrollState,
|
constrainScrollState,
|
||||||
|
animateToConstraints,
|
||||||
} from "../scroll";
|
} from "../scroll";
|
||||||
import {
|
import {
|
||||||
setEraserCursor,
|
setEraserCursor,
|
||||||
@@ -610,6 +611,10 @@ const YOUTUBE_VIDEO_STATES = new Map<
|
|||||||
|
|
||||||
const MAX_EMBEDDABLE_VIEWPORT_SCALE = 4;
|
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 = false;
|
||||||
let IS_PLAIN_PASTE_TIMER = 0;
|
let IS_PLAIN_PASTE_TIMER = 0;
|
||||||
let PLAIN_PASTE_TOAST_SHOWN = false;
|
let PLAIN_PASTE_TOAST_SHOWN = false;
|
||||||
@@ -4394,17 +4399,39 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.setState({ shouldCacheIgnoreZoom: false });
|
this.setState({ shouldCacheIgnoreZoom: false });
|
||||||
this.maybeUnfollowRemoteUser();
|
this.maybeUnfollowRemoteUser();
|
||||||
this.setState(state);
|
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
|
/** clamps scroll/zoom back into `appState.scrollConstraints` (no-op when
|
||||||
* unconstrained). Runs as a queued update, so it sees the preceding change. */
|
* unconstrained). Runs as a queued update, so it sees the preceding change.
|
||||||
private constrainViewportToScrollConstraints = () => {
|
* `tolerance` (0–1) relaxes the bounds for rubberbanding. */
|
||||||
|
private constrainViewportToScrollConstraints = (tolerance = 0) => {
|
||||||
this.setState((prevState) =>
|
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
|
* 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
|
* 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.
|
* 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
|
* The visible span is `[-scroll, -scroll + visibleSize]`; we keep it within
|
||||||
* `[boxStart, boxStart + boxSize]`. When the box can't cover the viewport on
|
* `[boxStart, boxStart + boxSize]`, expanded by `overscroll` on each side (for
|
||||||
* this axis (only at the MAX_ZOOM cap for a tiny box) we center the box instead.
|
* 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 = (
|
const constrainScrollAxis = (
|
||||||
scroll: number,
|
scroll: number,
|
||||||
boxStart: number,
|
boxStart: number,
|
||||||
boxSize: number,
|
boxSize: number,
|
||||||
visibleSize: number,
|
visibleSize: number,
|
||||||
|
overscroll: number,
|
||||||
): number => {
|
): number => {
|
||||||
const max = -boxStart;
|
const max = -boxStart + overscroll;
|
||||||
const min = visibleSize - (boxStart + boxSize);
|
const min = visibleSize - (boxStart + boxSize) - overscroll;
|
||||||
return min > max ? (min + max) / 2 : clamp(scroll, min, max);
|
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
|
* 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
|
* inside the box, any zoom anchor (which lives within the viewport) is also
|
||||||
* guaranteed to stay inside the box.
|
* 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 = (
|
export const constrainScrollState = (
|
||||||
state: Pick<
|
state: Pick<
|
||||||
AppState,
|
AppState,
|
||||||
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
"scrollX" | "scrollY" | "zoom" | "width" | "height" | "scrollConstraints"
|
||||||
>,
|
>,
|
||||||
|
tolerance = 0,
|
||||||
): Viewport => {
|
): Viewport => {
|
||||||
const { scrollConstraints, width, height } = state;
|
const { scrollConstraints, width, height } = state;
|
||||||
|
|
||||||
@@ -100,12 +107,15 @@ export const constrainScrollState = (
|
|||||||
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
|
return { scrollX: state.scrollX, scrollY: state.scrollY, zoom: state.zoom };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tolerance = clamp(tolerance, 0, 1);
|
||||||
|
|
||||||
const minZoom = getMinZoomForConstraints(scrollConstraints, {
|
const minZoom = getMinZoomForConstraints(scrollConstraints, {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
});
|
});
|
||||||
|
// relax the min zoom so the user can briefly zoom out past the fit zoom
|
||||||
const zoomValue = getNormalizedZoom(
|
const zoomValue = getNormalizedZoom(
|
||||||
clamp(state.zoom.value, minZoom, MAX_ZOOM),
|
clamp(state.zoom.value, minZoom * (1 - tolerance), MAX_ZOOM),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -114,17 +124,51 @@ export const constrainScrollState = (
|
|||||||
scrollConstraints.x,
|
scrollConstraints.x,
|
||||||
scrollConstraints.width,
|
scrollConstraints.width,
|
||||||
width / zoomValue,
|
width / zoomValue,
|
||||||
|
(tolerance * width) / zoomValue,
|
||||||
),
|
),
|
||||||
scrollY: constrainScrollAxis(
|
scrollY: constrainScrollAxis(
|
||||||
state.scrollY,
|
state.scrollY,
|
||||||
scrollConstraints.y,
|
scrollConstraints.y,
|
||||||
scrollConstraints.height,
|
scrollConstraints.height,
|
||||||
height / zoomValue,
|
height / zoomValue,
|
||||||
|
(tolerance * height) / zoomValue,
|
||||||
),
|
),
|
||||||
zoom: { value: 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
|
* Scrolls (and optionally zooms) the viewport so that the given target is in
|
||||||
* view, optionally animating the transition.
|
* view, optionally animating the transition.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
|
import { KEYS, MAX_ZOOM } from "@excalidraw/common";
|
||||||
|
|
||||||
@@ -9,7 +10,13 @@ import {
|
|||||||
actionZoomToFitSelection,
|
actionZoomToFitSelection,
|
||||||
} from "../actions/actionCanvas";
|
} from "../actions/actionCanvas";
|
||||||
import { getNormalizedZoom } from "../scene";
|
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 { Keyboard } from "./helpers/ui";
|
||||||
import {
|
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;
|
y: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
tolerance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
|
|||||||
Reference in New Issue
Block a user