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) {
- + + + +
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); };