Browse Source

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

Co-authored-by: Brice Leroy <brice@brigalabs.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
Type Horror 2 years ago
parent
commit
25bb6738ea

+ 48 - 18
dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx

@@ -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
 
@@ -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.
 
 ```tsx
-({ message: string, closable?:boolean,duration?:number 
+({ message: string, closable?:boolean,duration?:number
   } | 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.
 
-
 <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

+ 1 - 1
src/actions/actionCanvas.tsx

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

+ 97 - 18
src/components/App.tsx

@@ -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,20 +1847,91 @@ class App extends React.Component<AppProps, AppState> {
     this.actionManager.executeAction(actionToggleHandTool);
   };
 
-  scrollToContent = (
-    target:
-      | ExcalidrawElement
-      | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
+  /**
+   * Zooms on canvas viewport center
+   */
+  zoomCanvas = (
+    /** decimal fraction between 0.1 (10% zoom) and 30 (3000% zoom) */
+    value: number,
   ) => {
     this.setState({
-      ...calculateScrollCenter(
-        Array.isArray(target) ? target : [target],
+      ...getStateForZoom(
+        {
+          viewportX: this.state.width / 2 + this.state.offsetLeft,
+          viewportY: this.state.height / 2 + this.state.offsetTop,
+          nextZoom: getNormalizedZoom(value),
+        },
         this.state,
-        this.canvas,
       ),
     });
   };
 
+  private cancelInProgresAnimation: (() => void) | null = null;
+
+  scrollToContent = (
+    target:
+      | ExcalidrawElement
+      | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
+    opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
+  ) => {
+    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 = (
     toast: {
       message: string;
@@ -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,
     }));

+ 3 - 1
src/data/restore.ts

@@ -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:

+ 2 - 0
src/packages/excalidraw/CHANGELOG.md

@@ -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

+ 189 - 0
src/tests/fitToContent.test.tsx

@@ -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 - 0
src/utils.ts

@@ -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[],