Files
upstream-excalidraw/packages/excalidraw/tests/fitToContent.test.tsx
2026-09-16 05:31:04 +05:00

370 lines
9.5 KiB
TypeScript

import React from "react";
import { Excalidraw } from "../index";
import { SCROLL_TO_CONTENT_ANIMATION_KEY } from "../components/App.viewport";
import { AnimationController } from "../renderer/animation";
import { getNormalizedZoom } from "../scene";
import { API } from "./helpers/api";
import { act, render } from "./test-utils";
const { h } = window;
/**
* The scroll/zoom animation is driven by `AnimationController`. With render
* throttling enabled (see the `beforeEach` below) it schedules frames via
* `requestAnimationFrame`, advancing the easing based on elapsed wall-clock
* time. We use a very long animation `duration` (see `LONG_ANIMATION_DURATION`)
* so it can never complete while we sample it, and let a few frames pass
* between samples so the easing makes observable (but partial) progress.
*/
const LONG_ANIMATION_DURATION = 1_000_000;
const waitForAnimationProgress = (frames = 4) => {
return act(
() =>
new Promise<void>((resolve) => {
let remaining = frames;
const step = () => {
if (--remaining <= 0) {
resolve();
} else {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
}),
);
};
/**
* Polls until the scroll/zoom animation has removed itself from the
* `AnimationController` (i.e. it ran to completion), or until `maxFrames`
* elapses as a safety net so a regression can't hang the suite.
*/
const waitForAnimationToStop = (maxFrames = 200) => {
return act(
() =>
new Promise<void>((resolve) => {
let remaining = maxFrames;
const check = () => {
if (
!AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY) ||
--remaining <= 0
) {
resolve();
} else {
requestAnimationFrame(check);
}
};
requestAnimationFrame(check);
}),
);
};
describe("scale-down", () => {
it("should zoom to fit the selected element", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
act(() => {
h.app.viewport.setViewport({
target: rectElement,
fit: "scale-down",
animation: false,
});
});
// element is 10x taller than the viewport size,
// zoom should be at least 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should zoom to fit multiple elements", async () => {
await render(<Excalidraw />);
const topLeft = API.createElement({
width: 20,
height: 20,
x: 0,
y: 0,
});
const bottomRight = API.createElement({
width: 20,
height: 20,
x: 80,
y: 80,
});
API.setElements([topLeft, bottomRight]);
h.state.width = 10;
h.state.height = 10;
expect(h.state.zoom.value).toBe(1);
act(() => {
h.app.viewport.setViewport({
target: [topLeft, bottomRight],
fit: "scale-down",
animation: false,
});
});
// elements take 100x100, which is 10x bigger than the viewport size,
// zoom should be at least 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should zoom to fit when scrolling to an element by id", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
act(() => {
// navigating by element id (a string target) with zoomToFit
h.app.viewport.setViewport({
target: rectElement.id,
fit: "scale-down",
animation: false,
});
});
// element is 10x taller than the viewport, so fit-to-content should
// drop the zoom to <= 1/10
expect(h.state.zoom.value).toBeLessThanOrEqual(0.1);
});
it("should scroll the viewport to the selected element", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
API.setElements([rectElement]);
expect(h.state.zoom.value).toBe(1);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
act(() => {
h.app.viewport.setViewport({
target: rectElement,
fit: "contain",
animation: false,
});
});
expect(h.state.zoom.value).toBe(0.1);
// state should reflect some scrolling
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
});
});
describe("none", () => {
it("should keep the current zoom and only center the target", async () => {
await render(<Excalidraw />);
h.state.width = 100;
h.state.height = 100;
const rectElement = API.createElement({
width: 50,
height: 50,
x: 1000,
y: 1000,
});
API.setElements([rectElement]);
act(() => {
API.setAppState({ zoom: { value: getNormalizedZoom(0.5) } });
});
act(() => {
h.app.viewport.setViewport({
target: rectElement,
fit: "none",
animation: false,
});
});
// pan-only: the zoom must stay untouched...
expect(h.state.zoom.value).toBe(0.5);
// ...with the target's center (1025, 1025) centered in the viewport
// (scroll = viewportSize / 2 / zoom - sceneCenter)
expect(h.state.scrollX).toBeCloseTo(100 / 2 / 0.5 - 1025);
expect(h.state.scrollY).toBeCloseTo(100 / 2 / 0.5 - 1025);
});
});
describe("scale-down animated", () => {
beforeEach(() => {
// pace the animation via requestAnimationFrame instead of a tight
// setTimeout(0) loop, which would otherwise starve the test's own timers
window.EXCALIDRAW_THROTTLE_RENDER = true;
});
afterEach(() => {
window.EXCALIDRAW_THROTTLE_RENDER = undefined;
// stop any in-flight scroll/zoom animation so it doesn't keep ticking on
// the unmounted component and leak into the next test via the singleton
AnimationController.reset();
});
it("should ease scroll the viewport to the selected element", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
API.setElements([rectElement]);
act(() => {
h.app.viewport.setViewport({
target: rectElement,
fit: "scale-down",
animation: { duration: LONG_ANIMATION_DURATION },
});
});
// the animation hasn't progressed yet, so we're still at the origin
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
// Since this is an animation, we expect values to change through time.
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForAnimationProgress();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
it("should animate the scroll but not the zoom", async () => {
await render(<Excalidraw />);
h.state.width = 50;
h.state.height = 50;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
API.setElements([rectElement]);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
act(() => {
h.app.viewport.setViewport({
target: rectElement,
fit: "scale-down",
animation: { duration: LONG_ANIMATION_DURATION },
});
});
await waitForAnimationProgress();
const prevScrollX = h.state.scrollX;
const prevScrollY = h.state.scrollY;
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
await waitForAnimationProgress();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
it("should stop ticking and settle on the target once complete", async () => {
await render(<Excalidraw />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
API.setElements([rectElement]);
act(() => {
// a short duration so the animation completes within a few frames
h.app.viewport.setViewport({
target: rectElement,
fit: "scale-down",
animation: { duration: 10 },
});
});
await waitForAnimationToStop();
// the animation must remove itself from the controller rather than keep
// ticking forever after reaching the target
expect(AnimationController.running(SCROLL_TO_CONTENT_ANIMATION_KEY)).toBe(
false,
);
// it should have settled on the target viewport (moved off the origin)
const settledScrollX = h.state.scrollX;
const settledScrollY = h.state.scrollY;
expect(settledScrollX).not.toBe(0);
expect(settledScrollY).not.toBe(0);
expect(h.state.shouldCacheIgnoreZoom).toBe(false);
// further frames must not move the viewport (no perpetual re-rendering)
await waitForAnimationProgress();
expect(h.state.scrollX).toBe(settledScrollX);
expect(h.state.scrollY).toBe(settledScrollY);
});
});