feat: support scrollToContent opts.fitToViewport (#6581)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Arnošt Pleskot <arnostpleskot@gmail.com>
This commit is contained in:
Barnabás Molnár 2023-06-29 12:36:38 +02:00 committed by GitHub
parent b33fa6d6f6
commit 29a5e982c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 364 additions and 124 deletions

View File

@ -306,30 +306,32 @@ This is the history API. history.clear() will clear the history.
## scrollToContent ## scrollToContent
<pre> ```tsx
(<br /> (
{" "} target?: ExcalidrawElement | ExcalidrawElement[],
target?:{" "} opts?:
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> | {
ExcalidrawElement fitToContent?: boolean;
</a>{" "} animate?: boolean;
&#124;{" "} duration?: number;
<a href="https://github.com/excalidraw/excalidraw/blob/master/src/element/types.ts#L115"> }
ExcalidrawElement | {
</a> fitToViewport?: boolean;
[], viewportZoomFactor?: number;
<br /> animate?: boolean;
{" "}opts?: &#123; fitToContent?: boolean; animate?: boolean; duration?: number duration?: number;
&#125; }
<br />) => void ) => void
</pre> ```
Scroll the nearest element out of the elements supplied to the center of the viewport. 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 | | Attribute | type | default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| target | <code>ExcalidrawElement &#124; ExcalidrawElement[]</code> | All scene elements | The element(s) to scroll to. | | 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. | | 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.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`. | | opts.duration | number | 500 | Duration of the animation if `opts.animate` is `true`. |

View File

@ -20,7 +20,6 @@ import {
isHandToolActive, isHandToolActive,
} from "../appState"; } from "../appState";
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
import { excludeElementsInFramesFromSelection } from "../scene/selection";
import { Bounds } from "../element/bounds"; import { Bounds } from "../element/bounds";
export const actionChangeViewBackgroundColor = register({ export const actionChangeViewBackgroundColor = register({
@ -226,52 +225,96 @@ const zoomValueToFitBoundsOnViewport = (
return clampedZoomValueToFitElements as NormalizedZoomValue; return clampedZoomValueToFitElements as NormalizedZoomValue;
}; };
export const zoomToFitElements = ( export const zoomToFit = ({
elements: readonly ExcalidrawElement[], targetElements,
appState: Readonly<AppState>, appState,
zoomToSelection: boolean, fitToViewport = false,
) => { viewportZoomFactor = 0.7,
const nonDeletedElements = getNonDeletedElements(elements); }: {
const selectedElements = getSelectedElements(nonDeletedElements, appState); targetElements: readonly ExcalidrawElement[];
appState: Readonly<AppState>;
const commonBounds = /** whether to fit content to viewport (beyond >100%) */
zoomToSelection && selectedElements.length > 0 fitToViewport: boolean;
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements)) /** zoom content to cover X of the viewport, when fitToViewport=true */
: getCommonBounds( viewportZoomFactor?: number;
excludeElementsInFramesFromSelection(nonDeletedElements), }) => {
); const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
const newZoom = {
value: zoomValueToFitBoundsOnViewport(commonBounds, {
width: appState.width,
height: appState.height,
}),
};
const [x1, y1, x2, y2] = commonBounds; const [x1, y1, x2, y2] = commonBounds;
const centerX = (x1 + x2) / 2; const centerX = (x1 + x2) / 2;
const centerY = (y1 + y2) / 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 { return {
appState: { appState: {
...appState, ...appState,
...centerScrollOn({ scrollX,
scenePoint: { x: centerX, y: centerY }, scrollY,
viewportDimensions: { zoom: { value: newZoomValue },
width: appState.width,
height: appState.height,
},
zoom: newZoom,
}),
zoom: newZoom,
}, },
commitToHistory: false, commitToHistory: false,
}; };
}; };
export const actionZoomToSelected = register({ // Note, this action differs from actionZoomToFitSelection in that it doesn't
name: "zoomToSelection", // 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" }, 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) => keyTest: (event) =>
event.code === CODES.TWO && event.code === CODES.TWO &&
event.shiftKey && event.shiftKey &&
@ -279,11 +322,34 @@ export const actionZoomToSelected = register({
!event[KEYS.CTRL_OR_CMD], !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({ export const actionZoomToFit = register({
name: "zoomToFit", name: "zoomToFit",
viewMode: true, viewMode: true,
trackEvent: { category: "canvas" }, trackEvent: { category: "canvas" },
perform: (elements, appState) => zoomToFitElements(elements, appState, false), perform: (elements, appState) =>
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
keyTest: (event) => keyTest: (event) =>
event.code === CODES.ONE && event.code === CODES.ONE &&
event.shiftKey && event.shiftKey &&

View File

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

View File

@ -245,6 +245,7 @@ import {
isTransparent, isTransparent,
easeToValuesRAF, easeToValuesRAF,
muteFSAbortError, muteFSAbortError,
easeOut,
} from "../utils"; } from "../utils";
import { import {
ContextMenu, ContextMenu,
@ -320,10 +321,7 @@ import {
actionRemoveAllElementsFromFrame, actionRemoveAllElementsFromFrame,
actionSelectAllElementsInFrame, actionSelectAllElementsInFrame,
} from "../actions/actionFrame"; } from "../actions/actionFrame";
import { import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
actionToggleHandTool,
zoomToFitElements,
} from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { actionWrapTextInContainer } from "../actions/actionBoundText"; import { actionWrapTextInContainer } from "../actions/actionBoundText";
@ -2239,27 +2237,51 @@ class App extends React.Component<AppProps, AppState> {
target: target:
| ExcalidrawElement | ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(), | 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?.(); this.cancelInProgresAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary // 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 zoom = this.state.zoom;
let scrollX = this.state.scrollX; let scrollX = this.state.scrollX;
let scrollY = this.state.scrollY; let scrollY = this.state.scrollY;
if (opts?.fitToContent) { if (opts?.fitToContent || opts?.fitToViewport) {
// compute an appropriate viewport location (scroll X, Y) and zoom level const { appState } = zoomToFit({
// that fit the target elements on the scene targetElements,
const { appState } = zoomToFitElements(targets, this.state, false); appState: this.state,
fitToViewport: !!opts?.fitToViewport,
viewportZoomFactor: opts?.viewportZoomFactor,
});
zoom = appState.zoom; zoom = appState.zoom;
scrollX = appState.scrollX; scrollX = appState.scrollX;
scrollY = appState.scrollY; scrollY = appState.scrollY;
} else { } else {
// compute only the viewport location, without any zoom adjustment // 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; scrollX = scroll.scrollX;
scrollY = scroll.scrollY; scrollY = scroll.scrollY;
} }
@ -2269,19 +2291,42 @@ class App extends React.Component<AppProps, AppState> {
if (opts?.animate) { if (opts?.animate) {
const origScrollX = this.state.scrollX; const origScrollX = this.state.scrollX;
const origScrollY = this.state.scrollY; const origScrollY = this.state.scrollY;
const origZoom = this.state.zoom.value;
// zoom animation could become problematic on scenes with large number const cancel = easeToValuesRAF({
// of elements, setting it to its final value to improve user experience. fromValues: {
// scrollX: origScrollX,
// using zoomCanvas() to zoom on current viewport center scrollY: origScrollY,
this.zoomCanvas(zoom.value); 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 = () => { this.cancelInProgresAnimation = () => {
cancel(); cancel();
this.cancelInProgresAnimation = null; this.cancelInProgresAnimation = null;

View File

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

View File

@ -15,6 +15,7 @@ Please add the latest change on the top under the correct section.
### Features ### 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). - 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) - 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) - 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 ### 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) - 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)

View File

@ -784,7 +784,6 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
<div className="export export-blob"> <div className="export export-blob">
<img src={blobUrl} alt="" /> <img src={blobUrl} alt="" />
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
if (!excalidrawAPI) { if (!excalidrawAPI) {
@ -806,6 +805,78 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
> >
Export to Canvas Export to Canvas
</button> </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"> <div className="export export-canvas">
<img src={canvasUrl} alt="" /> <img src={canvasUrl} alt="" />
</div> </div>

View File

@ -160,19 +160,6 @@ describe("fitToContent animated", () => {
expect(window.requestAnimationFrame).toHaveBeenCalled(); 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(); await waitForNextAnimationFrame();
const prevScrollX = h.state.scrollX; const prevScrollX = h.state.scrollX;

View File

@ -197,68 +197,134 @@ export const throttleRAF = <T extends any[]>(
* @param {number} k - The value to be tweened. * @param {number} k - The value to be tweened.
* @returns {number} The tweened value. * @returns {number} The tweened value.
*/ */
function easeOut(k: number): number { export const easeOut = (k: number) => {
return 1 - Math.pow(1 - k, 4); 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 * Animates values from `fromValues` to `toValues` using the requestAnimationFrame API.
* callback through a requestAnimationFrame call * 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: * const cancelAnimation = easeToValuesRAF({
* ```ts * fromValues,
* easeToValuesRAF([10, 20, 10], [0, 0, 0], (a, b, c) => setState(a,b, c)) * toValues,
* ``` * onStep,
* onCancel,
* });
* *
* @param fromValues The initial values, must be numeric * // To cancel the animation:
* @param toValues The destination values, must also be numeric * cancelAnimation();
* @param callback The callback receiving the values
* @param opts default to 250ms duration and the easeOut function
*/ */
export const easeToValuesRAF = ( export const easeToValuesRAF = <
fromValues: number[], T extends Record<keyof T, number>,
toValues: number[], K extends keyof T,
callback: (...values: number[]) => void, >({
opts?: { duration?: number; easeFn?: (value: number) => number }, 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 canceled = false;
let frameId = 0; let frameId = 0;
let startTime: number; 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) { function step(timestamp: number) {
if (canceled) { if (canceled) {
return; return;
} }
if (startTime === undefined) { if (startTime === undefined) {
startTime = timestamp; 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) { if (elapsed < duration) {
// console.log(elapsed, duration, elapsed / duration); const progress = elapsed / duration;
const factor = easeFn(elapsed / duration);
const newValues = fromValues.map( const newValues = {} as T;
(fromValue, index) =>
(toValues[index] - fromValue) * factor + fromValue, 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); frameId = window.requestAnimationFrame(step);
} else { } else {
// ensure final values are reached at the end of the transition onStep(toValues);
callback(...toValues); onEnd?.();
} }
} }
frameId = window.requestAnimationFrame(step); frameId = window.requestAnimationFrame(step);
return () => { return () => {
onCancel?.();
canceled = true; canceled = true;
window.cancelAnimationFrame(frameId); window.cancelAnimationFrame(frameId);
}; };