diff --git a/CHANGELOG.md b/CHANGELOG.md index c8be8308a19..081ac731f58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- refactor(Animation): BREAKING: Animation api reduction and semplification (byValue is removed, '+=' syntax is removed, callbacks fired 100%) [#8547](https://github.com/fabricjs/fabric.js/pull/8547) - feat(PolyControl): modify the shape of a poly with control points [#8556](https://github.com/fabricjs/fabric.js/pull/8556) - BREAKING: remove Object.stateful and Object.statefulCache [#8573](https://github.com/fabricjs/fabric.js/pull/8573) - fix(IText): refactor clearing context top logic of itext to align with brush pattern, using the canvas rendering cycle in order to guard from edge cases #8560 diff --git a/index.js b/index.js index a05af7cf7aa..293b081091b 100644 --- a/index.js +++ b/index.js @@ -150,13 +150,12 @@ import { } from './src/util/dom_misc'; import { isTransparent } from './src/util/misc/isTransparent'; import { mergeClipPaths } from './src/util/misc/mergeClipPaths'; +import { animate, animateColor } from './src/util/animation/animate'; +import * as ease from './src/util/animation/easing'; import { - animate, - animateColor, - ease, requestAnimFrame, cancelAnimFrame, -} from './src/util/animation'; +} from './src/util/animation/AnimationFrameProvider'; import { classRegistry } from './src/util/class_registry'; import { removeFromArray } from './src/util/internals/removeFromArray'; import { getRandomInt } from './src/util/internals/getRandomInt'; diff --git a/src/canvas/static_canvas.class.ts b/src/canvas/static_canvas.class.ts index b93f59a817e..76d422845d5 100644 --- a/src/canvas/static_canvas.class.ts +++ b/src/canvas/static_canvas.class.ts @@ -22,7 +22,10 @@ import { TToCanvasElementOptions, TValidToObjectMethod, } from '../typedefs'; -import { cancelAnimFrame, requestAnimFrame } from '../util/animation'; +import { + cancelAnimFrame, + requestAnimFrame, +} from '../util/animation/AnimationFrameProvider'; import { cleanUpJsdomNode, getElementOffset, diff --git a/src/parkinglot/straighten.ts b/src/parkinglot/straighten.ts new file mode 100644 index 00000000000..352fe68eb2c --- /dev/null +++ b/src/parkinglot/straighten.ts @@ -0,0 +1,58 @@ +// @ts-nocheck +import { noop } from '../constants'; +import { FabricObject } from '../shapes/Object/FabricObject'; +import { TDegree } from '../typedefs'; +import { animate } from '../util/animation/animate'; + +Object.assign(FabricObject.prototype, { + /** + * @private + * @return {Number} angle value + */ + _getAngleValueForStraighten(this: FabricObject) { + const angle = this.angle % 360; + if (angle > 0) { + return Math.round((angle - 1) / 90) * 90; + } + return Math.round(angle / 90) * 90; + }, + + /** + * Straightens an object (rotating it from current angle to one of 0, 90, 180, 270, etc. depending on which is closer) + */ + straighten(this: FabricObject) { + this.rotate(this._getAngleValueForStraighten()); + }, + + /** + * Same as {@link straighten} but with animation + * @param {Object} callbacks Object with callback functions + * @param {Function} [callbacks.onComplete] Invoked on completion + * @param {Function} [callbacks.onChange] Invoked on every step of animation + */ + fxStraighten( + this: FabricObject, + callbacks: { + onChange?(value: TDegree): any; + onComplete?(): any; + } = {} + ) { + const onComplete = callbacks.onComplete || noop, + onChange = callbacks.onChange || noop; + + return animate({ + target: this, + startValue: this.angle, + endValue: this._getAngleValueForStraighten(), + duration: this.FX_DURATION, + onChange: (value: TDegree) => { + this.rotate(value); + onChange(value); + }, + onComplete: () => { + this.setCoords(); + onComplete(); + }, + }); + }, +}); diff --git a/src/shapes/Object/AnimatableObject.ts b/src/shapes/Object/AnimatableObject.ts index 6590d666e4d..6daacfd9094 100644 --- a/src/shapes/Object/AnimatableObject.ts +++ b/src/shapes/Object/AnimatableObject.ts @@ -1,39 +1,28 @@ import { TColorArg } from '../../color/color.class'; -import { noop } from '../../constants'; import { ObjectEvents } from '../../EventTypeDefs'; -import { TDegree } from '../../typedefs'; -import { - animate, - animateColor, - AnimationOptions, +import { animate, animateColor } from '../../util/animation/animate'; +import type { + ValueAnimationOptions, ColorAnimationOptions, -} from '../../util/animation'; +} from '../../util/animation/types'; +import { ArrayAnimation } from '../../util/animation/ArrayAnimation'; import type { ColorAnimation } from '../../util/animation/ColorAnimation'; import type { ValueAnimation } from '../../util/animation/ValueAnimation'; import { StackedObject } from './StackedObject'; type TAnimationOptions = T extends number - ? AnimationOptions + ? ValueAnimationOptions : ColorAnimationOptions; export abstract class AnimatableObject< EventSpec extends ObjectEvents = ObjectEvents > extends StackedObject { - /** - * Animation duration (in ms) for fx* methods - * @type Number - * @default - */ - FX_DURATION: number; - /** * List of properties to consider for animating colors. * @type String[] */ colorProperties: string[]; - abstract rotate(deg: TDegree): void; - /** * Animates object's properties * @param {String|Object} property Property to animate (if string) or properties to animate (if object) @@ -45,46 +34,13 @@ export abstract class AnimatableObject< * * object.animate({ left: ..., top: ... }); * 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?: Partial> - ): (ColorAnimation | ValueAnimation)[]; animate( animatable: Record, options?: Partial> - ): (ColorAnimation | ValueAnimation)[]; - animate>( - arg0: S, - 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 Partial< - TAnimationOptions - >; - return keys.map((key, index) => - this._animate( - key, - animatable[key], - index === keys.length - 1 - ? options - : { ...options, onChange: undefined, onComplete: undefined } - ) + ): (ColorAnimation | ValueAnimation | ArrayAnimation)[] { + return Object.entries(animatable).map(([key, endValue]) => + this._animate(key, endValue, options) ); } @@ -96,41 +52,26 @@ export abstract class AnimatableObject< */ _animate( key: string, - to: T, + endValue: 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); - - 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 { easing, duration, abort, startValue, onChange, onComplete } = + options; const animationOptions = { target: this, + // path.reduce... is the current value in case start value isn't provided startValue: - options.startValue ?? - // backward compat - (options as any).from ?? - currentValue, - endValue: to, - // `byValue` takes precedence over `endValue` - byValue: - options.byValue ?? - // backward compat - (options as any).by, - easing: options.easing, - duration: options.duration, - abort: options.abort?.bind(this), + startValue ?? path.reduce((deep: any, key) => deep[key], this), + endValue, + easing, + duration, + abort: abort?.bind(this), onChange: ( value: string | number, - valueRatio: number, - durationRatio: number + valueProgress: number, + durationProgress: number ) => { path.reduce((deep: Record, key, index) => { if (index === path.length - 1) { @@ -138,76 +79,26 @@ export abstract class AnimatableObject< } return deep[key]; }, this); - options.onChange && + onChange && // @ts-expect-error generic callback arg0 is wrong - options.onChange(value, valueRatio, durationRatio); + onChange(value, valueProgress, durationProgress); }, onComplete: ( value: string | number, - valueRatio: number, - durationRatio: number + valueProgress: number, + durationProgress: number ) => { this.setCoords(); - options.onComplete && + onComplete && // @ts-expect-error generic callback arg0 is wrong - options.onComplete(value, valueRatio, durationRatio); + onComplete(value, valueProgress, durationProgress); }, } as TAnimationOptions; if (propIsColor) { return animateColor(animationOptions as ColorAnimationOptions); } else { - return animate(animationOptions as AnimationOptions); + return animate(animationOptions as ValueAnimationOptions); } } - - /** - * @private - * @return {Number} angle value - */ - protected _getAngleValueForStraighten() { - const angle = this.angle % 360; - if (angle > 0) { - return Math.round((angle - 1) / 90) * 90; - } - return Math.round(angle / 90) * 90; - } - - /** - * Straightens an object (rotating it from current angle to one of 0, 90, 180, 270, etc. depending on which is closer) - */ - straighten() { - this.rotate(this._getAngleValueForStraighten()); - } - - /** - * Same as {@link straighten} but with animation - * @param {Object} callbacks Object with callback functions - * @param {Function} [callbacks.onComplete] Invoked on completion - * @param {Function} [callbacks.onChange] Invoked on every step of animation - */ - fxStraighten( - callbacks: { - onChange?(value: TDegree): any; - onComplete?(): any; - } = {} - ) { - const onComplete = callbacks.onComplete || noop, - onChange = callbacks.onChange || noop; - - return animate({ - target: this, - startValue: this.angle, - endValue: this._getAngleValueForStraighten(), - duration: this.FX_DURATION, - onChange: (value: TDegree) => { - this.rotate(value); - onChange(value); - }, - onComplete: () => { - this.setCoords(); - onComplete(); - }, - }); - } } diff --git a/src/shapes/Object/Object.ts b/src/shapes/Object/Object.ts index f3fe37b6156..d40446196bc 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'; +import { runningAnimations } from '../../util/animation/AnimationRegistry'; import { clone } from '../../util/lang_object'; import { capitalize } from '../../util/lang_string'; import { capValue } from '../../util/misc/capValue'; @@ -2082,7 +2082,6 @@ export const fabricObjectDefaultValues = { clipPath: undefined, inverted: false, absolutePositioned: false, - FX_DURATION: 500, }; Object.assign(FabricObject.prototype, fabricObjectDefaultValues); diff --git a/src/util/animation/AnimationBase.ts b/src/util/animation/AnimationBase.ts index ec358a2ab99..c83ea2615ef 100644 --- a/src/util/animation/AnimationBase.ts +++ b/src/util/animation/AnimationBase.ts @@ -5,9 +5,7 @@ import { defaultEasing } from './easing'; import { AnimationState, TAbortCallback, - TAnimationBaseOptions, - TAnimationCallbacks, - TAnimationValues, + TBaseAnimationOptions, TEasingFunction, TOnAnimationChangeCallback, } from './types'; @@ -18,10 +16,11 @@ 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 byValue: T; protected readonly easing: TEasingFunction; private readonly _onStart: VoidFunction; @@ -40,11 +39,11 @@ export abstract class AnimationBase< * Time %, or the ratio of `timeElapsed / duration` * @see tick */ - durationRatio = 0; + durationProgress = 0; /** * Value %, or the ratio of `(currentValue - startValue) / (endValue - startValue)` */ - valueRatio = 0; + valueProgress = 0; /** * Current value */ @@ -54,12 +53,6 @@ export abstract class AnimationBase< */ 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, @@ -71,8 +64,7 @@ export abstract class AnimationBase< onComplete = noop, abort = defaultAbort, target, - }: Partial & TAnimationCallbacks> & - Required, 'endValue'>>) { + }: TBaseAnimationOptions) { this.tick = this.tick.bind(this); this.duration = duration; @@ -87,7 +79,7 @@ export abstract class AnimationBase< this.startValue = startValue; this.byValue = byValue; this.value = this.startValue; - this.endValue = this.calculate(this.duration).value; + this.endValue = Object.freeze(this.calculate(this.duration).value); } get state() { @@ -101,7 +93,7 @@ export abstract class AnimationBase< */ protected abstract calculate(timeElapsed: number): { value: T; - changeRatio: number; + valueProgress: number; }; start() { @@ -127,29 +119,30 @@ export abstract class AnimationBase< 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; + this.durationProgress = boundDurationMs / this.duration; + const { value, valueProgress } = this.calculate(boundDurationMs); + this.value = Object.freeze(value); + this.valueProgress = valueProgress; if (this._state === 'aborted') { return; - } else if (this._abort(value, this.valueRatio, this.durationRatio)) { + } else if ( + this._abort(this.value, this.valueProgress, this.durationProgress) + ) { 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.durationProgress = this.valueProgress = 1; + this._onChange(this.endValue, this.valueProgress, this.durationProgress); this._state = 'completed'; - this._onComplete(endValue, this.valueRatio, this.durationRatio); + this._onComplete( + this.endValue, + this.valueProgress, + this.durationProgress + ); this.unregister(); } else { - this._onChange(value, this.valueRatio, this.durationRatio); + this._onChange(this.value, this.valueProgress, this.durationProgress); requestAnimFrame(this.tick); } } diff --git a/src/util/animation/ArrayAnimation.ts b/src/util/animation/ArrayAnimation.ts index 8d519715bb5..57470f8a175 100644 --- a/src/util/animation/ArrayAnimation.ts +++ b/src/util/animation/ArrayAnimation.ts @@ -5,13 +5,12 @@ export class ArrayAnimation extends AnimationBase { constructor({ startValue = [0], endValue = [100], - byValue = endValue.map((value, i) => value - startValue[i]), ...options }: ArrayAnimationOptions) { super({ ...options, startValue, - byValue, + byValue: endValue.map((value, i) => value - startValue[i]), }); } protected calculate(timeElapsed: number) { @@ -20,7 +19,9 @@ export class ArrayAnimation extends AnimationBase { ); return { value: values, - changeRatio: Math.abs((values[0] - this.startValue[0]) / this.byValue[0]), + valueProgress: Math.abs( + (values[0] - this.startValue[0]) / this.byValue[0] + ), }; } } diff --git a/src/util/animation/ColorAnimation.ts b/src/util/animation/ColorAnimation.ts index ea4c67ffe4b..2b7f3c0a0e7 100644 --- a/src/util/animation/ColorAnimation.ts +++ b/src/util/animation/ColorAnimation.ts @@ -1,26 +1,36 @@ import { Color } from '../../color/color.class'; import { TRGBAColorSource } from '../../color/color.class'; +import { halfPI } from '../../constants'; import { capValue } from '../misc/capValue'; import { AnimationBase } from './AnimationBase'; -import { ColorAnimationOptions, TOnAnimationChangeCallback } from './types'; +import type { + ColorAnimationOptions, + TEasingFunction, + TOnAnimationChangeCallback, +} from './types'; + +const defaultColorEasing: TEasingFunction = ( + timeElapsed, + startValue, + byValue, + duration +) => { + const durationProgress = 1 - Math.cos((timeElapsed / duration) * halfPI); + return startValue + byValue * durationProgress; +}; const wrapColorCallback = ( callback?: TOnAnimationChangeCallback ) => callback && - ((rgba: TRGBAColorSource, valueRatio: number, durationRatio: number) => - callback(new Color(rgba).toRgba(), valueRatio, durationRatio)); + ((rgba: TRGBAColorSource, valueProgress: number, durationProgress: number) => + callback(new Color(rgba).toRgba(), valueProgress, durationProgress)); 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; - }, + easing = defaultColorEasing, onChange, onComplete, abort, @@ -31,13 +41,9 @@ export class ColorAnimation extends AnimationBase { 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), + byValue: endColor.map( + (value, i) => value - startColor[i] + ) as TRGBAColorSource, easing, onChange: wrapColorCallback(onChange), onComplete: wrapColorCallback(onComplete), @@ -48,12 +54,15 @@ export class ColorAnimation extends AnimationBase { 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); + const value = [ + ...[r, g, b].map(Math.round), + capValue(0, a, 1), + ] as TRGBAColorSource; return { - value: [...rgb, capValue(0, a, 1)] as TRGBAColorSource, - changeRatio: + value, + valueProgress: // to correctly calculate the change ratio we must find a changed value - rgb + value .map((p, i) => this.byValue[i] !== 0 ? Math.abs((p - this.startValue[i]) / this.byValue[i]) diff --git a/src/util/animation/ValueAnimation.ts b/src/util/animation/ValueAnimation.ts index e0d329b49c5..45fd25de64c 100644 --- a/src/util/animation/ValueAnimation.ts +++ b/src/util/animation/ValueAnimation.ts @@ -1,17 +1,16 @@ import { AnimationBase } from './AnimationBase'; -import { AnimationOptions } from './types'; +import { ValueAnimationOptions } from './types'; export class ValueAnimation extends AnimationBase { constructor({ startValue = 0, endValue = 100, - byValue = endValue - startValue, - ...options - }: AnimationOptions) { + ...otherOptions + }: ValueAnimationOptions) { super({ - ...options, + ...otherOptions, startValue, - byValue, + byValue: endValue - startValue, }); } @@ -24,7 +23,7 @@ export class ValueAnimation extends AnimationBase { ); return { value, - changeRatio: Math.abs((value - this.startValue) / this.byValue), + valueProgress: Math.abs((value - this.startValue) / this.byValue), }; } } diff --git a/src/util/animation/animate.ts b/src/util/animation/animate.ts index cfcb17fd23e..27be4466537 100644 --- a/src/util/animation/animate.ts +++ b/src/util/animation/animate.ts @@ -2,19 +2,15 @@ import { ValueAnimation } from './ValueAnimation'; import { ArrayAnimation } from './ArrayAnimation'; import { ColorAnimation } from './ColorAnimation'; import { - AnimationOptions, + ValueAnimationOptions, ArrayAnimationOptions, ColorAnimationOptions, } from './types'; const isArrayAnimation = ( - options: ArrayAnimationOptions | AnimationOptions + options: ArrayAnimationOptions | ValueAnimationOptions ): options is ArrayAnimationOptions => { - return ( - Array.isArray(options.startValue) || - Array.isArray(options.endValue) || - Array.isArray(options.byValue) - ); + return Array.isArray(options.startValue) || Array.isArray(options.endValue); }; /** @@ -43,12 +39,12 @@ const isArrayAnimation = ( * }); * */ -export const animate = < - T extends AnimationOptions | ArrayAnimationOptions, +export function animate(options: ArrayAnimationOptions): ArrayAnimation; +export function animate(options: ValueAnimationOptions): ValueAnimation; +export function animate< + T extends ValueAnimationOptions | ArrayAnimationOptions, R extends T extends ArrayAnimationOptions ? ArrayAnimation : ValueAnimation ->( - options: T -): R => { +>(options: T): R { const animation = ( isArrayAnimation(options) ? new ArrayAnimation(options) @@ -56,10 +52,10 @@ export const animate = < ) as R; animation.start(); return animation; -}; +} -export const animateColor = (options: ColorAnimationOptions) => { +export function animateColor(options: ColorAnimationOptions) { const animation = new ColorAnimation(options); animation.start(); return animation; -}; +} diff --git a/src/util/animation/index.ts b/src/util/animation/index.ts deleted file mode 100644 index f1fea7f06de..00000000000 --- a/src/util/animation/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 index b5c3af11004..b3e88245d59 100644 --- a/src/util/animation/types.ts +++ b/src/util/animation/types.ts @@ -5,13 +5,14 @@ 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. + * @param {number} valueProgress ∈ [0, 1], the current animation progress reflected on value, normalized. + * 0 is the starting value and 1 is the ending value. + * @param {number} durationProgress ∈ [0, 1], the current animation duration normalized to 1. */ export type TOnAnimationChangeCallback = ( value: T, - valueRatio: number, - durationRatio: number + valueProgress: number, + durationProgress: number ) => R; /** @@ -50,19 +51,19 @@ export type TAnimationBaseOptions = { * Duration of the animation in ms * @default 500 */ - duration?: number; + duration: number; /** * Delay to start the animation in ms * @default 0 */ - delay?: number; + delay: number; /** * Easing function * @default {defaultEasing} */ - easing?: TEasingFunction; + easing: TEasingFunction; /** * The object this animation is being performed on @@ -93,41 +94,31 @@ export type TAnimationCallbacks = { abort: TAbortCallback; }; -export type TAnimationValues = - | { +export type TBaseAnimationOptions = Partial< + TAnimationBaseOptions & TAnimationCallbacks +> & { + startValue: T; + byValue: T; +}; + +export type TAnimationOptions = Partial< + TAnimationBaseOptions & + TAnimationCallbacks & { /** * 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 + /** + * Ending value(s) + * @default 100 + */ + endValue: T; + } >; -export type AnimationOptions = TAnimationOptions; +export type ValueAnimationOptions = TAnimationOptions; export type ArrayAnimationOptions = TAnimationOptions; diff --git a/test/ts/animation.ts b/test/ts/animation.ts index cbaf7df2c71..cfb40f19b71 100644 --- a/test/ts/animation.ts +++ b/test/ts/animation.ts @@ -1,5 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ import { IsExact } from 'conditional-type-checks'; -import { animate } from '../../src/util/animation'; +import { animate } from '../../src/util/animation/animate'; function assertStrict(assertTrue: IsExact) { return assertTrue; @@ -8,6 +9,19 @@ function assertStrict(assertTrue: IsExact) { animate({ endValue: 3, }); +animate({ + // @ts-expect-error `byValue` is not part of options + byValue: 2, +}); +// @ts-expect-error `byValue` is not part of options +animate({ + byValue: 2, + endValue: 3, +}); +animate({ + // @ts-expect-error `foo` is not part of options + foo: 'bar', +}) const context = animate({ startValue: 1, @@ -32,3 +46,27 @@ const arrayContext = animate({ }); assertStrict(true); + +const mixedContextError = animate({ + startValue: [5], + // @ts-expect-error mixed context + endValue: 1, + onChange(a, b, c) { + assertStrict(true); + assertStrict(true); + assertStrict(true); + }, +}); + +const mixedContextError2 = animate({ + // @ts-expect-error mixed context + startValue: 5, + endValue: [1], + onChange(a, b, c) { + assertStrict(true); + assertStrict(true); + assertStrict(true); + }, +}); + + diff --git a/test/unit/animation.js b/test/unit/animation.js index f980ef483c1..e2f1a534f5f 100644 --- a/test/unit/animation.js +++ b/test/unit/animation.js @@ -40,24 +40,14 @@ let called = false; fabric.util.animateColor({ startValue: 'red', - endValue: 'magenta', + endValue: 'blue', 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(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%'); done(); @@ -65,14 +55,14 @@ }); }); - QUnit.test('animateColor byValue with ignored opacity', function (assert) { + QUnit.test('animateColor with opacity', function (assert) { var done = assert.async(); fabric.util.animateColor({ - startValue: 'rgba(255,0,0,0.5)', - byValue: 'rgba(0,0,255,0.5)', + startValue: 'rgba(255, 0, 0, 0.9)', + endValue: 'rgba(0, 0, 255, 0.7)', duration: 16, onComplete: function (val, changePerc, timePerc) { - assert.equal(val, 'rgba(255,0,255,0.5)', 'color is magenta'); + assert.equal(val, 'rgba(0,0,255,0.7)', 'color is animated on all 4 values'); assert.equal(changePerc, 1, 'change percentage is 100%'); assert.equal(timePerc, 1, 'time percentage is 100%'); done(); @@ -80,14 +70,17 @@ }); }); - QUnit.test('animateColor byValue with opacity', function (assert) { + QUnit.test('animateColor, opacity out of bounds value are ignored', function (assert) { var done = assert.async(); fabric.util.animateColor({ startValue: 'red', - byValue: [0, 0, 255, -0.5], + endValue: [255, 255, 255, 3], 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,0.5)', 'color is magenta'); + assert.equal(val, 'rgba(255,255,255,1)', 'color is normalized to max values'); assert.equal(changePerc, 1, 'change percentage is 100%'); assert.equal(timePerc, 1, 'time percentage is 100%'); done(); @@ -95,33 +88,36 @@ }); }); - QUnit.test('animateColor byValue with wrong opacity is ignored', function (assert) { + QUnit.test('animateColor opacity only', function (assert) { var done = assert.async(); + let called = false; 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') + startValue: 'rgba(255, 0, 0, 0.9)', + endValue: 'rgba(255, 0, 0, 0.7)', + duration: 96, + onChange: function (val, changePerc) { + const alpha = new fabric.Color(val).getAlpha(); + assert.equal(changePerc, (0.9 - alpha) / (0.9 - 0.7), 'valueProgress should match'); + called = true; }, onComplete: function (val, changePerc, timePerc) { - assert.equal(val, 'rgba(255,0,255,1)', 'color is magenta'); + assert.equal(val, 'rgba(255,0,0,0.7)', 'color is animated on all 4 values'); assert.equal(changePerc, 1, 'change percentage is 100%'); assert.equal(timePerc, 1, 'time percentage is 100%'); + assert.ok(called); done(); } }); }); - QUnit.test('byValue', function (assert) { + QUnit.test('endValue', function (assert) { var done = assert.async(); fabric.util.animate({ - startValue: 0, - byValue: 10, + startValue: 2, endValue: 5, duration: 16, onComplete: function (val, changePerc, timePerc) { - assert.equal(val, 10, 'endValue is ignored'); + assert.equal(val, 5, 'endValue is respected'); assert.equal(changePerc, 1, 'change percentage is 100%'); assert.equal(timePerc, 1, 'time percentage is 100%'); done(); @@ -273,7 +269,7 @@ assert.ok(typeof object.animate === 'function'); - object.animate('left', 40); + 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[0].target, object, 'animation.target should be set'); @@ -290,7 +286,7 @@ var done = assert.async(); var object = new fabric.Object({ left: 20, top: 30, width: 40, height: 50, angle: 43 }); - object.animate('left', '+=40'); + object.animate({ left: object.left + 40 }); assert.ok(true, 'animate without options does not crash'); setTimeout(function() { @@ -304,7 +300,7 @@ var done = assert.async(); var object = new fabric.Object({ left: 20, top: 30, width: 40, height: 50, angle: 43, shadow: { offsetX: 20 } }); - object.animate('shadow.offsetX', 100); + object.animate({ 'shadow.offsetX': 100 }); assert.ok(true, 'animate without options does not crash'); setTimeout(function() { @@ -319,7 +315,7 @@ properties.forEach(function (prop, index) { object.set(prop, 'red'); - object.animate(prop, 'blue'); + 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(findAnimationsByTarget(object).length, index + 1, 'animation.target should be set'); @@ -338,7 +334,7 @@ var done = assert.async(); var object = new fabric.Object({ left: 20, top: 30, width: 40, height: 50, angle: 43 }); - object.animate('left', '-=40'); + object.animate({ left: object.left - 40 }); assert.ok(true, 'animate without options does not crash'); setTimeout(function() { @@ -348,23 +344,6 @@ }, 1000); }); - QUnit.test('animate with object', function(assert) { - var done = assert.async(); - var object = new fabric.Object({ left: 20, top: 30, width: 40, height: 50, angle: 43 }); - - assert.ok(typeof object.animate === 'function'); - - 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(findAnimationsByTarget(object).length, 1, 'animation.target should be set'); - - setTimeout(function() { - assert.equal(40, Math.round(object.left)); - done(); - }, 1000); - }); - QUnit.test('animate multiple properties', function(assert) { var done = assert.async(); var object = new fabric.Object({ left: 123, top: 124 }); @@ -399,7 +378,7 @@ assert.equal(Math.round(object.get('top')), 1); assert.ok(changedInvocations > 0); - assert.equal(completeInvocations, 1); + assert.equal(completeInvocations, 2, 'the callbacks get call for each animation'); done(); @@ -413,23 +392,24 @@ fabric.util.animate({ startValue: [1, 2, 3], endValue: [2, 4, 6], - byValue: [1, 2, 3], duration: 96, - onChange: function(currentValue) { + onChange: function(currentValue, valueProgress) { assert.equal(fabric.runningAnimations.length, 1, 'runningAnimations should not be empty'); assert.ok(Array.isArray(currentValue), 'should be array'); - assert.ok(fabric.runningAnimations[0].value !== currentValue, 'should not share array'); + assert.ok(Object.isFrozen(fabric.runningAnimations[0].value), 'should be frozen'); 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'); }) + assert.equal(valueProgress, currentValue[0] - 1, 'should match'); // Make sure mutations are not kept assert.ok(currentValue[0] <= 2, 'mutating callback values must not persist'); currentValue[0] = 200; run = true; }, - onComplete: function(endValue) { + onComplete: function (endValue) { + assert.ok(Object.isFrozen(endValue), 'should be frozen'); assert.equal(endValue.length, 3); assert.deepEqual(endValue, [2, 4, 6]); assert.equal(run, true, 'something run'); @@ -442,14 +422,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'))); diff --git a/test/unit/canvas_dispose.js b/test/unit/canvas_dispose.js index f53575eba74..7a3eee8ee08 100644 --- a/test/unit/canvas_dispose.js +++ b/test/unit/canvas_dispose.js @@ -208,7 +208,7 @@ function testCanvasDisposing() { assert.ok(typeof canvas.dispose === 'function'); assert.ok(typeof canvas.destroy === 'function'); canvas.add(makeRect(), makeRect(), makeRect()); - canvas.item(0).animate('scaleX', 10); + canvas.item(0).animate({ scaleX: 10 }); assert.equal(fabric.runningAnimations.length, 1, 'should have a running animation'); await canvas.dispose(); assert.equal(fabric.runningAnimations.length, 0, 'dispose should clear running animations'); diff --git a/test/unit/object.js b/test/unit/object.js index 3acff348df8..f079ee380b9 100644 --- a/test/unit/object.js +++ b/test/unit/object.js @@ -511,7 +511,7 @@ assert.equal(canvas.contextContainer.getLineDash().length, 6, 'bailed immediately as array empty'); }); - QUnit.test('straighten', function(assert) { + QUnit.skip('straighten', function(assert) { var object = new fabric.Object({ left: 100, top: 124, width: 210, height: 66 }); assert.ok(typeof object.straighten === 'function'); @@ -540,7 +540,7 @@ assert.equal(object.get('angle'), 270); }); - QUnit.test('fxStraighten', function(assert) { + QUnit.skip('fxStraighten', function(assert) { var done = assert.async(); var object = new fabric.Object({ left: 20, top: 30, width: 40, height: 50, angle: 43 }); @@ -1417,7 +1417,7 @@ QUnit.test('dispose', function (assert) { var object = new fabric.Object({ fill: 'blue', width: 100, height: 100 }); assert.ok(typeof object.dispose === 'function'); - object.animate('fill', 'red'); + object.animate({ fill: 'red' }); const findAnimationsByTarget = target => fabric.runningAnimations.filter(({ target: t }) => target === t); assert.equal(findAnimationsByTarget(object).length, 1, 'runningAnimations should include the animation'); object.dispose();