fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
487 lines (478 loc) • 16.4 kB
text/typescript
import { runningAnimations } from './AnimationRegistry';
import { animateColor, animate } from './animate';
import * as ease from './easing';
import { Color } from '../../color/Color';
import { FabricObject } from '../../shapes/Object/FabricObject';
import { ValueAnimation } from './ValueAnimation';
import { Shadow } from '../../Shadow';
jest.useFakeTimers();
const findAnimationsByTarget = (target: any) =>
runningAnimations.filter(({ target: t }) => target === t);
describe('animate', () => {
afterEach(() => {
// 'runningAnimations should be empty at the end of a test'
expect(runningAnimations.length).toBe(0);
runningAnimations.cancelAll();
jest.runAllTimers();
});
it('animateColor', async () => {
let expectRun = 0;
animateColor({
startValue: 'red',
endValue: 'blue',
duration: 16,
onComplete: function (val, changePerc, timePerc) {
// 'color is blue'
expect(val).toBe('rgba(0,0,255,1)');
// 'change percentage is 100%'
expect(changePerc).toBe(1);
// 'time percentage is 100%'
expect(timePerc).toBe(1);
expectRun += 1;
},
onChange: (val, complete) => {
if (complete !== 1) {
// 'color is not blue'
expectRun += 1;
expect(val).not.toBe('rgba(0,0,255,1)');
} else {
// 'color is blue'
expectRun += 1;
expect(val).toBe('rgba(0,0,255,1)');
}
// 'expected type is String'
expect(typeof val === 'string').toBe(true);
},
});
jest.advanceTimersByTime(32);
expect(expectRun).toEqual(3);
});
it('animateColor change percentage is calculated from a changed value', async () => {
const duration = 96;
const changePercSnap: number[] = [];
animateColor({
startValue: 'red',
endValue: 'blue',
duration,
onChange: function (_val, changePerc) {
changePercSnap.push(changePerc);
},
onComplete: function (val, changePerc, timePerc) {
// 'color is blue'
expect(val).toBe('rgba(0,0,255,1)');
// 'change percentage is 100%'
expect(changePerc).toBe(1);
// 'time percentage is 100%'
expect(timePerc).toBe(1);
},
});
jest.advanceTimersByTime(duration + 16);
expect(changePercSnap).toMatchSnapshot();
});
it('animateColor with opacity', async () => {
const duration = 16;
animateColor({
startValue: 'rgba(255, 0, 0, 0.9)',
endValue: 'rgba(0, 0, 255, 0.7)',
duration: 16,
onComplete: function (val, _changePerc, _timePerc) {
// 'color is animated on all 4 values'
expect(val).toEqual('rgba(0,0,255,0.7)');
},
});
jest.advanceTimersByTime(duration + 16);
});
it('animateColor, opacity out of bounds value are ignored', async () => {
const duration = 16;
animateColor({
startValue: 'red',
endValue: [255, 255, 255, 3],
duration,
onChange: (val) => {
// 'alpha diff should be ignored'
expect(new Color(val).getAlpha()).toEqual(1);
},
onComplete: function (val) {
// 'color is normalized to max values';
expect(val).toEqual('rgba(255,255,255,1)');
},
});
jest.advanceTimersByTime(duration + 16);
});
it('animateColor opacity only', async () => {
let called = false;
const duration = 96;
animateColor({
startValue: 'rgba(255, 0, 0, 0.9)',
endValue: 'rgba(255, 0, 0, 0.7)',
duration: 96,
onChange: function (val, changePerc) {
const alpha = new Color(val).getAlpha();
// 'valueProgress should match'
expect(changePerc).toBe((0.9 - alpha) / (0.9 - 0.7));
called = true;
},
onComplete: function (val, _changePerc, _timePerc) {
// 'color is animated on all 4 values';
expect(val).toBe('rgba(255,0,0,0.7)');
},
});
jest.advanceTimersByTime(duration + 16);
expect(called).toBe(true);
});
it('endValue', async () => {
const duration = 16;
animate({
startValue: 2,
endValue: 5,
duration,
onComplete: function (val, changePerc, timePerc) {
// 'endValue is respected')
expect(val).toBe(5);
// , 'change percentage is 100%')
expect(changePerc).toBe(1);
// , 'time percentage is 100%'
expect(timePerc).toBe(1);
},
});
jest.advanceTimersByTime(duration + 16);
});
it('animation context', async () => {
const options = { foo: 'bar' };
const context = animate(options);
expect(context.state).toBe('pending');
expect(typeof context.abort === 'function').toBe(true);
expect(context.duration).toEqual(500);
expect(runningAnimations.length).toBe(1);
jest.advanceTimersByTime(32);
expect(context.state).toBe('running');
jest.advanceTimersByTime(1000);
expect(context.state).toBe('completed');
// 'animation should not exist in registry'
expect(runningAnimations.length).toBe(0);
});
it('runningAnimations', async () => {
expect(runningAnimations instanceof Array).toBe(true);
expect(typeof runningAnimations.cancelAll === 'function').toBe(true);
expect(typeof runningAnimations.cancelByTarget === 'function').toBe(true);
expect(typeof runningAnimations.cancelByCanvas === 'function').toBe(true);
expect(runningAnimations.length).toBe(0);
const target = { foo: 'bar' };
const context = animate({
target,
});
jest.advanceTimersByTime(32);
// 'should have registered animation'
expect(runningAnimations.length).toBe(1);
expect(context.state).toBe('running');
// 'animation should exist in registry'
expect(runningAnimations.indexOf(context)).toBe(0);
const byTarget = findAnimationsByTarget(target);
// 'should have found registered animation by target'
expect(byTarget.length).toBe(1);
// 'should have found registered animation by target'
expect(byTarget[0]).toEqual(context);
jest.advanceTimersByTime(1000);
expect(context.state).toBe('completed');
expect(runningAnimations.length).toBe(0);
});
it('runningAnimations with abort', async () => {
let abort = false;
const options = {
abort() {
return abort;
},
};
const context = animate(options);
jest.advanceTimersByTime(100);
expect(runningAnimations.length).toBe(1);
expect(runningAnimations.indexOf(context)).toBe(0);
expect(context.state).toBe('running');
abort = true;
jest.advanceTimersByTime(100);
expect(runningAnimations.length).toBe(0);
expect(runningAnimations.indexOf(context)).toBe(-1);
expect(context.state).toBe('aborted');
});
it('runningAnimations with imperative abort', async () => {
const options = { foo: 'bar' };
const context = animate(options);
expect(context.state).toBe('pending');
jest.advanceTimersByTime(32);
expect(context.state).toBe('running');
context.abort();
jest.advanceTimersByTime(32);
expect(context.state).toBe('aborted');
});
it('runningAnimations cancelAll', async () => {
const options = { foo: 'bar' };
animate(options);
animate(options);
animate(options);
animate(options);
expect(runningAnimations.length).toBe(4);
expect(typeof runningAnimations.cancelAll === 'function').toBe(true);
const cancelledAnimations = runningAnimations.cancelAll();
expect(cancelledAnimations.length).toBe(4);
expect(runningAnimations.length).toBe(0);
// make sure splice didn't destroy instance
expect(runningAnimations instanceof Array).toBe(true);
});
it('runningAnimations cancelByCanvas', async () => {
const canvas = { pip: 'py' };
animate({ foo: 'bar', target: 'pip' });
animate({ foo: 'bar', target: { canvas: 'pip' } });
animate({ foo: 'bar' });
animate({ target: { canvas } });
// 'should have registered animations'
expect(runningAnimations.length).toBe(4);
let cancelledAnimations = runningAnimations.cancelByCanvas();
// 'should return empty array'
expect(cancelledAnimations.length).toBe(0);
// animations are still all there
expect(runningAnimations.length).toBe(4);
cancelledAnimations = runningAnimations.cancelByCanvas(canvas);
// 'should return cancelled animations'
expect(cancelledAnimations.length).toBe(1);
expect(cancelledAnimations[0].target.canvas).toBe(canvas);
// 'should have left registered animation'
expect(runningAnimations.length).toBe(3);
jest.advanceTimersByTime(1000);
});
it('runningAnimations cancelByTarget', async () => {
const options = { foo: 'bar', target: 'pip' },
opt2 = { bar: 'baz' };
animate(options);
animate(options);
animate(options);
const baz = animate(opt2);
expect(runningAnimations.length).toBe(4);
let cancelledAnimations = runningAnimations.cancelByTarget();
expect(cancelledAnimations.length).toBe(0);
expect(runningAnimations.length).toBe(4);
cancelledAnimations = runningAnimations.cancelByTarget('pip');
expect(cancelledAnimations.length).toBe(3);
expect(runningAnimations.length).toBe(1);
expect(runningAnimations[0]).toBe(baz);
jest.advanceTimersByTime(1000);
});
it('animate', async () => {
const object = new FabricObject({
left: 20,
top: 30,
width: 40,
height: 50,
angle: 43,
});
expect(typeof object.animate === 'function').toBe(true);
const context = object.animate({ left: 40 });
expect(Object.keys(context)).toEqual(['left']);
expect(context.left instanceof ValueAnimation).toBe(true);
expect(runningAnimations.length).toBe(1);
expect(runningAnimations[0].target).toBe(object);
jest.advanceTimersByTime(1000);
expect(Math.round(object.left)).toBe(40);
});
it('animate with increment and without options', async () => {
const object = new FabricObject({
left: 20,
top: 30,
width: 40,
height: 50,
angle: 43,
});
object.animate({ left: object.left + 40 });
jest.advanceTimersByTime(1000);
expect(Math.round(object.left)).toBe(60);
});
it('animate with keypath', async () => {
const object = new FabricObject({
left: 20,
top: 30,
width: 40,
height: 50,
angle: 43,
shadow: new Shadow({ offsetX: 20 }),
});
object.animate({ 'shadow.offsetX': 100 });
jest.advanceTimersByTime(1000);
expect(Math.round(object.shadow!.offsetX)).toBe(100);
});
it('animate with color', async () => {
const object = new FabricObject(),
properties = FabricObject.colorProperties;
properties.forEach((prop, index) => {
object.set(prop, 'red');
object.animate({ [prop]: 'blue' });
expect(runningAnimations.length).toBe(index + 1);
expect(findAnimationsByTarget(object).length).toBe(index + 1);
});
jest.advanceTimersByTime(1000);
properties.forEach((prop) => {
expect(object[prop]).toBe('rgba(0,0,255,1)');
});
});
it('animate with decrement', async () => {
const object = new FabricObject({
left: 20,
top: 30,
width: 40,
height: 50,
angle: 43,
});
object.animate({ left: object.left - 40 });
jest.advanceTimersByTime(1000);
expect(Math.round(object.left)).toBe(-20);
});
it('animate multiple properties', async () => {
const object = new FabricObject({ left: 123, top: 124 });
const context = object.animate({ left: 223, top: 224 });
expect(Object.keys(context)).toEqual(['left', 'top']);
expect(context.left instanceof ValueAnimation).toBe(true);
expect(context.top instanceof ValueAnimation).toBe(true);
jest.advanceTimersByTime(1000);
expect(Math.round(object.get('left'))).toBe(223);
expect(Math.round(object.get('top'))).toBe(224);
});
it('animate multiple properties with callback', async () => {
const object = new FabricObject({ left: 0, top: 0 });
let changedInvocations = 0;
let completeInvocations = 0;
object.animate(
{ left: 1, top: 1 },
{
duration: 10,
onChange: function () {
changedInvocations++;
},
onComplete: function () {
completeInvocations++;
},
},
);
jest.advanceTimersByTime(32);
expect(Math.round(object.get('left'))).toBe(1);
expect(Math.round(object.get('top'))).toBe(1);
expect(changedInvocations).toBe(4);
expect(completeInvocations).toBe(2);
});
it('animate with list of values', async () => {
let run = 0;
const duration = 96;
animate({
startValue: [1, 2, 3],
endValue: [2, 4, 6],
duration,
onChange: (currentValue, _valueProgress) => {
expect(runningAnimations.length).toBe(1);
expect(Array.isArray(currentValue)).toBe(true);
expect(Object.isFrozen(runningAnimations[0].value)).toBe(true);
expect(runningAnimations[0].value).toEqual(currentValue);
expect(currentValue.length).toBe(3);
expect(currentValue[0]).toBeLessThanOrEqual(2);
try {
currentValue[0] = 200;
// catch the frozen status
expect(true).toBe(false);
} catch (e) {
expect(e).toBeInstanceOf(Error);
}
run++;
},
onComplete: (endValue) => {
expect(Object.isFrozen(endValue)).toBe(true);
expect(endValue.length).toBe(3);
expect(endValue).toEqual([2, 4, 6]);
},
});
jest.advanceTimersByTime(duration + 20);
expect(run).toBeGreaterThanOrEqual(3);
jest.advanceTimersByTime(duration + 20);
});
it('abort function is calle with object as context', async () => {
const object = new FabricObject({ left: 123, top: 124 });
let context: any;
object.animate(
{ left: 223, top: 224 },
{
abort: function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
context = this;
return true;
},
},
);
jest.advanceTimersByTime(100);
expect(Math.round(object.get('left'))).toBe(123);
expect(Math.round(object.get('top'))).toBe(124);
expect(context).toBe(object);
jest.advanceTimersByTime(500);
});
it('animate with imperative abort', async () => {
const object = new FabricObject({ left: 123, top: 124 });
let called = 0;
const context = object._animate('left', 223, {
abort: function () {
called++;
return false;
},
});
expect(typeof context.abort === 'function').toBe(true);
expect(context.state).toBe('pending');
context.abort();
expect(context.state).toBe('aborted');
jest.advanceTimersByTime(100);
expect(Math.round(object.get('left'))).toBe(123);
expect(called).toBe(0);
});
it('animate with delay', async () => {
const object = new FabricObject({ left: 123, top: 124 });
const delay = 500;
const offset = 20;
const duration = 200;
const context = object._animate('left', 223, {
delay,
duration,
});
expect(context.state).toBe('pending');
jest.advanceTimersByTime(delay - offset);
expect(context.state).toBe('pending');
jest.advanceTimersByTime(offset * 2);
expect(context.state).toBe('running');
jest.advanceTimersByTime(duration + offset);
expect(context.state).toBe('completed');
});
});
describe('easing', () => {
afterEach(() => {
jest.clearAllTimers();
jest.runAllTimers();
});
Object.entries(ease).map(([easingName, easingFunction]) => {
it(easingName, async () => {
const duration = 320;
const snapshot: any[] = [];
expect(typeof ease.easeInQuad === 'function').toBe(true);
const object = new FabricObject({ left: 0 });
object.animate(
{ left: 100 },
{
onComplete: function () {
// 'animation ended correctly'
expect(Math.round(object.left)).toBe(100);
},
duration,
onChange: function (val, percentage) {
snapshot.push({
val: val.toFixed(4),
percentage: percentage.toFixed(4),
});
},
easing: easingFunction,
},
);
jest.advanceTimersByTime(duration + 16);
expect(snapshot).toMatchSnapshot();
});
});
});