feat: Add fitToContent and animate to scrollToContent (#6319)

Co-authored-by: Brice Leroy <brice@brigalabs.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Type Horror 2023-03-25 15:26:58 -07:00 committed by GitHub
parent 9e52c30ce8
commit 25bb6738ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 413 additions and 38 deletions

View File

@ -1,6 +1,19 @@
# ref
<pre>
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> &#124; <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> &#124; <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> &#124; <br/>&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">resolvablePromise</a> } }
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">
createRef
</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
&#124;{" "}
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
callbackRef
</a>{" "}
&#124; <br />
&#123; current: &#123; readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
resolvablePromise
</a> } }
</pre>
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
@ -139,7 +152,9 @@ function App() {
return (
<div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
<button className="custom-button" onClick={updateScene}>Update Scene</button>
<button className="custom-button" onClick={updateScene}>
Update Scene
</button>
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
</div>
);
@ -187,7 +202,8 @@ function App() {
return (
<div style={{ height: "500px" }}>
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
<button className="custom-button"
<button
className="custom-button"
onClick={() => {
const libraryItems = [
{
@ -205,10 +221,8 @@ function App() {
];
excalidrawAPI.updateLibrary({
libraryItems,
openLibraryMenu: true
openLibraryMenu: true,
});
}}
>
Update Library
@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
<pre>
() =>{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement[]
</a>
</pre>
@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
<pre>
() => NonDeleted&#60;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[]&#62;
@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
## scrollToContent
<pre>
(target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
(<br />
{" "}
target?:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>{" "}
&#124;{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
ExcalidrawElement
</a>
[]) => void
[],
<br />
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
&#125;
<br />) => void
</pre>
Scroll the nearest element out of the elements supplied to the center. Defaults to the elements on the scene.
Scroll the nearest element out of the elements supplied to the center of the viewport. Defaults to the elements on the scene.
| Attribute | type | default | Description |
| --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. |
| opts.fitToContent | boolean | false | Whether to fit the elements to viewport by automatically changing zoom as needed. |
| opts.animate | boolean | false | Whether to animate between starting and ending position. Note that for larger scenes the animation may not be smooth due to performance issues. |
| opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |
## refresh
@ -358,15 +385,18 @@ This API can be used to get the files present in the scene. It may contain files
This API has the below signature. It sets the `tool` passed in param as the active tool.
<pre>
(tool: <br/> &#123; type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]&#124; "eraser" &#125; &#124;<br/> &#123; type: "custom"; customType: string &#125;) => void
(tool: <br /> &#123; type:{" "}
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
SHAPES
</a>
[number]["value"]&#124; "eraser" &#125; &#124;
<br /> &#123; type: "custom"; customType: string &#125;) => void
</pre>
## setCursor
This API can be used to customise the mouse cursor on the canvas and has the below signature.
It sets the mouse cursor to the cursor passed in param.
This API can be used to customise the mouse cursor on the canvas and has the below signature. It sets the mouse cursor to the cursor passed in param.
```tsx
(cursor: string) => void

View File

@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue;
};
const zoomToFitElements = (
export const zoomToFitElements = (
elements: readonly ExcalidrawElement[],
appState: Readonly<AppState>,
zoomToSelection: boolean,

View File

@ -229,6 +229,7 @@ import {
updateActiveTool,
getShortcutKey,
isTransparent,
easeToValuesRAF,
} from "../utils";
import {
ContextMenu,
@ -284,7 +285,10 @@ import {
import { shouldShowBoundingBox } from "../element/transformHandles";
import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard";
import { actionToggleHandTool } from "../actions/actionCanvas";
import {
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionCreateContainerFromText } from "../actions/actionBoundText";
@ -1843,18 +1847,89 @@ class App extends React.Component<AppProps, AppState> {
this.actionManager.executeAction(actionToggleHandTool);
};
/**
* Zooms on canvas viewport center
*/
zoomCanvas = (
/** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
value: number,
) => {
this.setState({
...getStateForZoom(
{
viewportX: this.state.width / 2 + this.state.offsetLeft,
viewportY: this.state.height / 2 + this.state.offsetTop,
nextZoom: getNormalizedZoom(value),
},
this.state,
),
});
};
private cancelInProgresAnimation: (() => void) | null = null;
scrollToContent = (
target:
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
) => {
this.setState({
...calculateScrollCenter(
Array.isArray(target) ? target : [target],
this.state,
this.canvas,
),
});
this.cancelInProgresAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
const targets = Array.isArray(target) ? target : [target];
let zoom = this.state.zoom;
let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY;
if (opts?.fitToContent) {
// compute an appropriate viewport location (scroll X, Y) and zoom level
// that fit the target elements on the scene
const { appState } = zoomToFitElements(targets, this.state, false);
zoom = appState.zoom;
scrollX = appState.scrollX;
scrollY = appState.scrollY;
} else {
// compute only the viewport location, without any zoom adjustment
const scroll = calculateScrollCenter(targets, this.state, this.canvas);
scrollX = scroll.scrollX;
scrollY = scroll.scrollY;
}
// when animating, we use RequestAnimationFrame to prevent the animation
// from slowing down other processes
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
// zoom animation could become problematic on scenes with large number
// of elements, setting it to its final value to improve user experience.
//
// using zoomCanvas() to zoom on current viewport center
this.zoomCanvas(zoom.value);
const cancel = easeToValuesRAF(
[origScrollX, origScrollY],
[scrollX, scrollY],
(scrollX, scrollY) => this.setState({ scrollX, scrollY }),
{ duration: opts?.duration ?? 500 },
);
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
};
} else {
this.setState({ scrollX, scrollY, zoom });
}
};
/** use when changing scrollX/scrollY/zoom based on user interaction */
private translateCanvas: React.Component<any, AppState>["setState"] = (
state,
) => {
this.cancelInProgresAnimation?.();
this.setState(state);
};
setToast = (
@ -2055,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
offset = -offset;
}
if (event.shiftKey) {
this.setState((state) => ({ scrollX: state.scrollX + offset }));
this.translateCanvas((state) => ({
scrollX: state.scrollX + offset,
}));
} else {
this.setState((state) => ({ scrollY: state.scrollY + offset }));
this.translateCanvas((state) => ({
scrollY: state.scrollY + offset,
}));
}
}
@ -2938,12 +3017,12 @@ class App extends React.Component<AppProps, AppState> {
state,
);
return {
this.translateCanvas({
zoom: zoomState.zoom,
scrollX: zoomState.scrollX + deltaX / nextZoom,
scrollY: zoomState.scrollY + deltaY / nextZoom,
shouldCacheIgnoreZoom: true,
};
});
});
this.resetShouldCacheIgnoreZoomDebounced();
} else {
@ -3719,7 +3798,7 @@ class App extends React.Component<AppProps, AppState> {
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
}
this.setState({
this.translateCanvas({
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
scrollY: this.state.scrollY - deltaY / this.state.zoom.value,
});
@ -4865,7 +4944,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverHorizontal) {
const x = event.clientX;
const dx = x - pointerDownState.lastCoords.x;
this.setState({
this.translateCanvas({
scrollX: this.state.scrollX - dx / this.state.zoom.value,
});
pointerDownState.lastCoords.x = x;
@ -4875,7 +4954,7 @@ class App extends React.Component<AppProps, AppState> {
if (pointerDownState.scrollbars.isOverVertical) {
const y = event.clientY;
const dy = y - pointerDownState.lastCoords.y;
this.setState({
this.translateCanvas({
scrollY: this.state.scrollY - dy / this.state.zoom.value,
});
pointerDownState.lastCoords.y = y;
@ -6304,7 +6383,7 @@ class App extends React.Component<AppProps, AppState> {
// reduced amplification for small deltas (small movements on a trackpad)
Math.min(1, absDelta / 20);
this.setState((state) => ({
this.translateCanvas((state) => ({
...getStateForZoom(
{
viewportX: cursorX,
@ -6321,14 +6400,14 @@ class App extends React.Component<AppProps, AppState> {
// scroll horizontally when shift pressed
if (event.shiftKey) {
this.setState(({ zoom, scrollX }) => ({
this.translateCanvas(({ zoom, scrollX }) => ({
// on Mac, shift+wheel tends to result in deltaX
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
}));
return;
}
this.setState(({ zoom, scrollX, scrollY }) => ({
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
scrollX: scrollX - deltaX / zoom.value,
scrollY: scrollY - deltaY / zoom.value,
}));

View File

@ -495,7 +495,9 @@ export const restoreAppState = (
? {
value: appState.zoom as NormalizedZoomValue,
}
: appState.zoom || defaultAppState.zoom,
: appState.zoom?.value
? appState.zoom
: defaultAppState.zoom,
// when sidebar docked and user left it open in last session,
// keep it open. If not docked, keep it closed irrespective of last state.
openSidebar:

View File

@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
### Features
- [`ExcalidrawAPI.scrolToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) has new opts object allowing you to fit viewport to content, and animate the scrolling. [#6319](https://github.com/excalidraw/excalidraw/pull/6319)
- Expose `useI18n()` hook return an object containing `t()` i18n helper and current `langCode`. You can use this in components you render as `<Excalidraw>` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
- [`restoreElements`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils/restore#restoreelements) API now takes an optional parameter `opts` which currently supports the below attributes

View File

@ -0,0 +1,189 @@
import { render } from "./test-utils";
import { API } from "./helpers/api";
import ExcalidrawApp from "../excalidraw-app";
const { h } = window;
describe("fitToContent", () => {
it("should zoom to fit the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 50,
height: 100,
x: 50,
y: 100,
});
expect(h.state.zoom.value).toBe(1);
h.app.scrollToContent(rectElement, { fitToContent: true });
// 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(<ExcalidrawApp />);
const topLeft = API.createElement({
width: 20,
height: 20,
x: 0,
y: 0,
});
const bottomRight = API.createElement({
width: 20,
height: 20,
x: 80,
y: 80,
});
h.state.width = 10;
h.state.height = 10;
expect(h.state.zoom.value).toBe(1);
h.app.scrollToContent([topLeft, bottomRight], {
fitToContent: true,
});
// 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 scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
expect(h.state.zoom.value).toBe(1);
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
h.app.scrollToContent(rectElement);
// zoom level should stay the same
expect(h.state.zoom.value).toBe(1);
// state should reflect some scrolling
expect(h.state.scrollX).not.toBe(0);
expect(h.state.scrollY).not.toBe(0);
});
});
const waitForNextAnimationFrame = () => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
};
describe("fitToContent animated", () => {
beforeEach(() => {
jest.spyOn(window, "requestAnimationFrame");
});
afterEach(() => {
jest.restoreAllMocks();
});
it("should ease scroll the viewport to the selected element", async () => {
await render(<ExcalidrawApp />);
h.state.width = 10;
h.state.height = 10;
const rectElement = API.createElement({
width: 100,
height: 100,
x: -100,
y: -100,
});
h.app.scrollToContent(rectElement, { animate: true });
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the scroll values change at 50ms and 100ms
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
await waitForNextAnimationFrame();
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 waitForNextAnimationFrame();
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(<ExcalidrawApp />);
h.state.width = 50;
h.state.height = 50;
const rectElement = API.createElement({
width: 100,
height: 100,
x: 100,
y: 100,
});
expect(h.state.scrollX).toBe(0);
expect(h.state.scrollY).toBe(0);
h.app.scrollToContent(rectElement, { animate: true, fitToContent: true });
expect(window.requestAnimationFrame).toHaveBeenCalled();
// Since this is an animation, we expect values to change through time.
// We'll verify that the zoom/scroll values change in each animation frame
// zoom is not animated, it should be set to its final value, which in our
// case zooms out to 50% so that th element is fully visible (it's 2x large
// as the canvas)
expect(h.state.zoom.value).toBeLessThanOrEqual(0.5);
// FIXME I think this should be [-100, -100] so we may have a bug in our zoom
// hadnling, alas
expect(h.state.scrollX).toBe(25);
expect(h.state.scrollY).toBe(25);
await waitForNextAnimationFrame();
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 waitForNextAnimationFrame();
expect(h.state.scrollX).not.toBe(prevScrollX);
expect(h.state.scrollY).not.toBe(prevScrollY);
});
});

View File

@ -181,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
return ret;
};
/**
* Exponential ease-out method
*
* @param {number} k - The value to be tweened.
* @returns {number} The tweened value.
*/
function easeOut(k: number): number {
return 1 - Math.pow(1 - k, 4);
}
/**
* Compute new values based on the same ease function and trigger the
* callback through a requestAnimationFrame call
*
* use `opts` to define a duration and/or an easeFn
*
* for example:
* ```ts
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
* ```
*
* @param fromValues The initial values, must be numeric
* @param toValues The destination values, must also be numeric
* @param callback The callback receiving the values
* @param opts default to 250ms duration and the easeOut function
*/
export const easeToValuesRAF = (
fromValues: number[],
toValues: number[],
callback: (...values: number[]) => void,
opts?: { duration?: number; easeFn?: (value: number) => number },
) => {
let canceled = false;
let frameId = 0;
let startTime: number;
const duration = opts?.duration || 250; // default animation to 0.25 seconds
const easeFn = opts?.easeFn || easeOut; // default the easeFn to easeOut
function step(timestamp: number) {
if (canceled) {
return;
}
if (startTime === undefined) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
if (elapsed < duration) {
// console.log(elapsed, duration, elapsed / duration);
const factor = easeFn(elapsed / duration);
const newValues = fromValues.map(
(fromValue, index) =>
(toValues[index] - fromValue) * factor + fromValue,
);
callback(...newValues);
frameId = window.requestAnimationFrame(step);
} else {
// ensure final values are reached at the end of the transition
callback(...toValues);
}
}
frameId = window.requestAnimationFrame(step);
return () => {
canceled = true;
window.cancelAnimationFrame(frameId);
};
};
// https://github.com/lodash/lodash/blob/es/chunk.js
export const chunk = <T extends any>(
array: readonly T[],