From 4051296c880f539135fdf5c1bfa8329038552919 Mon Sep 17 00:00:00 2001 From: Lazauya Date: Fri, 30 Dec 2022 11:30:00 -0600 Subject: [PATCH] refactor(): refactor animation in classes (#8297) Co-authored-by: kristpregracke Co-authored-by: ShaMan123 Co-authored-by: Shachar <34343793+ShaMan123@users.noreply.github.com> Co-authored-by: Andrea Bogazzi --- CHANGELOG.md | 4 + src/canvas/static_canvas.class.ts | 2 +- src/color/color.class.ts | 120 +++++----- src/color/util.ts | 5 +- src/shapes/Object/AnimatableObject.ts | 120 ++++++---- src/shapes/Object/Object.ts | 2 +- src/util/animate.ts | 176 -------------- src/util/animate_color.ts | 85 ------- src/util/animation/AnimationBase.ts | 169 ++++++++++++++ src/util/animation/AnimationFrameProvider.ts | 23 ++ src/util/animation/AnimationRegistry.ts | 61 +++++ src/util/animation/ArrayAnimation.ts | 26 +++ src/util/animation/ColorAnimation.ts | 65 ++++++ src/util/animation/ValueAnimation.ts | 30 +++ src/util/animation/animate.ts | 65 ++++++ .../{anim_ease.ts => animation/easing.ts} | 40 ++-- src/util/animation/index.ts | 5 + src/util/animation/types.ts | 138 +++++++++++ src/util/animation_registry.ts | 82 ------- src/util/misc/misc.ts | 10 +- test/ts/animation.ts | 42 ++++ test/unit/animation.js | 221 ++++++++++++------ test/unit/canvas.js | 2 +- test/unit/canvas_static.js | 2 +- test/unit/color.js | 25 +- test/unit/object.js | 9 +- 26 files changed, 966 insertions(+), 563 deletions(-) delete mode 100644 src/util/animate.ts delete mode 100644 src/util/animate_color.ts create mode 100644 src/util/animation/AnimationBase.ts create mode 100644 src/util/animation/AnimationFrameProvider.ts create mode 100644 src/util/animation/AnimationRegistry.ts create mode 100644 src/util/animation/ArrayAnimation.ts create mode 100644 src/util/animation/ColorAnimation.ts create mode 100644 src/util/animation/ValueAnimation.ts create mode 100644 src/util/animation/animate.ts rename src/util/{anim_ease.ts => animation/easing.ts} (92%) create mode 100644 src/util/animation/index.ts create mode 100644 src/util/animation/types.ts delete mode 100644 src/util/animation_registry.ts create mode 100644 test/ts/animation.ts 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();