Răsfoiți Sursa

feat: support scrollToContent opts.fitToViewport (#6581)

Co-authored-by: dwelle <[email protected]>
Co-authored-by: Arnošt Pleskot <[email protected]>
Barnabás Molnár 2 ani în urmă
părinte
comite
29a5e982c3

+ 21 - 19
dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx

@@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
 
 ## scrollToContent
 
-<pre>
-  (<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#L115">
-    ExcalidrawElement
-  </a>
-  [],
-  <br />
-  {"  "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number
-  &#125;
-  <br />) => void
-</pre>
+```tsx
+(
+  target?: ExcalidrawElement | ExcalidrawElement[],
+  opts?:
+      | {
+          fitToContent?: boolean;
+          animate?: boolean;
+          duration?: number;
+        }
+      | {
+          fitToViewport?: boolean;
+          viewportZoomFactor?: number;
+          animate?: boolean;
+          duration?: number;
+        }
+) => void
+```
 
 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. |
+| target | [ExcalidrawElement](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) &#124; [ExcalidrawElement[]](https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115) | 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. Note that the zoom range is between 10%-100%. |
+| opts.fitToViewport | boolean | false | Similar to fitToContent but the zoom range is not limited. If elements are smaller than the viewport, zoom will go above 100%. |
+| opts.viewportZoomFactor | number | 0.7 | when fitToViewport=true, how much screen should the content cover, between 0.1 (10%) and 1 (100%) |
 | 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`. |
 

+ 100 - 34
src/actions/actionCanvas.tsx

@@ -20,7 +20,6 @@ import {
   isHandToolActive,
 } from "../appState";
 import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
-import { excludeElementsInFramesFromSelection } from "../scene/selection";
 import { Bounds } from "../element/bounds";
 
 export const actionChangeViewBackgroundColor = register({
@@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
   return clampedZoomValueToFitElements as NormalizedZoomValue;
 };
 
-export const zoomToFitElements = (
-  elements: readonly ExcalidrawElement[],
-  appState: Readonly<AppState>,
-  zoomToSelection: boolean,
-) => {
-  const nonDeletedElements = getNonDeletedElements(elements);
-  const selectedElements = getSelectedElements(nonDeletedElements, appState);
+export const zoomToFit = ({
+  targetElements,
+  appState,
+  fitToViewport = false,
+  viewportZoomFactor = 0.7,
+}: {
+  targetElements: readonly ExcalidrawElement[];
+  appState: Readonly<AppState>;
+  /** whether to fit content to viewport (beyond >100%) */
+  fitToViewport: boolean;
+  /** zoom content to cover X of the viewport, when fitToViewport=true */
+  viewportZoomFactor?: number;
+}) => {
+  const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
+
+  const [x1, y1, x2, y2] = commonBounds;
+  const centerX = (x1 + x2) / 2;
+  const centerY = (y1 + y2) / 2;
+
+  let newZoomValue;
+  let scrollX;
+  let scrollY;
+
+  if (fitToViewport) {
+    const commonBoundsWidth = x2 - x1;
+    const commonBoundsHeight = y2 - y1;
 
-  const commonBounds =
-    zoomToSelection && selectedElements.length > 0
-      ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
-      : getCommonBounds(
-          excludeElementsInFramesFromSelection(nonDeletedElements),
-        );
+    newZoomValue =
+      Math.min(
+        appState.width / commonBoundsWidth,
+        appState.height / commonBoundsHeight,
+      ) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
 
-  const newZoom = {
-    value: zoomValueToFitBoundsOnViewport(commonBounds, {
+    // Apply clamping to newZoomValue to be between 10% and 3000%
+    newZoomValue = Math.min(
+      Math.max(newZoomValue, 0.1),
+      30.0,
+    ) as NormalizedZoomValue;
+
+    scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
+    scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
+  } else {
+    newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
       width: appState.width,
       height: appState.height,
-    }),
-  };
+    });
+
+    const centerScroll = centerScrollOn({
+      scenePoint: { x: centerX, y: centerY },
+      viewportDimensions: {
+        width: appState.width,
+        height: appState.height,
+      },
+      zoom: { value: newZoomValue },
+    });
+
+    scrollX = centerScroll.scrollX;
+    scrollY = centerScroll.scrollY;
+  }
 
-  const [x1, y1, x2, y2] = commonBounds;
-  const centerX = (x1 + x2) / 2;
-  const centerY = (y1 + y2) / 2;
   return {
     appState: {
       ...appState,
-      ...centerScrollOn({
-        scenePoint: { x: centerX, y: centerY },
-        viewportDimensions: {
-          width: appState.width,
-          height: appState.height,
-        },
-        zoom: newZoom,
-      }),
-      zoom: newZoom,
+      scrollX,
+      scrollY,
+      zoom: { value: newZoomValue },
     },
     commitToHistory: false,
   };
 };
 
-export const actionZoomToSelected = register({
-  name: "zoomToSelection",
+// Note, this action differs from actionZoomToFitSelection in that it doesn't
+// zoom beyond 100%. In other words, if the content is smaller than viewport
+// size, it won't be zoomed in.
+export const actionZoomToFitSelectionInViewport = register({
+  name: "zoomToFitSelectionInViewport",
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => zoomToFitElements(elements, appState, true),
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+    return zoomToFit({
+      targetElements: selectedElements.length ? selectedElements : elements,
+      appState,
+      fitToViewport: false,
+    });
+  },
+  // NOTE shift-2 should have been assigned actionZoomToFitSelection.
+  // TBD on how proceed
   keyTest: (event) =>
     event.code === CODES.TWO &&
     event.shiftKey &&
@@ -279,11 +322,34 @@ export const actionZoomToSelected = register({
     !event[KEYS.CTRL_OR_CMD],
 });
 
+export const actionZoomToFitSelection = register({
+  name: "zoomToFitSelection",
+  trackEvent: { category: "canvas" },
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(
+      getNonDeletedElements(elements),
+      appState,
+    );
+    return zoomToFit({
+      targetElements: selectedElements.length ? selectedElements : elements,
+      appState,
+      fitToViewport: true,
+    });
+  },
+  // NOTE this action should use shift-2 per figma, alas
+  keyTest: (event) =>
+    event.code === CODES.THREE &&
+    event.shiftKey &&
+    !event.altKey &&
+    !event[KEYS.CTRL_OR_CMD],
+});
+
 export const actionZoomToFit = register({
   name: "zoomToFit",
   viewMode: true,
   trackEvent: { category: "canvas" },
-  perform: (elements, appState) => zoomToFitElements(elements, appState, false),
+  perform: (elements, appState) =>
+    zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
   keyTest: (event) =>
     event.code === CODES.ONE &&
     event.shiftKey &&

+ 2 - 1
src/actions/types.ts

@@ -82,7 +82,8 @@ export type ActionName =
   | "zoomOut"
   | "resetZoom"
   | "zoomToFit"
-  | "zoomToSelection"
+  | "zoomToFitSelection"
+  | "zoomToFitSelectionInViewport"
   | "changeFontFamily"
   | "changeTextAlign"
   | "changeVerticalAlign"

+ 68 - 23
src/components/App.tsx

@@ -245,6 +245,7 @@ import {
   isTransparent,
   easeToValuesRAF,
   muteFSAbortError,
+  easeOut,
 } from "../utils";
 import {
   ContextMenu,
@@ -320,10 +321,7 @@ import {
   actionRemoveAllElementsFromFrame,
   actionSelectAllElementsInFrame,
 } from "../actions/actionFrame";
-import {
-  actionToggleHandTool,
-  zoomToFitElements,
-} from "../actions/actionCanvas";
+import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
 import { jotaiStore } from "../jotai";
 import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
@@ -2239,27 +2237,51 @@ class App extends React.Component<AppProps, AppState> {
     target:
       | ExcalidrawElement
       | readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
-    opts?: { fitToContent?: boolean; animate?: boolean; duration?: number },
+    opts?:
+      | {
+          fitToContent?: boolean;
+          fitToViewport?: never;
+          viewportZoomFactor?: never;
+          animate?: boolean;
+          duration?: number;
+        }
+      | {
+          fitToContent?: never;
+          fitToViewport?: boolean;
+          /** when fitToViewport=true, how much screen should the content cover,
+           * between 0.1 (10%) and 1 (100%)
+           */
+          viewportZoomFactor?: number;
+          animate?: boolean;
+          duration?: number;
+        },
   ) => {
     this.cancelInProgresAnimation?.();
 
     // convert provided target into ExcalidrawElement[] if necessary
-    const targets = Array.isArray(target) ? target : [target];
+    const targetElements = 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);
+    if (opts?.fitToContent || opts?.fitToViewport) {
+      const { appState } = zoomToFit({
+        targetElements,
+        appState: this.state,
+        fitToViewport: !!opts?.fitToViewport,
+        viewportZoomFactor: opts?.viewportZoomFactor,
+      });
       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);
+      const scroll = calculateScrollCenter(
+        targetElements,
+        this.state,
+        this.canvas,
+      );
       scrollX = scroll.scrollX;
       scrollY = scroll.scrollY;
     }
@@ -2269,19 +2291,42 @@ class App extends React.Component<AppProps, AppState> {
     if (opts?.animate) {
       const origScrollX = this.state.scrollX;
       const origScrollY = this.state.scrollY;
+      const origZoom = this.state.zoom.value;
+
+      const cancel = easeToValuesRAF({
+        fromValues: {
+          scrollX: origScrollX,
+          scrollY: origScrollY,
+          zoom: origZoom,
+        },
+        toValues: { scrollX, scrollY, zoom: zoom.value },
+        interpolateValue: (from, to, progress, key) => {
+          // for zoom, use different easing
+          if (key === "zoom") {
+            return from * Math.pow(to / from, easeOut(progress));
+          }
+          // handle using default
+          return undefined;
+        },
+        onStep: ({ scrollX, scrollY, zoom }) => {
+          this.setState({
+            scrollX,
+            scrollY,
+            zoom: { value: zoom },
+          });
+        },
+        onStart: () => {
+          this.setState({ shouldCacheIgnoreZoom: true });
+        },
+        onEnd: () => {
+          this.setState({ shouldCacheIgnoreZoom: false });
+        },
+        onCancel: () => {
+          this.setState({ shouldCacheIgnoreZoom: false });
+        },
+        duration: opts?.duration ?? 500,
+      });
 
-      // 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;

+ 1 - 0
src/keys.ts

@@ -10,6 +10,7 @@ export const CODES = {
   BRACKET_LEFT: "BracketLeft",
   ONE: "Digit1",
   TWO: "Digit2",
+  THREE: "Digit3",
   NINE: "Digit9",
   QUOTE: "Quote",
   ZERO: "Digit0",

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

@@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Add support for `opts.fitToViewport` and `opts.viewportZoomFactor` in the [`ExcalidrawAPI.scrollToContent`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props/ref#scrolltocontent) API. [#6581](https://github.com/excalidraw/excalidraw/pull/6581).
 - Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
 - Sidebar component now supports tabs — for more detailed description of new behavior and breaking changes, see the linked PR. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
 - Exposed `DefaultSidebar` component to allow modifying the default sidebar, such as adding custom tabs to it. [#6213](https://github.com/excalidraw/excalidraw/pull/6213)
@@ -64,7 +65,7 @@ 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)
+- [`ExcalidrawAPI.scrollToContent`](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)
 

+ 72 - 1
src/packages/excalidraw/example/App.tsx

@@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
           <div className="export export-blob">
             <img src={blobUrl} alt="" />
           </div>
-
           <button
             onClick={async () => {
               if (!excalidrawAPI) {
@@ -806,6 +805,78 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
           >
             Export to Canvas
           </button>
+          <button
+            onClick={async () => {
+              if (!excalidrawAPI) {
+                return;
+              }
+              const canvas = await exportToCanvas({
+                elements: excalidrawAPI.getSceneElements(),
+                appState: {
+                  ...initialData.appState,
+                  exportWithDarkMode,
+                },
+                files: excalidrawAPI.getFiles(),
+              });
+              const ctx = canvas.getContext("2d")!;
+              ctx.font = "30px Virgil";
+              ctx.strokeText("My custom text", 50, 60);
+              setCanvasUrl(canvas.toDataURL());
+            }}
+          >
+            Export to Canvas
+          </button>
+          <button
+            type="button"
+            onClick={() => {
+              if (!excalidrawAPI) {
+                return;
+              }
+
+              const elements = excalidrawAPI.getSceneElements();
+              excalidrawAPI.scrollToContent(elements[0], {
+                fitToViewport: true,
+              });
+            }}
+          >
+            Fit to viewport, first element
+          </button>
+          <button
+            type="button"
+            onClick={() => {
+              if (!excalidrawAPI) {
+                return;
+              }
+
+              const elements = excalidrawAPI.getSceneElements();
+              excalidrawAPI.scrollToContent(elements[0], {
+                fitToContent: true,
+              });
+
+              excalidrawAPI.scrollToContent(elements[0], {
+                fitToContent: true,
+              });
+            }}
+          >
+            Fit to content, first element
+          </button>
+          <button
+            type="button"
+            onClick={() => {
+              if (!excalidrawAPI) {
+                return;
+              }
+
+              const elements = excalidrawAPI.getSceneElements();
+              excalidrawAPI.scrollToContent(elements[0], {
+                fitToContent: true,
+              });
+
+              excalidrawAPI.scrollToContent(elements[0]);
+            }}
+          >
+            Scroll to first element, no fitToContent, no fitToViewport
+          </button>
           <div className="export export-canvas">
             <img src={canvasUrl} alt="" />
           </div>

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

@@ -160,19 +160,6 @@ describe("fitToContent animated", () => {
 
     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;

+ 98 - 32
src/utils.ts

@@ -197,68 +197,134 @@ export const throttleRAF = <T extends any[]>(
  * @param {number} k - The value to be tweened.
  * @returns {number} The tweened value.
  */
-function easeOut(k: number): number {
+export const easeOut = (k: number) => {
   return 1 - Math.pow(1 - k, 4);
-}
+};
+
+const easeOutInterpolate = (from: number, to: number, progress: number) => {
+  return (to - from) * easeOut(progress) + from;
+};
 
 /**
- * Compute new values based on the same ease function and trigger the
- * callback through a requestAnimationFrame call
+ * Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
+ * Executes the `onStep` callback on each step with the interpolated values.
+ * Returns a function that can be called to cancel the animation.
  *
- * use `opts` to define a duration and/or an easeFn
+ * @example
+ * // Example usage:
+ * const fromValues = { x: 0, y: 0 };
+ * const toValues = { x: 100, y: 200 };
+ * const onStep = ({x, y}) => {
+ *   setState(x, y)
+ * };
+ * const onCancel = () => {
+ *   console.log("Animation canceled");
+ * };
  *
- * for example:
- * ```ts
- * easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c))
- * ```
+ * const cancelAnimation = easeToValuesRAF({
+ *   fromValues,
+ *   toValues,
+ *   onStep,
+ *   onCancel,
+ * });
  *
- * @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
+ * // To cancel the animation:
+ * cancelAnimation();
  */
-export const easeToValuesRAF = (
-  fromValues: number[],
-  toValues: number[],
-  callback: (...values: number[]) => void,
-  opts?: { duration?: number; easeFn?: (value: number) => number },
-) => {
+export const easeToValuesRAF = <
+  T extends Record<keyof T, number>,
+  K extends keyof T,
+>({
+  fromValues,
+  toValues,
+  onStep,
+  duration = 250,
+  interpolateValue,
+  onStart,
+  onEnd,
+  onCancel,
+}: {
+  fromValues: T;
+  toValues: T;
+  /**
+   * Interpolate a single value.
+   * Return undefined to be handled by the default interpolator.
+   */
+  interpolateValue?: (
+    fromValue: number,
+    toValue: number,
+    /** no easing applied  */
+    progress: number,
+    key: K,
+  ) => number | undefined;
+  onStep: (values: T) => void;
+  duration?: number;
+  onStart?: () => void;
+  onEnd?: () => void;
+  onCancel?: () => void;
+}) => {
   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;
+      onStart?.();
     }
 
-    const elapsed = timestamp - startTime;
+    const elapsed = Math.min(timestamp - startTime, duration);
+    const factor = easeOut(elapsed / duration);
+
+    const newValues = {} as T;
+
+    Object.keys(fromValues).forEach((key) => {
+      const _key = key as keyof T;
+      const result = ((toValues[_key] - fromValues[_key]) * factor +
+        fromValues[_key]) as T[keyof T];
+      newValues[_key] = result;
+    });
+
+    onStep(newValues);
 
     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,
-      );
+      const progress = elapsed / duration;
+
+      const newValues = {} as T;
+
+      Object.keys(fromValues).forEach((key) => {
+        const _key = key as K;
+        const startValue = fromValues[_key];
+        const endValue = toValues[_key];
+
+        let result;
+
+        result = interpolateValue
+          ? interpolateValue(startValue, endValue, progress, _key)
+          : easeOutInterpolate(startValue, endValue, progress);
+
+        if (result == null) {
+          result = easeOutInterpolate(startValue, endValue, progress);
+        }
+
+        newValues[_key] = result as T[K];
+      });
+      onStep(newValues);
 
-      callback(...newValues);
       frameId = window.requestAnimationFrame(step);
     } else {
-      // ensure final values are reached at the end of the transition
-      callback(...toValues);
+      onStep(toValues);
+      onEnd?.();
     }
   }
 
   frameId = window.requestAnimationFrame(step);
 
   return () => {
+    onCancel?.();
     canceled = true;
     window.cancelAnimationFrame(frameId);
   };