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:
parent
9e52c30ce8
commit
25bb6738ea
@ -1,6 +1,19 @@
|
|||||||
# ref
|
# ref
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<a href="https://reactjs.org/docs/refs-and-the-dom.html#creating-refs">createRef</a> | <a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a> | <a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">callbackRef</a> | <br/>{ current: { 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>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="https://reactjs.org/docs/hooks-reference.html#useref">useRef</a>{" "}
|
||||||
|
|{" "}
|
||||||
|
<a href="https://reactjs.org/docs/refs-and-the-dom.html#callback-refs">
|
||||||
|
callbackRef
|
||||||
|
</a>{" "}
|
||||||
|
| <br />
|
||||||
|
{ current: { readyPromise: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/utils.ts#L460">
|
||||||
|
resolvablePromise
|
||||||
|
</a> } }
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
You can pass a `ref` when you want to access some excalidraw APIs. We expose the below APIs:
|
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 (
|
return (
|
||||||
<div style={{ height: "500px" }}>
|
<div style={{ height: "500px" }}>
|
||||||
<p style={{ fontSize: "16px" }}> Click to update the scene</p>
|
<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)} />
|
<Excalidraw ref={(api) => setExcalidrawAPI(api)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -187,7 +202,8 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: "500px" }}>
|
<div style={{ height: "500px" }}>
|
||||||
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
<p style={{ fontSize: "16px" }}> Click to update the library items</p>
|
||||||
<button className="custom-button"
|
<button
|
||||||
|
className="custom-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const libraryItems = [
|
const libraryItems = [
|
||||||
{
|
{
|
||||||
@ -205,10 +221,8 @@ function App() {
|
|||||||
];
|
];
|
||||||
excalidrawAPI.updateLibrary({
|
excalidrawAPI.updateLibrary({
|
||||||
libraryItems,
|
libraryItems,
|
||||||
openLibraryMenu: true
|
openLibraryMenu: true,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Update Library
|
Update Library
|
||||||
@ -250,7 +264,7 @@ Resets the scene. If `resetLoadingState` is passed as true then it will also for
|
|||||||
|
|
||||||
<pre>
|
<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[]
|
ExcalidrawElement[]
|
||||||
</a>
|
</a>
|
||||||
</pre>
|
</pre>
|
||||||
@ -261,7 +275,7 @@ Returns all the elements including the deleted in the scene.
|
|||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
() => NonDeleted<
|
() => NonDeleted<
|
||||||
<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
|
ExcalidrawElement
|
||||||
</a>
|
</a>
|
||||||
[]>
|
[]>
|
||||||
@ -293,18 +307,31 @@ This is the history API. history.clear() will clear the history.
|
|||||||
## scrollToContent
|
## scrollToContent
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
(target?:{" "}
|
(<br />
|
||||||
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L114">
|
{" "}
|
||||||
|
target?:{" "}
|
||||||
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115">
|
||||||
ExcalidrawElement
|
ExcalidrawElement
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
|{" "}
|
|{" "}
|
||||||
<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
|
ExcalidrawElement
|
||||||
</a>
|
</a>
|
||||||
[]) => void
|
[],
|
||||||
|
<br />
|
||||||
|
{" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
|
||||||
|
}
|
||||||
|
<br />) => void
|
||||||
</pre>
|
</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 | 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
|
## refresh
|
||||||
|
|
||||||
@ -323,7 +350,7 @@ For any other cases if the position of excalidraw is updated (example due to scr
|
|||||||
This API can be used to show the toast with custom message.
|
This API can be used to show the toast with custom message.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
({ message: string, closable?:boolean,duration?:number
|
({ message: string, closable?:boolean,duration?:number
|
||||||
} | null) => void
|
} | null) => void
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -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.
|
This API has the below signature. It sets the `tool` passed in param as the active tool.
|
||||||
|
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
(tool: <br/> { type: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">SHAPES</a>[number]["value"]| "eraser" } |<br/> { type: "custom"; customType: string }) => void
|
(tool: <br /> { type:{" "}
|
||||||
|
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/shapes.tsx#L15">
|
||||||
|
SHAPES
|
||||||
|
</a>
|
||||||
|
[number]["value"]| "eraser" } |
|
||||||
|
<br /> { type: "custom"; customType: string }) => void
|
||||||
</pre>
|
</pre>
|
||||||
|
|
||||||
## setCursor
|
## setCursor
|
||||||
|
|
||||||
This API can be used to customise the mouse cursor on the canvas and has the below signature.
|
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.
|
||||||
It sets the mouse cursor to the cursor passed in param.
|
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
(cursor: string) => void
|
(cursor: string) => void
|
||||||
|
@ -226,7 +226,7 @@ const zoomValueToFitBoundsOnViewport = (
|
|||||||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
const zoomToFitElements = (
|
export const zoomToFitElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: Readonly<AppState>,
|
appState: Readonly<AppState>,
|
||||||
zoomToSelection: boolean,
|
zoomToSelection: boolean,
|
||||||
|
@ -229,6 +229,7 @@ import {
|
|||||||
updateActiveTool,
|
updateActiveTool,
|
||||||
getShortcutKey,
|
getShortcutKey,
|
||||||
isTransparent,
|
isTransparent,
|
||||||
|
easeToValuesRAF,
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@ -284,7 +285,10 @@ import {
|
|||||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||||
import { Fonts } from "../scene/Fonts";
|
import { Fonts } from "../scene/Fonts";
|
||||||
import { actionPaste } from "../actions/actionClipboard";
|
import { actionPaste } from "../actions/actionClipboard";
|
||||||
import { actionToggleHandTool } from "../actions/actionCanvas";
|
import {
|
||||||
|
actionToggleHandTool,
|
||||||
|
zoomToFitElements,
|
||||||
|
} from "../actions/actionCanvas";
|
||||||
import { jotaiStore } from "../jotai";
|
import { jotaiStore } from "../jotai";
|
||||||
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
|
||||||
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
import { actionCreateContainerFromText } from "../actions/actionBoundText";
|
||||||
@ -1843,18 +1847,89 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.actionManager.executeAction(actionToggleHandTool);
|
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 = (
|
scrollToContent = (
|
||||||
target:
|
target:
|
||||||
| ExcalidrawElement
|
| ExcalidrawElement
|
||||||
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
|
||||||
|
opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
|
||||||
) => {
|
) => {
|
||||||
this.setState({
|
this.cancelInProgresAnimation?.();
|
||||||
...calculateScrollCenter(
|
|
||||||
Array.isArray(target) ? target : [target],
|
// convert provided target into ExcalidrawElement[] if necessary
|
||||||
this.state,
|
const targets = Array.isArray(target) ? target : [target];
|
||||||
this.canvas,
|
|
||||||
),
|
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 = (
|
setToast = (
|
||||||
@ -2055,9 +2130,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
offset = -offset;
|
offset = -offset;
|
||||||
}
|
}
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.setState((state) => ({ scrollX: state.scrollX + offset }));
|
this.translateCanvas((state) => ({
|
||||||
|
scrollX: state.scrollX + offset,
|
||||||
|
}));
|
||||||
} else {
|
} 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,
|
state,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
this.translateCanvas({
|
||||||
zoom: zoomState.zoom,
|
zoom: zoomState.zoom,
|
||||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||||
shouldCacheIgnoreZoom: true,
|
shouldCacheIgnoreZoom: true,
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
this.resetShouldCacheIgnoreZoomDebounced();
|
this.resetShouldCacheIgnoreZoomDebounced();
|
||||||
} else {
|
} else {
|
||||||
@ -3719,7 +3798,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
window.addEventListener(EVENT.POINTER_UP, enableNextPaste);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
scrollX: this.state.scrollX - deltaX / this.state.zoom.value,
|
||||||
scrollY: this.state.scrollY - deltaY / 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) {
|
if (pointerDownState.scrollbars.isOverHorizontal) {
|
||||||
const x = event.clientX;
|
const x = event.clientX;
|
||||||
const dx = x - pointerDownState.lastCoords.x;
|
const dx = x - pointerDownState.lastCoords.x;
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
scrollX: this.state.scrollX - dx / this.state.zoom.value,
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.x = x;
|
pointerDownState.lastCoords.x = x;
|
||||||
@ -4875,7 +4954,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (pointerDownState.scrollbars.isOverVertical) {
|
if (pointerDownState.scrollbars.isOverVertical) {
|
||||||
const y = event.clientY;
|
const y = event.clientY;
|
||||||
const dy = y - pointerDownState.lastCoords.y;
|
const dy = y - pointerDownState.lastCoords.y;
|
||||||
this.setState({
|
this.translateCanvas({
|
||||||
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
scrollY: this.state.scrollY - dy / this.state.zoom.value,
|
||||||
});
|
});
|
||||||
pointerDownState.lastCoords.y = y;
|
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)
|
// reduced amplification for small deltas (small movements on a trackpad)
|
||||||
Math.min(1, absDelta / 20);
|
Math.min(1, absDelta / 20);
|
||||||
|
|
||||||
this.setState((state) => ({
|
this.translateCanvas((state) => ({
|
||||||
...getStateForZoom(
|
...getStateForZoom(
|
||||||
{
|
{
|
||||||
viewportX: cursorX,
|
viewportX: cursorX,
|
||||||
@ -6321,14 +6400,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
// scroll horizontally when shift pressed
|
// scroll horizontally when shift pressed
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
this.setState(({ zoom, scrollX }) => ({
|
this.translateCanvas(({ zoom, scrollX }) => ({
|
||||||
// on Mac, shift+wheel tends to result in deltaX
|
// on Mac, shift+wheel tends to result in deltaX
|
||||||
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
scrollX: scrollX - (deltaY || deltaX) / zoom.value,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState(({ zoom, scrollX, scrollY }) => ({
|
this.translateCanvas(({ zoom, scrollX, scrollY }) => ({
|
||||||
scrollX: scrollX - deltaX / zoom.value,
|
scrollX: scrollX - deltaX / zoom.value,
|
||||||
scrollY: scrollY - deltaY / zoom.value,
|
scrollY: scrollY - deltaY / zoom.value,
|
||||||
}));
|
}));
|
||||||
|
@ -495,7 +495,9 @@ export const restoreAppState = (
|
|||||||
? {
|
? {
|
||||||
value: appState.zoom as NormalizedZoomValue,
|
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,
|
// when sidebar docked and user left it open in last session,
|
||||||
// keep it open. If not docked, keep it closed irrespective of last state.
|
// keep it open. If not docked, keep it closed irrespective of last state.
|
||||||
openSidebar:
|
openSidebar:
|
||||||
|
@ -15,6 +15,8 @@ Please add the latest change on the top under the correct section.
|
|||||||
|
|
||||||
### Features
|
### 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)
|
- 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
|
- [`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
|
||||||
|
189
src/tests/fitToContent.test.tsx
Normal file
189
src/tests/fitToContent.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
73
src/utils.ts
73
src/utils.ts
@ -181,6 +181,79 @@ export const throttleRAF = <T extends any[]>(
|
|||||||
return ret;
|
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
|
// https://github.com/lodash/lodash/blob/es/chunk.js
|
||||||
export const chunk = <T extends any>(
|
export const chunk = <T extends any>(
|
||||||
array: readonly T[],
|
array: readonly T[],
|
||||||
|
Loading…
x
Reference in New Issue
Block a user