feat: Animation

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
This commit is contained in:
Mark Tolmacs
2026-06-24 17:44:33 +00:00
parent 24efadfc65
commit 3c235f9618
4 changed files with 192 additions and 10 deletions
+31 -4
View File
@@ -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` (01) 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
+49 -5
View File
@@ -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` (01, 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);
});
});
+1
View File
@@ -279,6 +279,7 @@ export type ScrollConstraints = {
y: number;
width: number;
height: number;
tolerance?: number;
};
export interface AppState {