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, 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` (01) 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
+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. * 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` (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 = ( 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);
});
});
+1
View File
@@ -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 {