diff --git a/CHANGELOG.md b/CHANGELOG.md
index 160c07f8401..7cb9bf9ddc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
## [next]
+- refactor(TS): `animate` and `AnimationRegistry` to classes [#8297](https://github.com/fabricjs/fabric.js/pull/8297)
+ BREAKING:
+ - return animation instance from animate instead of a cancel function and remove `findAnimationByXXX` from `AnimationRegistry`
+ - change `animateColor` signature to match `animate`, removed `colorEasing`
- fix(Object Stacking): 🔙 refactor logic to support Group 🔝
- chore(TS): migrate Group/ActiveSelection [#8455](https://github.com/fabricjs/fabric.js/pull/8455)
- chore(TS): Migrate smaller mixins to classes (dataurl and serialization ) [#8542](https://github.com/fabricjs/fabric.js/pull/8542)
diff --git a/src/canvas/static_canvas.class.ts b/src/canvas/static_canvas.class.ts
index fe972567787..ecc98102541 100644
--- a/src/canvas/static_canvas.class.ts
+++ b/src/canvas/static_canvas.class.ts
@@ -22,7 +22,7 @@ import {
TToCanvasElementOptions,
TValidToObjectMethod,
} from '../typedefs';
-import { cancelAnimFrame, requestAnimFrame } from '../util/animate';
+import { cancelAnimFrame, requestAnimFrame } from '../util/animation';
import {
cleanUpJsdomNode,
getElementOffset,
diff --git a/src/color/color.class.ts b/src/color/color.class.ts
index 973cf77a8ce..1109606a1b8 100644
--- a/src/color/color.class.ts
+++ b/src/color/color.class.ts
@@ -1,50 +1,66 @@
-//@ts-nocheck
import { ColorNameMap } from './color_map';
import { reHSLa, reHex, reRGBa } from './constants';
import { hue2rgb, hexify } from './util';
-type TColorSource = [number, number, number];
+/**
+ * RGB format
+ */
+export type TRGBColorSource = [red: number, green: number, blue: number];
+
+/**
+ * RGBA format
+ */
+export type TRGBAColorSource = [
+ red: number,
+ green: number,
+ blue: number,
+ alpha: number
+];
-type TColorAlphaSource = [number, number, number, number];
+export type TColorArg = string | TRGBColorSource | TRGBAColorSource | Color;
/**
* @class Color common color operations
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2/#colors colors}
*/
export class Color {
- private _source: TColorAlphaSource;
+ private _source: TRGBAColorSource;
/**
*
* @param {string} [color] optional in hex or rgb(a) or hsl format or from known color list
*/
- constructor(color?: string) {
+ constructor(color?: TColorArg) {
if (!color) {
+ // we default to black as canvas does
this.setSource([0, 0, 0, 1]);
+ } else if (color instanceof Color) {
+ this.setSource([...color._source]);
+ } else if (Array.isArray(color)) {
+ const [r, g, b, a = 1] = color;
+ this.setSource([r, g, b, a]);
} else {
- this._tryParsingColor(color);
+ this.setSource(this._tryParsingColor(color));
}
}
/**
* @private
* @param {string} [color] Color value to parse
+ * @returns {TRGBAColorSource}
*/
- _tryParsingColor(color?: string) {
+ protected _tryParsingColor(color: string) {
if (color in ColorNameMap) {
- color = ColorNameMap[color];
+ color = ColorNameMap[color as keyof typeof ColorNameMap];
}
-
- const source =
- color === 'transparent'
- ? [255, 255, 255, 0]
- : Color.sourceFromHex(color) ||
+ return color === 'transparent'
+ ? ([255, 255, 255, 0] as TRGBAColorSource)
+ : Color.sourceFromHex(color) ||
Color.sourceFromRgb(color) ||
- Color.sourceFromHsl(color) || [0, 0, 0, 1]; // color is not recognize let's default to black as canvas does
-
- if (source) {
- this.setSource(source);
- }
+ Color.sourceFromHsl(color) ||
+ // color is not recognized
+ // we default to black as canvas does
+ ([0, 0, 0, 1] as TRGBAColorSource);
}
/**
@@ -53,16 +69,16 @@ export class Color {
* @param {Number} r Red color value
* @param {Number} g Green color value
* @param {Number} b Blue color value
- * @return {TColorSource} Hsl color
+ * @return {TRGBColorSource} Hsl color
*/
- _rgbToHsl(r: number, g: number, b: number): TColorSource {
+ _rgbToHsl(r: number, g: number, b: number): TRGBColorSource {
r /= 255;
g /= 255;
b /= 255;
const maxValue = Math.max(r, g, b),
minValue = Math.min(r, g, b);
- let h, s;
+ let h!: number, s: number;
const l = (maxValue + minValue) / 2;
if (maxValue === minValue) {
@@ -89,7 +105,7 @@ export class Color {
/**
* Returns source of this color (where source is an array representation; ex: [200, 200, 100, 1])
- * @return {TColorAlphaSource}
+ * @return {TRGBAColorSource}
*/
getSource() {
return this._source;
@@ -97,9 +113,9 @@ export class Color {
/**
* Sets source of this color (where source is an array representation; ex: [200, 200, 100, 1])
- * @param {TColorAlphaSource} source
+ * @param {TRGBAColorSource} source
*/
- setSource(source: TColorAlphaSource) {
+ setSource(source: TRGBAColorSource) {
this._source = source;
}
@@ -223,20 +239,14 @@ export class Color {
otherColor = new Color(otherColor);
}
- const result = [],
- alpha = this.getAlpha(),
+ const [r, g, b, alpha] = this.getSource(),
otherAlpha = 0.5,
- source = this.getSource(),
- otherSource = otherColor.getSource();
-
- for (let i = 0; i < 3; i++) {
- result.push(
- Math.round(source[i] * (1 - otherAlpha) + otherSource[i] * otherAlpha)
+ otherSource = otherColor.getSource(),
+ [R, G, B] = [r, g, b].map((value, index) =>
+ Math.round(value * (1 - otherAlpha) + otherSource[index] * otherAlpha)
);
- }
- result[3] = alpha;
- this.setSource(result);
+ this.setSource([R, G, B, alpha]);
return this;
}
@@ -259,16 +269,16 @@ export class Color {
* @return {Color}
*/
static fromRgba(color: string): Color {
- return Color.fromSource(Color.sourceFromRgb(color));
+ return new Color(Color.sourceFromRgb(color));
}
/**
* Returns array representation (ex: [100, 100, 200, 1]) of a color that's in RGB or RGBA format
* @memberOf Color
* @param {String} color Color value ex: rgb(0-255,0-255,0-255), rgb(0%-100%,0%-100%,0%-100%)
- * @return {TColorAlphaSource | undefined} source
+ * @return {TRGBAColorSource | undefined} source
*/
- static sourceFromRgb(color: string): TColorAlphaSource | undefined {
+ static sourceFromRgb(color: string): TRGBAColorSource | undefined {
const match = color.match(reRGBa);
if (match) {
const r =
@@ -281,12 +291,7 @@ export class Color {
(parseInt(match[3], 10) / (/%$/.test(match[3]) ? 100 : 1)) *
(/%$/.test(match[3]) ? 255 : 1);
- return [
- parseInt(r, 10),
- parseInt(g, 10),
- parseInt(b, 10),
- match[4] ? parseFloat(match[4]) : 1,
- ];
+ return [r, g, b, match[4] ? parseFloat(match[4]) : 1];
}
}
@@ -309,7 +314,7 @@ export class Color {
* @return {Color}
*/
static fromHsla(color: string): Color {
- return Color.fromSource(Color.sourceFromHsl(color));
+ return new Color(Color.sourceFromHsl(color));
}
/**
@@ -317,10 +322,10 @@ export class Color {
* Adapted from https://github.com/mjijackson
* @memberOf Color
* @param {String} color Color value ex: hsl(0-360,0%-100%,0%-100%) or hsla(0-360,0%-100%,0%-100%, 0-1)
- * @return {TColorAlphaSource | undefined} source
+ * @return {TRGBAColorSource | undefined} source
* @see http://http://www.w3.org/TR/css3-color/#hsl-color
*/
- static sourceFromHsl(color: string): TColorAlphaSource | undefined {
+ static sourceFromHsl(color: string): TRGBAColorSource | undefined {
const match = color.match(reHSLa);
if (!match) {
return;
@@ -329,7 +334,7 @@ export class Color {
const h = (((parseFloat(match[1]) % 360) + 360) % 360) / 360,
s = parseFloat(match[2]) / (/%$/.test(match[2]) ? 100 : 1),
l = parseFloat(match[3]) / (/%$/.test(match[3]) ? 100 : 1);
- let r, g, b;
+ let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
@@ -358,7 +363,7 @@ export class Color {
* @return {Color}
*/
static fromHex(color: string): Color {
- return Color.fromSource(Color.sourceFromHex(color));
+ return new Color(Color.sourceFromHex(color));
}
/**
@@ -366,9 +371,9 @@ export class Color {
* @static
* @memberOf Color
* @param {String} color ex: FF5555 or FF5544CC (RGBa)
- * @return {TColorAlphaSource | undefined} source
+ * @return {TRGBAColorSource | undefined} source
*/
- static sourceFromHex(color: string): TColorAlphaSource | undefined {
+ static sourceFromHex(color: string): TRGBAColorSource | undefined {
if (color.match(reHex)) {
const value = color.slice(color.indexOf('#') + 1),
isShortNotation = value.length === 3 || value.length === 4,
@@ -396,17 +401,4 @@ export class Color {
];
}
}
-
- /**
- * Returns new color object, when given color in array representation (ex: [200, 100, 100, 0.5])
- * @static
- * @memberOf Color
- * @param {TColorSource | TColorAlphaSource} source
- * @return {Color}
- */
- static fromSource(source: TColorSource | TColorAlphaSource): Color {
- const oColor = new Color();
- oColor.setSource(source);
- return oColor;
- }
}
diff --git a/src/color/util.ts b/src/color/util.ts
index 2c25ebf85e8..3fb9a2d5940 100644
--- a/src/color/util.ts
+++ b/src/color/util.ts
@@ -1,5 +1,4 @@
/**
- * @private
* @param {Number} p
* @param {Number} q
* @param {Number} t
@@ -25,9 +24,7 @@ export function hue2rgb(p: number, q: number, t: number): number {
}
/**
- * Convert a [0, 255] value to hex
- * @param value
- * @returns
+ * Convert a value ∈ [0, 255] to hex
*/
export function hexify(value: number) {
const hexValue = value.toString(16).toUpperCase();
diff --git a/src/shapes/Object/AnimatableObject.ts b/src/shapes/Object/AnimatableObject.ts
index efad8b3b404..6590d666e4d 100644
--- a/src/shapes/Object/AnimatableObject.ts
+++ b/src/shapes/Object/AnimatableObject.ts
@@ -1,15 +1,20 @@
-// @ts-nocheck
+import { TColorArg } from '../../color/color.class';
import { noop } from '../../constants';
import { ObjectEvents } from '../../EventTypeDefs';
import { TDegree } from '../../typedefs';
-import { animate } from '../../util/animate';
-import { animateColor } from '../../util/animate_color';
+import {
+ animate,
+ animateColor,
+ AnimationOptions,
+ ColorAnimationOptions,
+} from '../../util/animation';
+import type { ColorAnimation } from '../../util/animation/ColorAnimation';
+import type { ValueAnimation } from '../../util/animation/ValueAnimation';
import { StackedObject } from './StackedObject';
-/**
- * TODO remove transient
- */
-type TAnimationOptions = Record;
+type TAnimationOptions = T extends number
+ ? AnimationOptions
+ : ColorAnimationOptions;
export abstract class AnimatableObject<
EventSpec extends ObjectEvents = ObjectEvents
@@ -34,7 +39,7 @@ export abstract class AnimatableObject<
* @param {String|Object} property Property to animate (if string) or properties to animate (if object)
* @param {Number|Object} value Value to animate property to (if string was given first) or options object
* @tutorial {@link http://fabricjs.com/fabric-intro-part-2#animation}
- * @return {AnimationContext | AnimationContext[]} animation context (or an array if passed multiple properties)
+ * @return {(ColorAnimation | ValueAnimation)[]} animation context (or an array if passed multiple properties)
*
* As object — multiple properties
*
@@ -42,26 +47,37 @@ export abstract class AnimatableObject<
* object.animate({ left: ..., top: ... }, { duration: ... });
*
* As string — one property
+ * Supports +=N and -=N for animating N units in a given direction
*
* object.animate('left', ...);
* object.animate('left', ..., { duration: ... });
*
+ * Example of +=/-=
+ * object.animate('right', '-=50');
+ * object.animate('top', '+=50', { duration: ... });
*/
- animate(key: string, toValue: T, options?: TAnimationOptions): void;
- animate(animatable: Record, options?: TAnimationOptions): void;
- animate>(
+ animate(
+ key: string,
+ toValue: T,
+ options?: Partial>
+ ): (ColorAnimation | ValueAnimation)[];
+ animate(
+ animatable: Record,
+ options?: Partial>
+ ): (ColorAnimation | ValueAnimation)[];
+ animate>(
arg0: S,
- arg1: S extends string ? T : TAnimationOptions,
- arg2?: S extends string ? TAnimationOptions : never
- ) {
+ arg1: S extends string ? T : Partial>,
+ arg2?: S extends string ? Partial> : never
+ ): (ColorAnimation | ValueAnimation)[] {
const animatable = (
typeof arg0 === 'string' ? { [arg0]: arg1 } : arg0
) as Record;
const keys = Object.keys(animatable);
- const options = (
- typeof arg0 === 'string' ? arg2 : arg1
- ) as TAnimationOptions;
- keys.map((key, index) =>
+ const options = (typeof arg0 === 'string' ? arg2 : arg1) as Partial<
+ TAnimationOptions
+ >;
+ return keys.map((key, index) =>
this._animate(
key,
animatable[key],
@@ -78,58 +94,70 @@ export abstract class AnimatableObject<
* @param {String} to Value to animate to
* @param {Object} [options] Options object
*/
- _animate(key: string, to: T, options: TAnimationOptions = {}) {
+ _animate(
+ key: string,
+ to: T,
+ options: Partial> = {}
+ ) {
const path = key.split('.');
const propIsColor = this.colorProperties.includes(path[path.length - 1]);
const currentValue = path.reduce((deep: any, key) => deep[key], this);
- to = to.toString();
- if (!propIsColor) {
- if (~to.indexOf('=')) {
- to = currentValue + parseFloat(to.replace('=', ''));
- } else {
- to = parseFloat(to);
- }
+ if (!propIsColor && typeof to === 'string') {
+ // check for things like +=50
+ // which should animate so that the thing moves by 50 units in the positive direction
+ to = to.includes('=')
+ ? currentValue + parseFloat(to.replace('=', ''))
+ : parseFloat(to);
}
const animationOptions = {
target: this,
- startValue: options.from ?? currentValue,
+ startValue:
+ options.startValue ??
+ // backward compat
+ (options as any).from ??
+ currentValue,
endValue: to,
- byValue: options.by,
+ // `byValue` takes precedence over `endValue`
+ byValue:
+ options.byValue ??
+ // backward compat
+ (options as any).by,
easing: options.easing,
duration: options.duration,
- abort:
- options.abort &&
- ((value, valueProgress, timeProgress) => {
- return options.abort.call(this, value, valueProgress, timeProgress);
- }),
- onChange: (value, valueProgress, timeProgress) => {
- path.reduce((deep: any, key, index) => {
+ abort: options.abort?.bind(this),
+ onChange: (
+ value: string | number,
+ valueRatio: number,
+ durationRatio: number
+ ) => {
+ path.reduce((deep: Record, key, index) => {
if (index === path.length - 1) {
deep[key] = value;
}
return deep[key];
}, this);
options.onChange &&
- options.onChange(value, valueProgress, timeProgress);
+ // @ts-expect-error generic callback arg0 is wrong
+ options.onChange(value, valueRatio, durationRatio);
},
- onComplete: (value, valueProgress, timeProgress) => {
+ onComplete: (
+ value: string | number,
+ valueRatio: number,
+ durationRatio: number
+ ) => {
this.setCoords();
options.onComplete &&
- options.onComplete(value, valueProgress, timeProgress);
+ // @ts-expect-error generic callback arg0 is wrong
+ options.onComplete(value, valueRatio, durationRatio);
},
- };
+ } as TAnimationOptions;
if (propIsColor) {
- return animateColor(
- animationOptions.startValue,
- animationOptions.endValue,
- animationOptions.duration,
- animationOptions
- );
+ return animateColor(animationOptions as ColorAnimationOptions);
} else {
- return animate(animationOptions);
+ return animate(animationOptions as AnimationOptions);
}
}
diff --git a/src/shapes/Object/Object.ts b/src/shapes/Object/Object.ts
index d55cb7aca2b..3c941f96328 100644
--- a/src/shapes/Object/Object.ts
+++ b/src/shapes/Object/Object.ts
@@ -15,7 +15,7 @@ import type {
TCacheCanvasDimensions,
} from '../../typedefs';
import { classRegistry } from '../../util/class_registry';
-import { runningAnimations } from '../../util/animation_registry';
+import { runningAnimations } from '../../util/animation';
import { clone } from '../../util/lang_object';
import { capitalize } from '../../util/lang_string';
import { capValue } from '../../util/misc/capValue';
diff --git a/src/util/animate.ts b/src/util/animate.ts
deleted file mode 100644
index a9e785de666..00000000000
--- a/src/util/animate.ts
+++ /dev/null
@@ -1,176 +0,0 @@
-//@ts-nocheck
-import { fabric } from '../../HEADER';
-import { runningAnimations } from './animation_registry';
-import { noop } from '../constants';
-
-/**
- *
- * @typedef {Object} AnimationOptions
- * Animation of a value or list of values.
- * @property {Function} [onChange] Callback; invoked on every value change
- * @property {Function} [onComplete] Callback; invoked when value change is completed
- * @property {number | number[]} [startValue=0] Starting value
- * @property {number | number[]} [endValue=100] Ending value
- * @property {number | number[]} [byValue=100] Value to modify the property by
- * @property {Function} [easing] Easing function
- * @property {number} [duration=500] Duration of change (in ms)
- * @property {Function} [abort] Additional function with logic. If returns true, animation aborts.
- * @property {number} [delay] Delay of animation start (in ms)
- *
- * @typedef {() => void} CancelFunction
- *
- * @typedef {Object} AnimationCurrentState
- * @property {number | number[]} currentValue value in range [`startValue`, `endValue`]
- * @property {number} completionRate value in range [0, 1]
- * @property {number} durationRate value in range [0, 1]
- *
- * @typedef {(AnimationOptions & AnimationCurrentState & { cancel: CancelFunction }} AnimationContext
- */
-
-const defaultEasing = (t, b, c, d) =>
- -c * Math.cos((t / d) * (Math.PI / 2)) + c + b;
-
-/**
- * Changes value from one to another within certain period of time, invoking callbacks as value is being changed.
- * @memberOf fabric.util
- * @param {AnimationOptions} [options] Animation options
- * When using lists, think of something like this:
- * @example
- * fabric.util.animate({
- * startValue: [1, 2, 3],
- * endValue: [2, 4, 6],
- * onChange: function([x, y, zoom]) {
- * canvas.zoomToPoint(new Point(x, y), zoom);
- * canvas.requestRenderAll();
- * }
- * });
- *
- * @example
- * fabric.util.animate({
- * startValue: 1,
- * endValue: 0,
- * onChange: function(v) {
- * obj.set('opacity', v);
- * canvas.requestRenderAll();
- * }
- * });
- *
- * @returns {CancelFunction} cancel function
- */
-export function animate(options = {}) {
- let cancel = false;
-
- const {
- startValue = 0,
- duration = 500,
- easing = defaultEasing,
- onChange = noop,
- abort = noop,
- onComplete = noop,
- endValue = 100,
- delay = 0,
- } = options;
-
- const context = {
- ...options,
- currentValue: startValue,
- completionRate: 0,
- durationRate: 0,
- };
-
- const removeFromRegistry = () => {
- const index = runningAnimations.indexOf(context);
- return index > -1 && runningAnimations.splice(index, 1)[0];
- };
-
- context.cancel = function () {
- cancel = true;
- return removeFromRegistry();
- };
- runningAnimations.push(context);
-
- const runner = function (timestamp) {
- const start = timestamp || +new Date(),
- finish = start + duration,
- isMany = Array.isArray(startValue),
- byValue =
- options.byValue ||
- (isMany
- ? startValue.map((value, i) => endValue[i] - value)
- : endValue - startValue);
-
- options.onStart && options.onStart();
-
- (function tick(ticktime) {
- const time = ticktime || +new Date();
- const currentTime = time > finish ? duration : time - start,
- timePerc = currentTime / duration,
- current = isMany
- ? startValue.map((_value, i) =>
- easing(currentTime, _value, byValue[i], duration)
- )
- : easing(currentTime, startValue, byValue, duration),
- valuePerc = isMany
- ? Math.abs((current[0] - startValue[0]) / byValue[0])
- : Math.abs((current - startValue) / byValue);
- // update context
- context.currentValue = isMany ? current.slice() : current;
- context.completionRate = valuePerc;
- context.durationRate = timePerc;
-
- if (cancel) {
- return;
- }
- if (abort(current, valuePerc, timePerc)) {
- removeFromRegistry();
- return;
- }
- if (time > finish) {
- // update context
- context.currentValue = isMany ? endValue.slice() : endValue;
- context.completionRate = 1;
- context.durationRate = 1;
- // execute callbacks
- onChange(isMany ? endValue.slice() : endValue, 1, 1);
- onComplete(endValue, 1, 1);
- removeFromRegistry();
- return;
- } else {
- onChange(current, valuePerc, timePerc);
- requestAnimFrame(tick);
- }
- })(start);
- };
-
- if (delay > 0) {
- setTimeout(() => requestAnimFrame(runner), delay);
- } else {
- requestAnimFrame(runner);
- }
-
- return context.cancel;
-}
-
-const _requestAnimFrame =
- fabric.window.requestAnimationFrame ||
- function (callback) {
- return fabric.window.setTimeout(callback, 1000 / 60);
- };
-
-const _cancelAnimFrame =
- fabric.window.cancelAnimationFrame || fabric.window.clearTimeout;
-
-/**
- * requestAnimationFrame polyfill based on http://paulirish.com/2011/requestanimationframe-for-smart-animating/
- * In order to get a precise start time, `requestAnimFrame` should be called as an entry into the method
- * @memberOf fabric.util
- * @param {Function} callback Callback to invoke
- * @param {DOMElement} element optional Element to associate with animation
- */
-export function requestAnimFrame(...args) {
- return _requestAnimFrame.apply(fabric.window, args);
-}
-
-export function cancelAnimFrame(...args) {
- return _cancelAnimFrame.apply(fabric.window, args);
-}
diff --git a/src/util/animate_color.ts b/src/util/animate_color.ts
deleted file mode 100644
index 971196e41d0..00000000000
--- a/src/util/animate_color.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-//@ts-nocheck
-import { Color } from '../color';
-import { animate } from './animate';
-
-// Calculate an in-between color. Returns a "rgba()" string.
-// Credit: Edwin Martin
-// http://www.bitstorm.org/jquery/color-animation/jquery.animate-colors.js
-// const calculateColor = (begin: number[], end: number[], pos) => {
-// const [r, g, b, _a] = begin.map((beg, index) => beg + pos * (end[index] - beg));
-// const a = begin && end ? parseFloat(_a) : 1;
-// return `rgba(${parseInt(r, 10)},${parseInt(g, 10)},${parseInt(b, 10)},${a})`;
-// }
-
-// color animation is broken. This function pass the tests for some reasons
-// but begin and end aren't array anymore since we improved animate function
-// to handler arrays internally.
-function calculateColor(begin, end, pos) {
- let color =
- 'rgba(' +
- parseInt(begin[0] + pos * (end[0] - begin[0]), 10) +
- ',' +
- parseInt(begin[1] + pos * (end[1] - begin[1]), 10) +
- ',' +
- parseInt(begin[2] + pos * (end[2] - begin[2]), 10);
-
- color +=
- ',' + (begin && end ? parseFloat(begin[3] + pos * (end[3] - begin[3])) : 1);
- color += ')';
- return color;
-}
-
-const defaultColorEasing = (currentTime, duration) =>
- 1 - Math.cos((currentTime / duration) * (Math.PI / 2));
-
-/**
- * Changes the color from one to another within certain period of time, invoking callbacks as value is being changed.
- * @memberOf fabric.util
- * @param {String} fromColor The starting color in hex or rgb(a) format.
- * @param {String} toColor The starting color in hex or rgb(a) format.
- * @param {Number} [duration] Duration of change (in ms).
- * @param {Object} [options] Animation options
- * @param {Function} [options.onChange] Callback; invoked on every value change
- * @param {Function} [options.onComplete] Callback; invoked when value change is completed
- * @param {Function} [options.colorEasing] Easing function. Note that this function only take two arguments (currentTime, duration). Thus the regular animation easing functions cannot be used.
- * @param {Function} [options.abort] Additional function with logic. If returns true, onComplete is called.
- * @returns {Function} abort function
- */
-export function animateColor(
- fromColor,
- toColor,
- duration = 500,
- {
- colorEasing = defaultColorEasing,
- onComplete,
- onChange,
- ...restOfOptions
- } = {}
-) {
- const startColor = new Color(fromColor).getSource(),
- endColor = new Color(toColor).getSource();
- return animate({
- ...restOfOptions,
- duration,
- startValue: startColor,
- endValue: endColor,
- byValue: endColor,
- easing: (currentTime, startValue, byValue, duration) =>
- calculateColor(startValue, byValue, colorEasing(currentTime, duration)),
- // has to take in account for color restoring;
- onComplete: (current, valuePerc, timePerc) =>
- onComplete?.(calculateColor(endColor, endColor, 0), valuePerc, timePerc),
- onChange: (current, valuePerc, timePerc) => {
- if (onChange) {
- if (Array.isArray(current)) {
- return onChange(
- calculateColor(current, current, 0),
- valuePerc,
- timePerc
- );
- }
- onChange(current, valuePerc, timePerc);
- }
- },
- });
-}
diff --git a/src/util/animation/AnimationBase.ts b/src/util/animation/AnimationBase.ts
new file mode 100644
index 00000000000..ec358a2ab99
--- /dev/null
+++ b/src/util/animation/AnimationBase.ts
@@ -0,0 +1,169 @@
+import { noop } from '../../constants';
+import { requestAnimFrame } from './AnimationFrameProvider';
+import { runningAnimations } from './AnimationRegistry';
+import { defaultEasing } from './easing';
+import {
+ AnimationState,
+ TAbortCallback,
+ TAnimationBaseOptions,
+ TAnimationCallbacks,
+ TAnimationValues,
+ TEasingFunction,
+ TOnAnimationChangeCallback,
+} from './types';
+
+const defaultAbort = () => false;
+
+export abstract class AnimationBase<
+ T extends number | number[] = number | number[]
+> {
+ readonly startValue: T;
+ readonly byValue: T;
+ readonly endValue: T;
+ readonly duration: number;
+ readonly delay: number;
+ protected readonly easing: TEasingFunction;
+
+ private readonly _onStart: VoidFunction;
+ private readonly _onChange: TOnAnimationChangeCallback;
+ private readonly _onComplete: TOnAnimationChangeCallback;
+ private readonly _abort: TAbortCallback;
+
+ /**
+ * Used to register the animation to a target object
+ * so that it can be cancelled within the object context
+ */
+ readonly target?: unknown;
+
+ private _state: AnimationState = 'pending';
+ /**
+ * Time %, or the ratio of `timeElapsed / duration`
+ * @see tick
+ */
+ durationRatio = 0;
+ /**
+ * Value %, or the ratio of `(currentValue - startValue) / (endValue - startValue)`
+ */
+ valueRatio = 0;
+ /**
+ * Current value
+ */
+ value: T;
+ /**
+ * Animation start time ms
+ */
+ private startTime!: number;
+
+ /**
+ * Constructor
+ * Since both `byValue` and `endValue` are accepted in subclass options
+ * and are populated with defaults if missing, we defer to `byValue` and
+ * ignore `endValue` to avoid conflict
+ */
+ constructor({
+ startValue,
+ byValue,
+ duration = 500,
+ delay = 0,
+ easing = defaultEasing,
+ onStart = noop,
+ onChange = noop,
+ onComplete = noop,
+ abort = defaultAbort,
+ target,
+ }: Partial & TAnimationCallbacks> &
+ Required, 'endValue'>>) {
+ this.tick = this.tick.bind(this);
+
+ this.duration = duration;
+ this.delay = delay;
+ this.easing = easing;
+ this._onStart = onStart;
+ this._onChange = onChange;
+ this._onComplete = onComplete;
+ this._abort = abort;
+ this.target = target;
+
+ this.startValue = startValue;
+ this.byValue = byValue;
+ this.value = this.startValue;
+ this.endValue = this.calculate(this.duration).value;
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ /**
+ * Calculate the current value based on the easing parameters
+ * @param timeElapsed in ms
+ * @protected
+ */
+ protected abstract calculate(timeElapsed: number): {
+ value: T;
+ changeRatio: number;
+ };
+
+ start() {
+ const firstTick: FrameRequestCallback = (timestamp) => {
+ if (this._state !== 'pending') return;
+ this.startTime = timestamp || +new Date();
+ this._state = 'running';
+ this._onStart();
+ this.tick(this.startTime);
+ };
+
+ this.register();
+
+ // setTimeout(cb, 0) will run cb on the next frame, causing a delay
+ // we don't want that
+ if (this.delay > 0) {
+ setTimeout(() => requestAnimFrame(firstTick), this.delay);
+ } else {
+ requestAnimFrame(firstTick);
+ }
+ }
+
+ private tick(t: number) {
+ const durationMs = (t || +new Date()) - this.startTime;
+ const boundDurationMs = Math.min(durationMs, this.duration);
+ this.durationRatio = boundDurationMs / this.duration;
+ const { value, changeRatio } = this.calculate(boundDurationMs);
+ this.value = Array.isArray(value) ? (value.slice() as T) : value;
+ this.valueRatio = changeRatio;
+
+ if (this._state === 'aborted') {
+ return;
+ } else if (this._abort(value, this.valueRatio, this.durationRatio)) {
+ this._state = 'aborted';
+ this.unregister();
+ } else if (durationMs >= this.duration) {
+ const endValue = this.endValue;
+ this.durationRatio = this.valueRatio = 1;
+ this._onChange(
+ (Array.isArray(endValue) ? endValue.slice() : endValue) as T,
+ this.valueRatio,
+ this.durationRatio
+ );
+ this._state = 'completed';
+ this._onComplete(endValue, this.valueRatio, this.durationRatio);
+ this.unregister();
+ } else {
+ this._onChange(value, this.valueRatio, this.durationRatio);
+ requestAnimFrame(this.tick);
+ }
+ }
+
+ private register() {
+ runningAnimations.push(this as unknown as AnimationBase);
+ }
+
+ private unregister() {
+ runningAnimations.remove(this as unknown as AnimationBase);
+ }
+
+ abort() {
+ this._state = 'aborted';
+ this.unregister();
+ }
+}
diff --git a/src/util/animation/AnimationFrameProvider.ts b/src/util/animation/AnimationFrameProvider.ts
new file mode 100644
index 00000000000..c410676cd40
--- /dev/null
+++ b/src/util/animation/AnimationFrameProvider.ts
@@ -0,0 +1,23 @@
+import { fabric } from '../../../HEADER';
+
+const _requestAnimFrame: AnimationFrameProvider['requestAnimationFrame'] =
+ fabric.window.requestAnimationFrame ||
+ function (callback: FrameRequestCallback) {
+ return fabric.window.setTimeout(callback, 1000 / 60);
+ };
+
+const _cancelAnimFrame: AnimationFrameProvider['cancelAnimationFrame'] =
+ fabric.window.cancelAnimationFrame || fabric.window.clearTimeout;
+
+/**
+ * requestAnimationFrame polyfill based on http://paulirish.com/2011/requestanimationframe-for-smart-animating/
+ * In order to get a precise start time, `requestAnimFrame` should be called as an entry into the method
+ * @param {Function} callback Callback to invoke
+ */
+export function requestAnimFrame(callback: FrameRequestCallback): number {
+ return _requestAnimFrame.call(fabric.window, callback);
+}
+
+export function cancelAnimFrame(handle: number): void {
+ return _cancelAnimFrame.call(fabric.window, handle);
+}
diff --git a/src/util/animation/AnimationRegistry.ts b/src/util/animation/AnimationRegistry.ts
new file mode 100644
index 00000000000..989a5ccccb8
--- /dev/null
+++ b/src/util/animation/AnimationRegistry.ts
@@ -0,0 +1,61 @@
+import { fabric } from '../../../HEADER';
+import type { Canvas } from '../../canvas/canvas_events';
+import type { FabricObject } from '../../shapes/Object/FabricObject';
+import type { AnimationBase } from './AnimationBase';
+
+/**
+ * Array holding all running animations
+ */
+class AnimationRegistry extends Array {
+ /**
+ * Remove a single animation using an animation context
+ * @param {AnimationBase} context
+ */
+ remove(context: AnimationBase) {
+ const index = this.indexOf(context);
+ index > -1 && this.splice(index, 1);
+ }
+
+ /**
+ * Cancel all running animations on the next frame
+ */
+ cancelAll() {
+ const animations = this.splice(0);
+ animations.forEach((animation) => animation.abort());
+ return animations;
+ }
+
+ /**
+ * Cancel all running animations attached to a Canvas on the next frame
+ * @param {Canvas} canvas
+ */
+ cancelByCanvas(canvas: Canvas) {
+ if (!canvas) {
+ return [];
+ }
+ const animations = this.filter(
+ (animation) =>
+ typeof animation.target === 'object' &&
+ (animation.target as FabricObject)?.canvas === canvas
+ );
+ animations.forEach((animation) => animation.abort());
+ return animations;
+ }
+
+ /**
+ * Cancel all running animations for target on the next frame
+ * @param target
+ */
+ cancelByTarget(target: AnimationBase['target']) {
+ if (!target) {
+ return [];
+ }
+ const animations = this.filter((animation) => animation.target === target);
+ animations.forEach((animation) => animation.abort());
+ return animations;
+ }
+}
+
+export const runningAnimations = new AnimationRegistry();
+
+fabric.runningAnimations = runningAnimations;
diff --git a/src/util/animation/ArrayAnimation.ts b/src/util/animation/ArrayAnimation.ts
new file mode 100644
index 00000000000..8d519715bb5
--- /dev/null
+++ b/src/util/animation/ArrayAnimation.ts
@@ -0,0 +1,26 @@
+import { AnimationBase } from './AnimationBase';
+import { ArrayAnimationOptions } from './types';
+
+export class ArrayAnimation extends AnimationBase {
+ constructor({
+ startValue = [0],
+ endValue = [100],
+ byValue = endValue.map((value, i) => value - startValue[i]),
+ ...options
+ }: ArrayAnimationOptions) {
+ super({
+ ...options,
+ startValue,
+ byValue,
+ });
+ }
+ protected calculate(timeElapsed: number) {
+ const values = this.startValue.map((value, i) =>
+ this.easing(timeElapsed, value, this.byValue[i], this.duration, i)
+ );
+ return {
+ value: values,
+ changeRatio: Math.abs((values[0] - this.startValue[0]) / this.byValue[0]),
+ };
+ }
+}
diff --git a/src/util/animation/ColorAnimation.ts b/src/util/animation/ColorAnimation.ts
new file mode 100644
index 00000000000..fe4be4ec021
--- /dev/null
+++ b/src/util/animation/ColorAnimation.ts
@@ -0,0 +1,65 @@
+import { Color } from '../../color';
+import { TRGBAColorSource } from '../../color/color.class';
+import { capValue } from '../misc/capValue';
+import { AnimationBase } from './AnimationBase';
+import { ColorAnimationOptions, TOnAnimationChangeCallback } from './types';
+
+const wrapColorCallback = (
+ callback?: TOnAnimationChangeCallback
+) =>
+ callback &&
+ ((rgba: TRGBAColorSource, valueRatio: number, durationRatio: number) =>
+ callback(new Color(rgba).toRgba(), valueRatio, durationRatio));
+
+export class ColorAnimation extends AnimationBase {
+ constructor({
+ startValue,
+ endValue,
+ byValue,
+ easing = (timeElapsed, startValue, byValue, duration) => {
+ const durationRatio =
+ 1 - Math.cos((timeElapsed / duration) * (Math.PI / 2));
+ return startValue + byValue * durationRatio;
+ },
+ onChange,
+ onComplete,
+ abort,
+ ...options
+ }: ColorAnimationOptions) {
+ const startColor = new Color(startValue).getSource();
+ const endColor = new Color(endValue).getSource();
+ super({
+ ...options,
+ startValue: startColor,
+ byValue: byValue
+ ? new Color(byValue)
+ .setAlpha(Array.isArray(byValue) && byValue[3] ? byValue[3] : 0)
+ .getSource()
+ : (endColor.map(
+ (value, i) => value - startColor[i]
+ ) as TRGBAColorSource),
+ easing,
+ onChange: wrapColorCallback(onChange),
+ onComplete: wrapColorCallback(onComplete),
+ abort: wrapColorCallback(abort),
+ });
+ }
+ protected calculate(timeElapsed: number) {
+ const [r, g, b, a] = this.startValue.map((value, i) =>
+ this.easing(timeElapsed, value, this.byValue[i], this.duration, i)
+ ) as TRGBAColorSource;
+ const rgb = [r, g, b].map(Math.round);
+ return {
+ value: [...rgb, capValue(0, a, 1)] as TRGBAColorSource,
+ changeRatio:
+ // to correctly calculate the change ratio we must find a changed value
+ rgb
+ .map((p, i) =>
+ this.byValue[i] !== 0
+ ? Math.abs((p - this.startValue[i]) / this.byValue[i])
+ : 0
+ )
+ .find((p) => p !== 0) || 0,
+ };
+ }
+}
diff --git a/src/util/animation/ValueAnimation.ts b/src/util/animation/ValueAnimation.ts
new file mode 100644
index 00000000000..e0d329b49c5
--- /dev/null
+++ b/src/util/animation/ValueAnimation.ts
@@ -0,0 +1,30 @@
+import { AnimationBase } from './AnimationBase';
+import { AnimationOptions } from './types';
+
+export class ValueAnimation extends AnimationBase {
+ constructor({
+ startValue = 0,
+ endValue = 100,
+ byValue = endValue - startValue,
+ ...options
+ }: AnimationOptions) {
+ super({
+ ...options,
+ startValue,
+ byValue,
+ });
+ }
+
+ protected calculate(timeElapsed: number) {
+ const value = this.easing(
+ timeElapsed,
+ this.startValue,
+ this.byValue,
+ this.duration
+ );
+ return {
+ value,
+ changeRatio: Math.abs((value - this.startValue) / this.byValue),
+ };
+ }
+}
diff --git a/src/util/animation/animate.ts b/src/util/animation/animate.ts
new file mode 100644
index 00000000000..cfcb17fd23e
--- /dev/null
+++ b/src/util/animation/animate.ts
@@ -0,0 +1,65 @@
+import { ValueAnimation } from './ValueAnimation';
+import { ArrayAnimation } from './ArrayAnimation';
+import { ColorAnimation } from './ColorAnimation';
+import {
+ AnimationOptions,
+ ArrayAnimationOptions,
+ ColorAnimationOptions,
+} from './types';
+
+const isArrayAnimation = (
+ options: ArrayAnimationOptions | AnimationOptions
+): options is ArrayAnimationOptions => {
+ return (
+ Array.isArray(options.startValue) ||
+ Array.isArray(options.endValue) ||
+ Array.isArray(options.byValue)
+ );
+};
+
+/**
+ * Changes value(s) from startValue to endValue within a certain period of time,
+ * invoking callbacks as the value(s) change.
+ *
+ * @example
+ * animate({
+ * startValue: 1,
+ * endValue: 0,
+ * onChange: (v) => {
+ * obj.set('opacity', v);
+ * // since we are running in a requested frame we should call `renderAll` and not `requestRenderAll`
+ * canvas.renderAll();
+ * }
+ * });
+ *
+ * @example Using lists:
+ * animate({
+ * startValue: [1, 2, 3],
+ * endValue: [2, 4, 6],
+ * onChange: ([x, y, zoom]) => {
+ * canvas.zoomToPoint(new Point(x, y), zoom);
+ * canvas.renderAll();
+ * }
+ * });
+ *
+ */
+export const animate = <
+ T extends AnimationOptions | ArrayAnimationOptions,
+ R extends T extends ArrayAnimationOptions ? ArrayAnimation : ValueAnimation
+>(
+ options: T
+): R => {
+ const animation = (
+ isArrayAnimation(options)
+ ? new ArrayAnimation(options)
+ : new ValueAnimation(options)
+ ) as R;
+ animation.start();
+ return animation;
+};
+
+export const animateColor = (options: ColorAnimationOptions) => {
+ const animation = new ColorAnimation(options);
+ animation.start();
+ return animation;
+};
diff --git a/src/util/anim_ease.ts b/src/util/animation/easing.ts
similarity index 92%
rename from src/util/anim_ease.ts
rename to src/util/animation/easing.ts
index 3c76141f3ff..54eaf0d120e 100644
--- a/src/util/anim_ease.ts
+++ b/src/util/animation/easing.ts
@@ -1,16 +1,10 @@
/**
* Easing functions
- * See Easing Equations by Robert Penner
+ * @see {@link http://gizma.com/easing/ Easing Equations by Robert Penner}
*/
-import { twoMathPi, halfPI } from '../constants';
-
-type TEasingFunction = (
- currentTime: number,
- startValue: number,
- byValue: number,
- duration: number
-) => number;
+import { twoMathPi, halfPI } from '../../constants';
+import { TEasingFunction } from './types';
const normalize = (a: number, c: number, p: number, s: number) => {
if (a < Math.abs(c)) {
@@ -36,11 +30,23 @@ const elastic = (
): number =>
a * Math.pow(2, 10 * (t -= 1)) * Math.sin(((t * d - s) * twoMathPi) / p);
+/**
+ * Default sinusoidal easing
+ */
+export const defaultEasing: TEasingFunction = (t, b, c, d) =>
+ -c * Math.cos((t / d) * halfPI) + c + b;
+
+/**
+ * Cubic easing in
+ */
+export const easeInCubic: TEasingFunction = (t, b, c, d) =>
+ c * (t / d) ** 3 + b;
+
/**
* Cubic easing out
*/
export const easeOutCubic: TEasingFunction = (t, b, c, d) =>
- c * ((t /= d - 1) * t ** 2 + 1) + b;
+ c * ((t / d - 1) ** 3 + 1) + b;
/**
* Cubic easing in and out
@@ -50,7 +56,7 @@ export const easeInOutCubic: TEasingFunction = (t, b, c, d) => {
if (t < 1) {
return (c / 2) * t ** 3 + b;
}
- return (c / 2) * ((t -= 2) * t ** 2 + 2) + b;
+ return (c / 2) * ((t - 2) ** 3 + 2) + b;
};
/**
@@ -80,13 +86,13 @@ export const easeInOutQuart: TEasingFunction = (t, b, c, d) => {
* Quintic easing in
*/
export const easeInQuint: TEasingFunction = (t, b, c, d) =>
- c * (t /= d) * t ** 4 + b;
+ c * (t / d) ** 5 + b;
/**
* Quintic easing out
*/
export const easeOutQuint: TEasingFunction = (t, b, c, d) =>
- c * ((t /= d - 1) * t ** 4 + 1) + b;
+ c * ((t / d - 1) ** 5 + 1) + b;
/**
* Quintic easing in and out
@@ -96,7 +102,7 @@ export const easeInOutQuint: TEasingFunction = (t, b, c, d) => {
if (t < 1) {
return (c / 2) * t ** 5 + b;
}
- return (c / 2) * ((t -= 2) * t ** 4 + 2) + b;
+ return (c / 2) * ((t - 2) ** 5 + 2) + b;
};
/**
@@ -319,9 +325,3 @@ export const easeInOutQuad: TEasingFunction = (t, b, c, d) => {
}
return (-c / 2) * (--t * (t - 2) - 1) + b;
};
-
-/**
- * Cubic easing in
- */
-export const easeInCubic: TEasingFunction = (t, b, c, d) =>
- c * (t /= d) * t * t + b;
diff --git a/src/util/animation/index.ts b/src/util/animation/index.ts
new file mode 100644
index 00000000000..f1fea7f06de
--- /dev/null
+++ b/src/util/animation/index.ts
@@ -0,0 +1,5 @@
+export * from './animate';
+export * from './AnimationFrameProvider';
+export * from './AnimationRegistry';
+export * as ease from './easing';
+export * from './types';
diff --git a/src/util/animation/types.ts b/src/util/animation/types.ts
new file mode 100644
index 00000000000..b5c3af11004
--- /dev/null
+++ b/src/util/animation/types.ts
@@ -0,0 +1,138 @@
+import { TColorArg } from '../../color/color.class';
+
+export type AnimationState = 'pending' | 'running' | 'completed' | 'aborted';
+
+/**
+ * Callback called every frame
+ * @param {number | number[]} value current value of the animation.
+ * @param valueRatio ∈ [0, 1], current value / end value.
+ * @param durationRatio ∈ [0, 1], time passed / duration.
+ */
+export type TOnAnimationChangeCallback = (
+ value: T,
+ valueRatio: number,
+ durationRatio: number
+) => R;
+
+/**
+ * Called on each step to determine if animation should abort
+ * @returns truthy if animation should abort
+ */
+export type TAbortCallback = TOnAnimationChangeCallback;
+
+/**
+ * An easing function used to calculate the current value
+ * @see {@link AnimationBase['calculate']}
+ *
+ * @param timeElapsed ms elapsed since start
+ * @param startValue
+ * @param byValue
+ * @param duration in ms
+ * @returns next value
+ */
+export type TEasingFunction = T extends number[]
+ ? (
+ timeElapsed: number,
+ startValue: number,
+ byValue: number,
+ duration: number,
+ index: number
+ ) => number
+ : (
+ timeElapsed: number,
+ startValue: number,
+ byValue: number,
+ duration: number
+ ) => number;
+
+export type TAnimationBaseOptions = {
+ /**
+ * Duration of the animation in ms
+ * @default 500
+ */
+ duration?: number;
+
+ /**
+ * Delay to start the animation in ms
+ * @default 0
+ */
+ delay?: number;
+
+ /**
+ * Easing function
+ * @default {defaultEasing}
+ */
+ easing?: TEasingFunction;
+
+ /**
+ * The object this animation is being performed on
+ */
+ target: unknown;
+};
+
+export type TAnimationCallbacks = {
+ /**
+ * Called when the animation starts
+ */
+ onStart: VoidFunction;
+
+ /**
+ * Called at each frame of the animation
+ */
+ onChange: TOnAnimationChangeCallback;
+
+ /**
+ * Called after the last frame of the animation
+ */
+ onComplete: TOnAnimationChangeCallback;
+
+ /**
+ * Function called at each frame.
+ * If it returns true, abort
+ */
+ abort: TAbortCallback;
+};
+
+export type TAnimationValues =
+ | {
+ /**
+ * Starting value(s)
+ * @default 0
+ */
+ startValue: T;
+ } & (
+ | {
+ /**
+ * Ending value(s)
+ * Ignored if `byValue` exists
+ * @default 100
+ */
+ endValue: T;
+ byValue?: never;
+ }
+ | {
+ /**
+ * Difference between the start value(s) to the end value(s)
+ * Overrides `endValue`
+ * @default [endValue - startValue]
+ */
+ byValue: T;
+ endValue?: never;
+ }
+ );
+
+export type TAnimationOptions = Partial<
+ TAnimationBaseOptions &
+ TAnimationValues &
+ TAnimationCallbacks
+>;
+
+export type AnimationOptions = TAnimationOptions;
+
+export type ArrayAnimationOptions = TAnimationOptions;
+
+export type ColorAnimationOptions = TAnimationOptions<
+ TColorArg,
+ string,
+ number[]
+>;
diff --git a/src/util/animation_registry.ts b/src/util/animation_registry.ts
deleted file mode 100644
index 7e4acbd5e37..00000000000
--- a/src/util/animation_registry.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-//@ts-nocheck
-import { fabric } from '../../HEADER';
-
-/**
- * Array holding all running animations
- * @memberof fabric
- * @type {AnimationContext[]}
- */
-class RunningAnimations extends Array {
- /**
- * cancel all running animations at the next requestAnimFrame
- * @returns {AnimationContext[]}
- */
- cancelAll(): any[] {
- const animations = this.splice(0);
- animations.forEach((animation) => animation.cancel());
- return animations;
- }
-
- /**
- * cancel all running animations attached to canvas at the next requestAnimFrame
- * @param {fabric.Canvas} canvas
- * @returns {AnimationContext[]}
- */
- cancelByCanvas(canvas: any) {
- if (!canvas) {
- return [];
- }
- const cancelled = this.filter(
- (animation) =>
- typeof animation.target === 'object' &&
- animation.target.canvas === canvas
- );
- cancelled.forEach((animation) => animation.cancel());
- return cancelled;
- }
-
- /**
- * cancel all running animations for target at the next requestAnimFrame
- * @param {*} target
- * @returns {AnimationContext[]}
- */
- cancelByTarget(target) {
- const cancelled = this.findAnimationsByTarget(target);
- cancelled.forEach((animation) => animation.cancel());
- return cancelled;
- }
-
- /**
- *
- * @param {CancelFunction} cancelFunc the function returned by animate
- * @returns {number}
- */
- findAnimationIndex(cancelFunc) {
- return this.indexOf(this.findAnimation(cancelFunc));
- }
-
- /**
- *
- * @param {CancelFunction} cancelFunc the function returned by animate
- * @returns {AnimationContext | undefined} animation's options object
- */
- findAnimation(cancelFunc) {
- return this.find((animation) => animation.cancel === cancelFunc);
- }
-
- /**
- *
- * @param {*} target the object that is assigned to the target property of the animation context
- * @returns {AnimationContext[]} array of animation options object associated with target
- */
- findAnimationsByTarget(target) {
- if (!target) {
- return [];
- }
- return this.filter((animation) => animation.target === target);
- }
-}
-
-export const runningAnimations = new RunningAnimations();
-
-fabric.runningAnimations = runningAnimations;
diff --git a/src/util/misc/misc.ts b/src/util/misc/misc.ts
index 387d79d3817..2eb0918460c 100644
--- a/src/util/misc/misc.ts
+++ b/src/util/misc/misc.ts
@@ -91,9 +91,13 @@ import {
} from '../dom_misc';
import { isTransparent } from './isTransparent';
import { mergeClipPaths } from './mergeClipPaths';
-import * as ease from '../anim_ease';
-import { animateColor } from '../animate_color';
-import { animate, requestAnimFrame, cancelAnimFrame } from '../animate';
+import {
+ animate,
+ animateColor,
+ ease,
+ requestAnimFrame,
+ cancelAnimFrame,
+} from '../animation';
import { createClass } from '../lang_class';
import { classRegistry } from '../class_registry';
diff --git a/test/ts/animation.ts b/test/ts/animation.ts
new file mode 100644
index 00000000000..be5fcd04400
--- /dev/null
+++ b/test/ts/animation.ts
@@ -0,0 +1,42 @@
+import { IsExact } from 'conditional-type-checks';
+import { animate } from '../../src/util/animation';
+
+function assertStrict(assertTrue: IsExact) {
+ return assertTrue;
+}
+
+animate({
+ endValue: 3,
+});
+animate({
+ byValue: 2,
+});
+// @ts-expect-error only one of (`byValue` | `endValue`) is allowed
+animate({
+ endValue: 3,
+ byValue: 2,
+});
+
+const context = animate({
+ startValue: 1,
+ endValue: 3,
+ onChange(a, b, c) {
+ assertStrict(true);
+ assertStrict(true);
+ assertStrict(true);
+ },
+});
+
+assertStrict(true);
+
+const arrayContext = animate({
+ startValue: [5],
+ byValue: [1],
+ onChange(a, b, c) {
+ assertStrict(true);
+ assertStrict(true);
+ assertStrict(true);
+ },
+});
+
+assertStrict(true);
diff --git a/test/unit/animation.js b/test/unit/animation.js
index 2e1b957c886..f980ef483c1 100644
--- a/test/unit/animation.js
+++ b/test/unit/animation.js
@@ -1,5 +1,8 @@
-(function() {
- QUnit.module('fabric.util.animate', {
+(function () {
+
+ const findAnimationsByTarget = target => fabric.runningAnimations.filter(({ target: t }) => target === t);
+
+ QUnit.module('animate', {
afterEach: function (assert) {
assert.equal(fabric.runningAnimations.length, 0, 'runningAnimations should be empty at the end of a test');
fabric.runningAnimations.cancelAll();
@@ -18,9 +21,11 @@
assert.ok(typeof val === 'string', 'expected type is String');
}
assert.ok(typeof fabric.util.animateColor === 'function', 'animateColor is a function');
- fabric.util.animateColor('red', 'blue', 16, {
- onComplete: function(val, changePerc, timePerc) {
- // animate color need some fixing
+ fabric.util.animateColor({
+ startValue: 'red',
+ endValue: 'blue',
+ duration: 16,
+ onComplete: function (val, changePerc, timePerc) {
assert.equal(val, 'rgba(0,0,255,1)', 'color is blue');
assert.equal(changePerc, 1, 'change percentage is 100%');
assert.equal(timePerc, 1, 'time percentage is 100%');
@@ -30,30 +35,111 @@
});
});
- // QUnit.test('fabric.util.animate', function(assert) {
- // var done = assert.async();
- // function testing(val) {
- // assert.notEqual(val, 'rgba(0,0,255,1)', 'color is not blue');
- // assert.ok(typeof val === 'String');
- // }
- // assert.ok(typeof fabric.util.animate === 'function', 'fabric.util.animate is a function');
- // fabric.util.animate('red', 'blue', 16, {
- // onComplete: function() {
- // // animate color need some fixing
- // // assert.equal(val, 'rgba(0,0,255,1)', 'color is blue')
- // done();
- // },
- // onChange: testing,
- // });
- // });
+ QUnit.test('animateColor change percentage is calculated from a changed value', function (assert) {
+ const done = assert.async();
+ let called = false;
+ fabric.util.animateColor({
+ startValue: 'red',
+ endValue: 'magenta',
+ duration: 96,
+ onChange: function (val, changePerc) {
+ called && assert.ok(changePerc !== 0, 'change percentage');
+ called = true;
+ },
+ onComplete: done,
+ });
+ });
+
+ QUnit.test('animateColor byValue', function (assert) {
+ var done = assert.async();
+ fabric.util.animateColor({
+ startValue: 'red',
+ byValue: 'blue',
+ duration: 16,
+ onComplete: function (val, changePerc, timePerc) {
+ assert.equal(val, 'rgba(255,0,255,1)', 'color is magenta');
+ assert.equal(changePerc, 1, 'change percentage is 100%');
+ assert.equal(timePerc, 1, 'time percentage is 100%');
+ done();
+ }
+ });
+ });
+
+ QUnit.test('animateColor byValue with ignored opacity', function (assert) {
+ var done = assert.async();
+ fabric.util.animateColor({
+ startValue: 'rgba(255,0,0,0.5)',
+ byValue: 'rgba(0,0,255,0.5)',
+ duration: 16,
+ onComplete: function (val, changePerc, timePerc) {
+ assert.equal(val, 'rgba(255,0,255,0.5)', 'color is magenta');
+ assert.equal(changePerc, 1, 'change percentage is 100%');
+ assert.equal(timePerc, 1, 'time percentage is 100%');
+ done();
+ }
+ });
+ });
+
+ QUnit.test('animateColor byValue with opacity', function (assert) {
+ var done = assert.async();
+ fabric.util.animateColor({
+ startValue: 'red',
+ byValue: [0, 0, 255, -0.5],
+ duration: 16,
+ onComplete: function (val, changePerc, timePerc) {
+ assert.equal(val, 'rgba(255,0,255,0.5)', 'color is magenta');
+ assert.equal(changePerc, 1, 'change percentage is 100%');
+ assert.equal(timePerc, 1, 'time percentage is 100%');
+ done();
+ }
+ });
+ });
+
+ QUnit.test('animateColor byValue with wrong opacity is ignored', function (assert) {
+ var done = assert.async();
+ fabric.util.animateColor({
+ startValue: 'red',
+ byValue: [0, 0, 255, 0.5],
+ duration: 16,
+ onChange: val => {
+ assert.equal(new fabric.Color(val).getAlpha(), 1, 'alpha diff should be ignored')
+ },
+ onComplete: function (val, changePerc, timePerc) {
+ assert.equal(val, 'rgba(255,0,255,1)', 'color is magenta');
+ assert.equal(changePerc, 1, 'change percentage is 100%');
+ assert.equal(timePerc, 1, 'time percentage is 100%');
+ done();
+ }
+ });
+ });
+
+ QUnit.test('byValue', function (assert) {
+ var done = assert.async();
+ fabric.util.animate({
+ startValue: 0,
+ byValue: 10,
+ endValue: 5,
+ duration: 16,
+ onComplete: function (val, changePerc, timePerc) {
+ assert.equal(val, 10, 'endValue is ignored');
+ assert.equal(changePerc, 1, 'change percentage is 100%');
+ assert.equal(timePerc, 1, 'time percentage is 100%');
+ done();
+ }
+ });
+ });
QUnit.test('animation context', function (assert) {
var done = assert.async();
var options = { foo: 'bar' };
- fabric.util.animate(options);
+ const context = fabric.util.animate(options);
+ assert.equal(context.state, 'pending', 'state');
+ assert.ok(typeof context.abort === 'function', 'context');
+ assert.equal(context.duration, 500, 'defaults');
assert.propEqual(options, { foo: 'bar' }, 'options were mutated');
- setTimeout(function() {
- assert.equal(fabric.runningAnimations.length, 0, 'animation should exist in registry');
+ setTimeout(function () {
+ assert.equal(context.state, 'completed', 'state');
+ assert.equal(fabric.runningAnimations.length, 0, 'animation should not exist in registry');
done();
}, 1000);
});
@@ -63,40 +149,31 @@
assert.ok(fabric.runningAnimations instanceof Array);
assert.ok(typeof fabric.runningAnimations.cancelAll === 'function');
assert.ok(typeof fabric.runningAnimations.cancelByTarget === 'function');
- assert.ok(typeof fabric.runningAnimations.findAnimationIndex === 'function');
- assert.ok(typeof fabric.runningAnimations.findAnimation === 'function');
- assert.ok(typeof fabric.runningAnimations.findAnimationsByTarget === 'function');
+ assert.ok(typeof fabric.runningAnimations.cancelByCanvas === 'function');
assert.equal(fabric.runningAnimations.length, 0, 'should have registered animation');
- var abort, target = { foo: 'bar' };
+ var context, target = { foo: 'bar' };
var options = {
target,
- onChange(currentValue, completionRate, durationRate) {
- var context = fabric.runningAnimations.findAnimation(abort);
- assert.equal(context.currentValue, currentValue, 'context.currentValue is wrong');
- assert.equal(context.completionRate, completionRate, 'context.completionRate is wrong');
- assert.equal(context.durationRate, durationRate, 'context.durationRate is wrong');
- assert.equal(fabric.runningAnimations.findAnimationIndex(abort), 0, 'animation should exist in registry');
+ onChange() {
+ assert.equal(context.state, 'running', 'state');
+ assert.equal(fabric.runningAnimations.indexOf(context), 0, 'animation should exist in registry');
},
onComplete() {
setTimeout(() => {
+ assert.equal(context.state, 'completed', 'state');
assert.equal(fabric.runningAnimations.length, 0, 'should have unregistered animation');
done();
}, 0);
}
};
- abort = fabric.util.animate(options);
- var context = fabric.runningAnimations.findAnimation(abort);
+ context = fabric.util.animate(options);
assert.equal(fabric.runningAnimations.length, 1, 'should have registered animation');
- assert.equal(fabric.runningAnimations.findAnimationIndex(abort), 0, 'animation should exist in registry');
- assert.equal(context.cancel, abort, 'animation should exist in registry');
- assert.equal(context.currentValue, 0, 'context.currentValue is wrong');
- assert.equal(context.completionRate, 0, 'context.completionRate is wrong');
- assert.equal(context.durationRate, 0, 'context.durationRate is wrong');
- var byTarget = fabric.runningAnimations.findAnimationsByTarget(target);
+ assert.equal(fabric.runningAnimations.indexOf(context), 0, 'animation should exist in registry');
+ var byTarget = findAnimationsByTarget(target);
assert.equal(byTarget.length, 1, 'should have found registered animation by target');
assert.deepEqual(byTarget[0], context, 'should have found registered animation by target');
delete byTarget[0].target;
- assert.equal(fabric.runningAnimations.findAnimationsByTarget(target), 0, 'should not have found registered animation by target');
+ assert.equal(findAnimationsByTarget(target), 0, 'should not have found registered animation by target');
});
QUnit.test('fabric.runningAnimations with abort', function (assert) {
@@ -115,25 +192,24 @@
done();
}, 0);
}
- assert.equal(fabric.runningAnimations.findAnimationIndex(abort), 0, 'animation should exist in registry');
+ assert.equal(fabric.runningAnimations.indexOf(context), 0, 'animation should exist in registry');
return _abort;
}
};
- var abort = fabric.util.animate(options);
+ var context = fabric.util.animate(options);
assert.equal(fabric.runningAnimations.length, 1, 'should have registered animation');
- assert.equal(fabric.runningAnimations.findAnimationIndex(abort), 0, 'animation should exist in registry');
- assert.equal(fabric.runningAnimations.findAnimation(abort).cancel, abort, 'animation should exist in registry');
+ assert.equal(fabric.runningAnimations.indexOf(context), 0, 'animation should exist in registry');
});
QUnit.test('fabric.runningAnimations with imperative abort', function (assert) {
var options = { foo: 'bar' };
- var abort = fabric.util.animate(options);
+ var context = fabric.util.animate(options);
+ assert.equal(context.state, 'pending', 'state');
assert.equal(fabric.runningAnimations.length, 1, 'should have registered animation');
- assert.equal(fabric.runningAnimations.findAnimationIndex(abort), 0, 'animation should exist in registry');
- assert.equal(fabric.runningAnimations.findAnimation(abort).cancel, abort, 'animation should exist in registry');
- var context = abort();
+ assert.equal(fabric.runningAnimations.indexOf(context), 0, 'animation should exist in registry');
+ context.abort();
+ assert.equal(context.state, 'aborted', 'state');
assert.equal(fabric.runningAnimations.length, 0, 'should have unregistered animation');
- assert.equal(context.foo, 'bar', 'should return animation context');
});
QUnit.test('fabric.runningAnimations cancelAll', function (assert) {
@@ -149,8 +225,6 @@
// make sure splice didn't destroy instance
assert.ok(fabric.runningAnimations instanceof Array);
assert.ok(typeof fabric.runningAnimations.cancelAll === 'function');
- assert.ok(typeof fabric.runningAnimations.findAnimationIndex === 'function');
- assert.ok(typeof fabric.runningAnimations.findAnimation === 'function');
});
QUnit.test('fabric.runningAnimations cancelByCanvas', function (assert) {
@@ -179,7 +253,7 @@
fabric.util.animate(options);
fabric.util.animate(options);
fabric.util.animate(options);
- fabric.util.animate(opt2);
+ const baz = fabric.util.animate(opt2);
assert.equal(fabric.runningAnimations.length, 4, 'should have registered animations');
var cancelledAnimations = fabric.runningAnimations.cancelByTarget();
assert.equal(cancelledAnimations.length, 0, 'should return empty array');
@@ -187,7 +261,7 @@
cancelledAnimations = fabric.runningAnimations.cancelByTarget('pip');
assert.equal(cancelledAnimations.length, 3, 'should return cancelled animations');
assert.equal(fabric.runningAnimations.length, 1, 'should have left 1 registered animation');
- assert.equal(fabric.runningAnimations[0].bar, opt2.bar, 'should have left 1 registered animation');
+ assert.strictEqual(fabric.runningAnimations[0], baz, 'should have left 1 registered animation');
setTimeout(() => {
done();
}, 1000);
@@ -248,7 +322,7 @@
object.animate(prop, 'blue');
assert.ok(true, 'animate without options does not crash');
assert.equal(fabric.runningAnimations.length, index + 1, 'should have 1 registered animation');
- assert.equal(fabric.runningAnimations.findAnimationsByTarget(object).length, index + 1, 'animation.target should be set');
+ assert.equal(findAnimationsByTarget(object).length, index + 1, 'animation.target should be set');
setTimeout(function () {
assert.equal(object[prop], new fabric.Color('blue').toRgba(), 'property [' + prop + '] has been animated');
@@ -283,7 +357,7 @@
object.animate({ left: 40});
assert.ok(true, 'animate without options does not crash');
assert.equal(fabric.runningAnimations.length, 1, 'should have 1 registered animation');
- assert.equal(fabric.runningAnimations.findAnimationsByTarget(object).length, 1, 'animation.target should be set');
+ assert.equal(findAnimationsByTarget(object).length, 1, 'animation.target should be set');
setTimeout(function() {
assert.equal(40, Math.round(object.left));
@@ -343,7 +417,9 @@
duration: 96,
onChange: function(currentValue) {
assert.equal(fabric.runningAnimations.length, 1, 'runningAnimations should not be empty');
- assert.deepEqual(fabric.runningAnimations[0]['currentValue'], currentValue)
+ assert.ok(Array.isArray(currentValue), 'should be array');
+ assert.ok(fabric.runningAnimations[0].value !== currentValue, 'should not share array');
+ assert.deepEqual(fabric.runningAnimations[0].value, currentValue);
assert.equal(currentValue.length, 3);
currentValue.forEach(function(v) {
assert.ok(v > 0, 'confirm values are not invalid numbers');
@@ -366,14 +442,14 @@
var done = assert.async();
var object = new fabric.Object({ left: 123, top: 124 });
- var context;
+ var context;
object.animate({ left: 223, top: 224 }, {
abort: function() {
context = this;
return true;
}
});
-
+
setTimeout(function() {
assert.equal(123, Math.round(object.get('left')));
assert.equal(124, Math.round(object.get('top')));
@@ -386,20 +462,22 @@
var done = assert.async();
var object = new fabric.Object({ left: 123, top: 124 });
- var context;
- var abort = object._animate('left', 223, {
+ let called = 0;
+ const context = object._animate('left', 223, {
abort: function () {
- context = this;
+ called++;
return false;
}
});
- assert.ok(typeof abort === 'function');
- abort();
+ assert.ok(typeof context.abort === 'function');
+ assert.equal(context.state, 'pending', 'state');
+ context.abort();
+ assert.equal(context.state, 'aborted', 'state');
setTimeout(function () {
- assert.equal(123, Math.round(object.get('left')));
- assert.equal(context, undefined, 'declarative abort should not be called after imperative abort was called');
+ assert.equal(Math.round(object.get('left')), 123);
+ assert.equal(called, 0, 'declarative abort should be called once before imperative abort cancels the run');
done();
}, 100);
});
@@ -407,18 +485,17 @@
QUnit.test('animate with delay', function (assert) {
var done = assert.async();
var object = new fabric.Object({ left: 123, top: 124 });
- var started = false;
var t = new Date();
- object._animate('left', 223, {
+ const context = object._animate('left', 223, {
onStart: function () {
- started = true;
+ assert.equal(context.state, 'running', 'state');
assert.gte(new Date() - t, 500, 'animation delay');
return false;
},
onComplete: done,
delay: 500
});
- assert.ok(started === false);
+ assert.equal(context.state, 'pending', 'state');
});
QUnit.test('animate easing easeInQuad', function(assert) {
diff --git a/test/unit/canvas.js b/test/unit/canvas.js
index ebf20abf42c..69529046451 100644
--- a/test/unit/canvas.js
+++ b/test/unit/canvas.js
@@ -2212,7 +2212,7 @@
}
assert.equal(canvas.item(0), rect);
- assert.ok(typeof canvas.fxRemove(rect, { onComplete: onComplete }) === 'function', 'should return animation abort function');
+ assert.ok(typeof canvas.fxRemove(rect, { onComplete: onComplete }).abort === 'function', 'should return animation abort function');
setTimeout(function() {
assert.equal(canvas.item(0), undefined);
diff --git a/test/unit/canvas_static.js b/test/unit/canvas_static.js
index 8afe2b2a463..02b7e23b18f 100644
--- a/test/unit/canvas_static.js
+++ b/test/unit/canvas_static.js
@@ -1717,7 +1717,7 @@
}
assert.ok(canvas.item(0) === rect);
- assert.ok(typeof canvas.fxRemove(rect, { onComplete: onComplete }) === 'function', 'should return animation abort function');
+ assert.ok(typeof canvas.fxRemove(rect, { onComplete: onComplete }).abort === 'function', 'should return animation abort function');
});
QUnit.test('setViewportTransform', function(assert) {
diff --git a/test/unit/color.js b/test/unit/color.js
index 145b8c35fb2..2e4a7745ab2 100644
--- a/test/unit/color.js
+++ b/test/unit/color.js
@@ -381,9 +381,8 @@
assert.deepEqual(fabric.Color.sourceFromHex('fff'), [255,255,255,1]);
});
- QUnit.test('fromSource', function(assert) {
- assert.ok(typeof fabric.Color.fromSource === 'function');
- var oColor = fabric.Color.fromSource([255,255,255,0.37]);
+ QUnit.test('from rgba', function(assert) {
+ var oColor = new fabric.Color([255,255,255,0.37]);
assert.ok(oColor);
assert.ok(oColor instanceof fabric.Color);
@@ -392,6 +391,26 @@
assert.equal(oColor.getAlpha(), 0.37);
});
+ QUnit.test('from rgb', function(assert) {
+ var oColor = new fabric.Color([255,255,255]);
+
+ assert.ok(oColor);
+ assert.ok(oColor instanceof fabric.Color);
+ assert.equal(oColor.toRgba(), 'rgba(255,255,255,1)');
+ assert.equal(oColor.toHex(), 'FFFFFF');
+ assert.equal(oColor.getAlpha(), 1);
+ });
+
+ QUnit.test('from Color instance', function(assert) {
+ var oColor = new fabric.Color(new fabric.Color([255,255,255]));
+
+ assert.ok(oColor);
+ assert.ok(oColor instanceof fabric.Color);
+ assert.equal(oColor.toRgba(), 'rgba(255,255,255,1)');
+ assert.equal(oColor.toHex(), 'FFFFFF');
+ assert.equal(oColor.getAlpha(), 1);
+ });
+
QUnit.test('overlayWith', function(assert) {
var oColor = new fabric.Color('FF0000');
assert.ok(typeof oColor.overlayWith === 'function');
diff --git a/test/unit/object.js b/test/unit/object.js
index 898cc0b97e6..85e68f5b276 100644
--- a/test/unit/object.js
+++ b/test/unit/object.js
@@ -552,13 +552,13 @@
var callbacks = { onComplete: onComplete, onChange: onChange };
assert.ok(typeof object.fxStraighten === 'function');
- assert.ok(typeof object.fxStraighten(callbacks) === 'function', 'should return animation abort function');
+ assert.ok(typeof object.fxStraighten(callbacks).abort === 'function', 'should return animation context');
assert.equal(fabric.util.toFixed(object.get('angle'), 0), 43);
setTimeout(function(){
assert.ok(onCompleteFired);
assert.ok(onChangeFired);
assert.equal(object.get('angle'), 0, 'angle should be set to 0 by the end of animation');
- assert.ok(typeof object.fxStraighten() === 'function', 'should work without callbacks');
+ assert.ok(typeof object.fxStraighten().abort === 'function', 'should work without callbacks');
done();
}, 1000);
});
@@ -1431,9 +1431,10 @@
var object = new fabric.Object({ fill: 'blue', width: 100, height: 100 });
assert.ok(typeof object.dispose === 'function');
object.animate('fill', 'red');
- assert.equal(fabric.runningAnimations.findAnimationsByTarget(object).length, 1, 'runningAnimations should include the animation');
+ const findAnimationsByTarget = target => fabric.runningAnimations.filter(({ target: t }) => target === t);
+ assert.equal(findAnimationsByTarget(object).length, 1, 'runningAnimations should include the animation');
object.dispose();
- assert.equal(fabric.runningAnimations.findAnimationsByTarget(object).length, 0, 'runningAnimations should be empty after dispose');
+ assert.equal(findAnimationsByTarget(object).length, 0, 'runningAnimations should be empty after dispose');
});
QUnit.test('prototype changes', function (assert) {
var object = new fabric.Object();