diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
index 08e80790..eaf58f75 100644
--- a/dev-docs/docs/@excalidraw/excalidraw/api/props/ref.mdx
+++ b/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
-
- (
- {" "}
- target?:{" "}
-
- ExcalidrawElement
- {" "}
- |{" "}
-
- ExcalidrawElement
-
- [],
-
- {" "}opts?: { fitToContent?: boolean; animate?: boolean; duration?: number
- }
- ) => void
-
+```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 | ExcalidrawElement | ExcalidrawElement[]
| 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) | [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`. |
diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx
index a94a3672..ae4a08a9 100644
--- a/src/actions/actionCanvas.tsx
+++ b/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,
- zoomToSelection: boolean,
-) => {
- const nonDeletedElements = getNonDeletedElements(elements);
- const selectedElements = getSelectedElements(nonDeletedElements, appState);
-
- const commonBounds =
- zoomToSelection && selectedElements.length > 0
- ? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
- : getCommonBounds(
- excludeElementsInFramesFromSelection(nonDeletedElements),
- );
-
- const newZoom = {
- value: zoomValueToFitBoundsOnViewport(commonBounds, {
- width: appState.width,
- height: appState.height,
- }),
- };
+export const zoomToFit = ({
+ targetElements,
+ appState,
+ fitToViewport = false,
+ viewportZoomFactor = 0.7,
+}: {
+ targetElements: readonly ExcalidrawElement[];
+ appState: Readonly;
+ /** 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;
+
+ newZoomValue =
+ Math.min(
+ appState.width / commonBoundsWidth,
+ appState.height / commonBoundsHeight,
+ ) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
+
+ // 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;
+ }
+
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 &&
diff --git a/src/actions/types.ts b/src/actions/types.ts
index 7ba40afa..bd81fa55 100644
--- a/src/actions/types.ts
+++ b/src/actions/types.ts
@@ -82,7 +82,8 @@ export type ActionName =
| "zoomOut"
| "resetZoom"
| "zoomToFit"
- | "zoomToSelection"
+ | "zoomToFitSelection"
+ | "zoomToFitSelectionInViewport"
| "changeFontFamily"
| "changeTextAlign"
| "changeVerticalAlign"
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 2479731e..3ccfa124 100644
--- a/src/components/App.tsx
+++ b/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 {
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 {
if (opts?.animate) {
const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY;
+ const origZoom = this.state.zoom.value;
- // 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({
+ 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,
+ });
- const cancel = easeToValuesRAF(
- [origScrollX, origScrollY],
- [scrollX, scrollY],
- (scrollX, scrollY) => this.setState({ scrollX, scrollY }),
- { duration: opts?.duration ?? 500 },
- );
this.cancelInProgresAnimation = () => {
cancel();
this.cancelInProgresAnimation = null;
diff --git a/src/keys.ts b/src/keys.ts
index 14b7d288..33f1188d 100644
--- a/src/keys.ts
+++ b/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",
diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md
index 1cd27d95..5933f564 100644
--- a/src/packages/excalidraw/CHANGELOG.md
+++ b/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 `` children to render any of our i18n locale strings. [#6224](https://github.com/excalidraw/excalidraw/pull/6224)
diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx
index 03646541..7f17b292 100644
--- a/src/packages/excalidraw/example/App.tsx
+++ b/src/packages/excalidraw/example/App.tsx
@@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
-
{
if (!excalidrawAPI) {
@@ -806,6 +805,78 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
>
Export to Canvas
+ {
+ 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
+
+ {
+ if (!excalidrawAPI) {
+ return;
+ }
+
+ const elements = excalidrawAPI.getSceneElements();
+ excalidrawAPI.scrollToContent(elements[0], {
+ fitToViewport: true,
+ });
+ }}
+ >
+ Fit to viewport, first element
+
+ {
+ if (!excalidrawAPI) {
+ return;
+ }
+
+ const elements = excalidrawAPI.getSceneElements();
+ excalidrawAPI.scrollToContent(elements[0], {
+ fitToContent: true,
+ });
+
+ excalidrawAPI.scrollToContent(elements[0], {
+ fitToContent: true,
+ });
+ }}
+ >
+ Fit to content, first element
+
+ {
+ 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
+
diff --git a/src/tests/fitToContent.test.tsx b/src/tests/fitToContent.test.tsx
index 6fce7cdc..fd7a1170 100644
--- a/src/tests/fitToContent.test.tsx
+++ b/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;
diff --git a/src/utils.ts b/src/utils.ts
index 40bff959..c644efd8 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -197,68 +197,134 @@ export const throttleRAF = (
* @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,
+ 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);
};