fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
1,593 lines (1,387 loc) • 84.5 kB
text/typescript
import { Canvas } from './Canvas';
import { Rect } from '../shapes/Rect';
import { IText } from '../shapes/IText/IText';
import '../shapes/ActiveSelection';
import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest';
import { config } from '../config';
import type {
FabricObject,
MultiSelectionStacking,
TPointerEvent,
} from '../../fabric';
import { createPointerEvent, makeRect } from '../../test/utils';
import {
ActiveSelection,
Circle,
classRegistry,
FabricText,
getFabricDocument,
Group,
Path,
version,
} from '../../fabric';
import TEST_IMAGE from '../../test/fixtures/test_image.gif';
import { isJSDOM } from '../../vitest.extend';
import {
EMPTY_JSON,
PATH_DATALESS_JSON,
PATH_OBJ_JSON,
PATH_WITHOUT_DEFAULTS_JSON,
PATH_JSON,
RECT_JSON,
ERROR_IMAGE_JSON,
} from './Canvas.fixtures.ts';
describe('Canvas', () => {
let canvas: Canvas;
let upperCanvasEl: HTMLCanvasElement;
let lowerCanvasEl: HTMLCanvasElement;
beforeEach(() => {
canvas = new Canvas(undefined, {
enableRetinaScaling: false,
width: 600,
height: 600,
});
upperCanvasEl = canvas.upperCanvasEl;
lowerCanvasEl = canvas.lowerCanvasEl;
});
afterEach(() => {
config.restoreDefaults();
classRegistry.setClass(ActiveSelection);
return canvas.dispose();
});
describe('touchStart', () => {
test('will prevent default to not allow dom scrolling on canvas touch drag', () => {
const canvas = new Canvas(undefined, {
allowTouchScrolling: false,
});
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evt = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
evt.preventDefault = vi.fn();
canvas._onTouchStart(evt);
expect(evt.preventDefault).toHaveBeenCalled();
});
test('will not prevent default when allowTouchScrolling is true and there is no action', () => {
const canvas = new Canvas(undefined, {
allowTouchScrolling: true,
});
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evt = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
evt.preventDefault = vi.fn();
canvas._onTouchStart(evt);
expect(evt.preventDefault).not.toHaveBeenCalled();
});
test('will prevent default when allowTouchScrolling is true but we are drawing', () => {
const canvas = new Canvas(undefined, {
allowTouchScrolling: true,
isDrawingMode: true,
});
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evt = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
evt.preventDefault = vi.fn();
canvas._onTouchStart(evt);
expect(evt.preventDefault).toHaveBeenCalled();
});
test('will prevent default when allowTouchScrolling is true and we are dragging an object', () => {
const canvas = new Canvas(undefined, {
allowTouchScrolling: true,
});
const rect = new Rect({
width: 2000,
height: 2000,
left: -500,
top: -500,
});
canvas.add(rect);
canvas.setActiveObject(rect);
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evt = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
evt.preventDefault = vi.fn();
canvas._onTouchStart(evt);
expect(evt.preventDefault).toHaveBeenCalled();
});
test('will NOT prevent default when allowTouchScrolling is true and we just lost selection', () => {
const canvas = new Canvas(undefined, {
allowTouchScrolling: true,
});
const rect = new Rect({
width: 200,
height: 200,
left: 1000,
top: 1000,
});
canvas.add(rect);
canvas.setActiveObject(rect);
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evt = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
evt.preventDefault = vi.fn();
canvas._onTouchStart(evt);
expect(evt.preventDefault).not.toHaveBeenCalled();
});
test('dispose after _onTouchStart', () => {
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const canvas = new Canvas(undefined, {
allowTouchScrolling: true,
isDrawingMode: true,
});
const touch = new Touch({
clientX: 10,
clientY: 0,
identifier: 1,
target: canvas.upperCanvasEl,
});
const evtStart = new TouchEvent('touchstart', {
touches: [touch],
changedTouches: [touch],
});
canvas._onTouchStart(evtStart);
const evtEnd = new TouchEvent('touchend', {
touches: [],
changedTouches: [touch],
});
canvas._onTouchEnd(evtEnd);
// @ts-expect-error -- private method
expect(+canvas._willAddMouseDown).toBeGreaterThan(0);
canvas.dispose();
// @ts-expect-error -- private method
expect(clearTimeoutSpy).toHaveBeenCalledWith(canvas._willAddMouseDown);
});
});
describe('handleMultiSelection', () => {
const canvas = new Canvas();
const rect = new Rect({ left: 100, width: 100, height: 100 });
const iText = new IText('itext');
canvas.add(rect, iText);
test('Selecting shapes containing text does not trigger the exit event', () => {
const exitMock = vi.fn();
iText.on('editing:exited', exitMock);
const firstClick = new MouseEvent('click', {
clientX: 0,
clientY: 0,
});
canvas._onMouseDown(firstClick);
canvas._onMouseUp(firstClick);
const secondClick = new MouseEvent('click', {
shiftKey: true,
clientX: 100,
clientY: 0,
});
canvas._onMouseDown(secondClick);
canvas._onMouseUp(secondClick);
expect(exitMock).toHaveBeenCalledTimes(0);
});
});
it('prevents multiple canvas initialization', () => {
const newCanvas = new Canvas();
expect(newCanvas.lowerCanvasEl).toBeTruthy();
expect(() => new Canvas(newCanvas.lowerCanvasEl)).toThrow();
});
it('initializes with element existing in the dom', () => {
const doc = getFabricDocument();
const wrapper = doc.createElement('div');
const canvasEl = doc.createElement('canvas');
wrapper.appendChild(canvasEl);
doc.body.appendChild(wrapper);
const newCanvas = new Canvas(canvasEl);
expect(wrapper.firstChild, 'replaced canvas el in dom').toBe(
newCanvas.elements.container,
);
expect(
newCanvas.elements.container.firstChild,
'appended canvas el to container',
).toBe(newCanvas.elements.lower.el);
expect(
newCanvas.elements.container.lastChild,
'appended upper canvas el to container',
).toBe(newCanvas.elements.upper.el);
});
it('has expected initial properties', () => {
expect('backgroundColor' in canvas).toBeTruthy();
expect(canvas.includeDefaultValues).toBe(true);
});
it('implements getObjects method', () => {
expect(
canvas.getObjects,
'should respond to `getObjects` method',
).toBeTypeOf('function');
expect(
canvas.getObjects(),
'should return empty array for `getObjects` when empty',
).toEqual([]);
expect(
canvas.getObjects().length,
'should have a 0 length when empty',
).toBe(0);
});
it('implements getElement method', () => {
expect(
canvas.getElement,
'should respond to `getElement` method',
).toBeTypeOf('function');
expect(canvas.getElement(), 'should return a proper element').toBe(
lowerCanvasEl,
);
});
it('implements item method', () => {
const rect = makeRect();
expect(canvas.item, 'should respond to item').toBeTypeOf('function');
canvas.add(rect);
expect(canvas.item(0), 'should return proper item').toBe(rect);
});
it('preserveObjectStacking property', () => {
expect(canvas.preserveObjectStacking).toBeTypeOf('boolean');
expect(canvas.preserveObjectStacking, 'default is true').toBeTruthy();
});
it('uniformScaling property', () => {
expect(canvas.uniformScaling).toBeTypeOf('boolean');
expect(canvas.uniformScaling, 'default is true').toBeTruthy();
});
it('uniScaleKey property', () => {
expect(canvas.uniScaleKey).toBeTypeOf('string');
expect(canvas.uniScaleKey, 'default is shift').toBe('shiftKey');
});
it('centeredScaling property', () => {
expect(canvas.centeredScaling).toBeTypeOf('boolean');
expect(canvas.centeredScaling, 'default is false').toBeFalsy();
});
it('centeredRotation property', () => {
expect(canvas.centeredRotation).toBeTypeOf('boolean');
expect(canvas.centeredRotation, 'default is false').toBeFalsy();
});
it('centeredKey property', () => {
expect(canvas.centeredKey).toBeTypeOf('string');
expect(canvas.centeredKey, 'default is alt').toBe('altKey');
});
it('altActionKey property', () => {
expect(canvas.altActionKey).toBeTypeOf('string');
expect(canvas.altActionKey, 'default is shift').toBe('shiftKey');
});
it('selection property', () => {
expect(canvas.selection).toBeTypeOf('boolean');
expect(canvas.selection, 'default is true').toBeTruthy();
});
it('initializes DOM elements correctly', () => {
expect(
canvas.lowerCanvasEl.getAttribute('data-fabric'),
'el should be marked by canvas init',
).toBe('main');
expect(
canvas.upperCanvasEl.getAttribute('data-fabric'),
'el should be marked by canvas init',
).toBe('top');
expect(
canvas.wrapperEl.getAttribute('data-fabric'),
'el should be marked by canvas init',
).toBe('wrapper');
});
it('implements renderTop method', () => {
expect(canvas.renderTop).toBeTypeOf('function');
});
it('implements _chooseObjectsToRender method', () => {
expect(canvas._chooseObjectsToRender).toBeTypeOf('function');
canvas.preserveObjectStacking = false;
const rect = makeRect(),
rect2 = makeRect(),
rect3 = makeRect();
canvas.add(rect);
canvas.add(rect2);
canvas.add(rect3);
let objs = canvas._chooseObjectsToRender();
expect(objs[0]).toBe(rect);
expect(objs[1]).toBe(rect2);
expect(objs[2]).toBe(rect3);
canvas.setActiveObject(rect);
objs = canvas._chooseObjectsToRender();
expect(objs[0]).toBe(rect2);
expect(objs[1]).toBe(rect3);
expect(objs[2]).toBe(rect);
canvas.setActiveObject(rect2);
canvas.preserveObjectStacking = true;
objs = canvas._chooseObjectsToRender();
expect(objs[0]).toBe(rect);
expect(objs[1]).toBe(rect2);
expect(objs[2]).toBe(rect3);
});
it('implements calcOffset method', () => {
expect(canvas.calcOffset, 'should respond to `calcOffset`').toBeTypeOf(
'function',
);
expect(canvas.calcOffset(), 'should return offset').toEqual({
left: 0,
top: 0,
});
});
it('implements add method', () => {
const rect1 = makeRect(),
rect2 = makeRect(),
rect3 = makeRect(),
rect4 = makeRect();
expect(canvas.add).toBeTypeOf('function');
expect(
canvas.add(rect1),
'should return the new length of objects array',
).toBe(1);
expect(canvas.item(0)).toBe(rect1);
canvas.add(rect2, rect3, rect4);
expect(
canvas.getObjects().length,
'should support multiple arguments',
).toBe(4);
expect(canvas.item(1)).toBe(rect2);
expect(canvas.item(2)).toBe(rect3);
expect(canvas.item(3)).toBe(rect4);
});
it('implements insertAt method', () => {
const rect1 = makeRect(),
rect2 = makeRect();
canvas.add(rect1, rect2);
expect(canvas.insertAt, 'should respond to `insertAt` method').toBeTypeOf(
'function',
);
const rect = makeRect();
canvas.insertAt(1, rect);
expect(canvas.item(1)).toBe(rect);
canvas.insertAt(2, rect);
expect(canvas.item(2)).toBe(rect);
});
it('implements remove method', () => {
const rect1 = makeRect(),
rect2 = makeRect(),
rect3 = makeRect(),
rect4 = makeRect();
canvas.add(rect1, rect2, rect3, rect4);
expect(canvas.remove).toBeTypeOf('function');
expect(canvas.remove(rect1)[0], 'should return the object removed').toBe(
rect1,
);
expect(canvas.item(0), 'should be second object').toBe(rect2);
canvas.remove(rect2, rect3);
expect(canvas.item(0)).toBe(rect4);
canvas.remove(rect4);
expect(canvas.isEmpty(), 'canvas should be empty').toBe(true);
});
it('clears hovered target when removed', () => {
const rect1 = makeRect();
canvas.add(rect1);
canvas._hoveredTarget = rect1;
canvas.remove(rect1);
expect(
canvas._hoveredTarget,
'reference to hovered target should be removed',
).toBeUndefined();
});
it('fires before:selection:cleared only when removing active objects', () => {
let isFired = false;
canvas.on('before:selection:cleared', () => {
isFired = true;
});
canvas.add(new Rect());
canvas.remove(canvas.item(0));
expect(
isFired,
'removing inactive object shouldnt fire "before:selection:cleared"',
).toBe(false);
canvas.add(new Rect());
canvas.setActiveObject(canvas.item(0));
canvas.remove(canvas.item(0));
expect(
isFired,
'removing active object should fire "before:selection:cleared"',
).toBe(true);
});
it('provides deselected objects in before:selection:cleared event', () => {
let deselected: FabricObject[] = [];
canvas.on('before:selection:cleared', (options) => {
deselected = options.deselected;
});
const rect = new Rect();
canvas.add(rect);
canvas.setActiveObject(rect);
canvas.discardActiveObject();
expect(deselected.length, 'options.deselected was the removed object').toBe(
1,
);
expect(deselected[0], 'options.deselected was the removed object').toBe(
rect,
);
const rect1 = new Rect();
const rect2 = new Rect();
canvas.add(rect1, rect2);
const activeSelection = new ActiveSelection();
activeSelection.add(rect1, rect2);
canvas.setActiveObject(activeSelection);
canvas.discardActiveObject();
expect(deselected.length, 'options.deselected was the removed object').toBe(
1,
);
expect(
deselected[0],
'removing an activeSelection pass that as a target',
).toBe(activeSelection);
});
it('fires selection:cleared only when removing active objects', () => {
let isFired = false;
canvas.on('selection:cleared', () => {
isFired = true;
});
canvas.add(new Rect());
canvas.remove(canvas.item(0));
expect(
isFired,
'removing inactive object shouldnt fire "selection:cleared"',
).toBe(false);
canvas.add(new Rect());
canvas.setActiveObject(canvas.item(0));
canvas.remove(canvas.item(0));
expect(
isFired,
'removing active object should fire "selection:cleared"',
).toBe(true);
canvas.off('selection:cleared');
});
it('creating active selection fires selection:created event', () => {
let isFired = false;
const rect1 = new Rect();
const rect2 = new Rect();
canvas.add(rect1, rect2);
canvas.on('selection:created', () => {
isFired = true;
});
initActiveSelection(canvas, rect1, rect2, 'selection-order');
expect(canvas._hoveredTarget, 'the created selection is also hovered').toBe(
canvas.getActiveObject(),
);
expect(isFired, 'selection:created fired').toBe(true);
canvas.off('selection:created');
canvas.clear();
});
it('creating active selection fires selected event on new objects', () => {
let isFired = false;
const rect1 = new Rect();
const rect2 = new Rect();
canvas.add(rect1, rect2);
rect2.on('selected', () => {
isFired = true;
});
initActiveSelection(canvas, rect1, rect2, 'selection-order');
const activeSelection = canvas.getActiveObjects();
expect(isFired, 'selected fired on rect2').toBe(true);
expect(activeSelection[0], 'first rec1').toBe(rect1);
expect(activeSelection[1], 'then rect2').toBe(rect2);
canvas.clear();
});
it('starts multiselection with correct order (default)', () => {
const rect1 = new Rect();
const rect2 = new Rect();
canvas.add(rect1, rect2);
initActiveSelection(canvas, rect2, rect1, 'selection-order');
const activeSelection = canvas.getActiveObjects();
expect(activeSelection[0], 'first rect2').toBe(rect2);
expect(activeSelection[1], 'then rect1').toBe(rect1);
});
it('starts multiselection with canvas stacking order', () => {
const rect1 = new Rect();
const rect2 = new Rect();
canvas.add(rect1, rect2);
initActiveSelection(canvas, rect2, rect1, 'canvas-stacking');
const activeSelection = canvas.getActiveObjects();
expect(activeSelection[0], 'first rect1').toBe(rect1);
expect(activeSelection[1], 'then rect2').toBe(rect2);
});
it('fires selection:updated when updating active selection', () => {
let isFired = false;
const rect1 = new Rect();
const rect2 = new Rect();
const rect3 = new Rect();
canvas.add(rect1, rect2, rect3);
canvas.on('selection:updated', () => {
isFired = true;
});
updateActiveSelection(canvas, [rect1, rect2], rect3, 'selection-order');
expect(isFired, 'selection:updated fired').toBe(true);
expect(canvas._hoveredTarget, 'hovered target is updated').toBe(
canvas.getActiveObject(),
);
});
it('fires deselected event when removing object from active selection', () => {
let isFired = false;
const rect1 = new Rect({ width: 10, height: 10 });
const rect2 = new Rect({ width: 10, height: 10 });
canvas.add(rect1, rect2);
rect2.on('deselected', () => {
isFired = true;
});
updateActiveSelection(canvas, [rect1, rect2], rect2, 'selection-order');
expect(isFired, 'deselected on rect2 fired').toBe(true);
});
it('fires selected event when adding object to active selection', () => {
let isFired = false;
const rect1 = new Rect();
const rect2 = new Rect();
const rect3 = new Rect();
canvas.add(rect1, rect2, rect3);
rect3.on('selected', () => {
isFired = true;
});
updateActiveSelection(canvas, [rect1, rect2], rect3, 'selection-order');
expect(isFired, 'selected on rect3 fired').toBe(true);
});
it('respects order of objects in continuing multiselection', () => {
const rect1 = new Rect();
const rect2 = new Rect();
const rect3 = new Rect();
canvas.add(rect1, rect2, rect3);
function assertObjectsInOrder(init: FabricObject[], added: FabricObject) {
updateActiveSelection(canvas, init, added, 'canvas-stacking');
expect(
canvas.getActiveObjects(),
'updated selection while preserving canvas stacking order',
).toEqual([rect1, rect2, rect3]);
canvas.discardActiveObject();
updateActiveSelection(canvas, init, added, 'selection-order');
expect(
canvas.getActiveObjects(),
'updated selection while preserving click order',
).toEqual([...init, added]);
canvas.discardActiveObject();
}
function assertObjectsInOrderOnCanvas(
init: FabricObject[],
added: FabricObject,
) {
expect(canvas.getObjects()).toEqual([rect1, rect2, rect3]);
assertObjectsInOrder(init, added);
expect(canvas.getObjects()).toEqual([rect1, rect2, rect3]);
}
assertObjectsInOrderOnCanvas([rect1, rect2], rect3);
assertObjectsInOrderOnCanvas([rect1, rect3], rect2);
assertObjectsInOrderOnCanvas([rect2, rect3], rect1);
canvas.remove(rect2, rect3);
const group = new Group([rect2, rect3], {
subTargetCheck: true,
interactive: true,
});
canvas.add(group);
function assertNestedObjectsInOrder(
init: FabricObject[],
added: FabricObject,
) {
expect(canvas.getObjects()).toEqual([rect1, group]);
expect(group.getObjects()).toEqual([rect2, rect3]);
assertObjectsInOrder(init, added);
expect(canvas.getObjects()).toEqual([rect1, group]);
expect(group.getObjects()).toEqual([rect2, rect3]);
}
assertNestedObjectsInOrder([rect1, rect2], rect3);
assertNestedObjectsInOrder([rect1, rect3], rect2);
assertNestedObjectsInOrder([rect2, rect3], rect1);
canvas.remove(rect1);
group.insertAt(0, rect1);
group.remove(rect3);
canvas.add(rect3);
function assertNestedObjectsInOrder2(
init: FabricObject[],
added: FabricObject,
) {
expect(canvas.getObjects()).toEqual([group, rect3]);
expect(group.getObjects()).toEqual([rect1, rect2]);
assertObjectsInOrder(init, added);
expect(canvas.getObjects()).toEqual([group, rect3]);
expect(group.getObjects()).toEqual([rect1, rect2]);
}
assertNestedObjectsInOrder2([rect1, rect2], rect3);
assertNestedObjectsInOrder2([rect1, rect3], rect2);
assertNestedObjectsInOrder2([rect2, rect3], rect1);
});
it('toggles selected objects in multiselection', () => {
const rect1 = new Rect();
const rect2 = new Rect();
const rect3 = new Rect();
let isFired = false;
rect2.on('deselected', () => {
isFired = true;
});
canvas.add(rect1, rect2, rect3);
updateActiveSelection(
canvas,
[rect1, rect2, rect3],
rect2,
'selection-order',
);
expect(canvas.getActiveObjects(), 'rect2 was deselected').toEqual([
rect1,
rect3,
]);
expect(isFired, 'fired deselected').toBeTruthy();
});
it('toggles nested target when clicking inside active selection', () => {
const rect1 = new Rect({ width: 10, height: 10 });
const rect2 = new Rect({ width: 10, height: 10 });
const rect3 = new Rect({ width: 10, height: 10 });
let isFired = false;
rect3.on('deselected', () => {
isFired = true;
});
canvas.add(rect1, rect2, rect3);
updateActiveSelection(
canvas,
[rect1, rect2, rect3],
null,
'selection-order',
);
expect(canvas.getActiveObjects(), 'rect3 was deselected').toEqual([
rect1,
rect2,
]);
expect(isFired, 'fired deselected').toBeTruthy();
});
it('does nothing when clicking active selection area', () => {
const rect1 = new Rect({ left: 10, width: 10, height: 10 });
const rect2 = new Rect({ left: -10, width: 5, height: 5 });
const rect3 = new Rect({ top: 10, width: 10, height: 10 });
canvas.add(rect1, rect2, rect3);
updateActiveSelection(
canvas,
[rect1, rect2, rect3],
null,
'selection-order',
);
expect(canvas.getActiveObjects(), 'nothing happened').toEqual([
rect1,
rect2,
rect3,
]);
expect(
canvas.getActiveObject() === canvas.getActiveObject(),
'still selected',
).toBeTruthy();
});
it('selects target behind active selection when using selection key', () => {
const rect1 = new Rect({ left: 15, top: 5, width: 10, height: 10 });
const rect2 = new Rect({ width: 10, height: 10, left: 5, top: 5 });
const rect3 = new Rect({ top: 15, left: 5, width: 10, height: 10 });
canvas.add(rect1, rect2, rect3);
initActiveSelection(canvas, rect1, rect3);
expect(
canvas.getActiveObject() === canvas.getActiveObject(),
'selected',
).toBeTruthy();
expect(canvas.getActiveObjects(), 'created').toEqual([rect1, rect3]);
canvas._onMouseDown({
clientX: 7,
clientY: 7,
[canvas.selectionKey as string]: true,
} as unknown as TPointerEvent);
expect(
canvas.getActiveObjects(),
'added from behind active selection',
).toEqual([rect1, rect2, rect3]);
expect(
canvas.getActiveObject() === canvas.getActiveObject(),
'still selected',
).toBeTruthy();
});
it('fires deselected event when changing active object', () => {
let isFired = false;
const rect1 = new Rect();
const rect2 = new Rect();
rect1.on('deselected', () => {
isFired = true;
});
canvas.setActiveObject(rect1);
canvas.setActiveObject(rect2);
expect(isFired, 'switching active group fires deselected').toBe(true);
});
it('fires selected event for each object when group selecting', () => {
let fired = 0;
const rect1 = new Rect({ width: 10, height: 10 });
const rect2 = new Rect({ width: 10, height: 10 });
const rect3 = new Rect({ width: 10, height: 10 });
rect1.on('selected', () => {
fired++;
});
rect2.on('selected', () => {
fired++;
});
rect3.on('selected', () => {
fired++;
});
canvas.add(rect1, rect2, rect3);
setGroupSelector(canvas, {
x: 1,
y: 1,
deltaX: 5,
deltaY: 5,
});
canvas._onMouseUp(createPointerEvent({ target: canvas.upperCanvasEl }));
expect(fired, 'event fired for each of 3 rects').toBe(3);
});
it('fires selection:created when multiple objects are selected', () => {
let isFired = false;
const rect1 = new Rect({ width: 10, height: 10 });
const rect2 = new Rect({ width: 10, height: 10 });
const rect3 = new Rect({ width: 10, height: 10 });
canvas.on('selection:created', () => {
isFired = true;
});
canvas.add(rect1, rect2, rect3);
setGroupSelector(canvas, {
x: 1,
y: 1,
deltaX: 5,
deltaY: 5,
});
canvas._onMouseUp(createPointerEvent({ target: canvas.upperCanvasEl }));
expect(isFired, 'selection created fired').toBe(true);
expect(
// @ts-expect-error -- constructor function has type
canvas.getActiveObject()!.constructor.type,
'an active selection is created',
).toBe('ActiveSelection');
expect(canvas.getActiveObjects()[0], 'rect1 is first object').toBe(rect1);
expect(canvas.getActiveObjects()[1], 'rect2 is second object').toBe(rect2);
expect(canvas.getActiveObjects()[2], 'rect3 is third object').toBe(rect3);
expect(canvas.getActiveObjects().length, 'contains exactly 3 objects').toBe(
3,
);
});
it('fires selection:created when a single object is selected', () => {
let isFired = false;
const rect1 = new Rect({ width: 10, height: 10 });
canvas.on('selection:created', () => {
isFired = true;
});
canvas.add(rect1);
setGroupSelector(canvas, {
x: 1,
y: 1,
deltaX: 5,
deltaY: 5,
});
canvas._onMouseUp(createPointerEvent({ target: canvas.upperCanvasEl }));
expect(isFired, 'selection:created fired').toBe(true);
expect(canvas.getActiveObject(), 'rect1 is set as activeObject').toBe(
rect1,
);
});
it('collects topmost object when no dragging occurs', () => {
const rect1 = new Rect({ width: 10, height: 10, top: 0, left: 0 });
const rect2 = new Rect({ width: 10, height: 10, top: 0, left: 0 });
const rect3 = new Rect({ width: 10, height: 10, top: 0, left: 0 });
canvas.add(rect1, rect2, rect3);
setGroupSelector(canvas, { x: 1, y: 1, deltaX: 0, deltaY: 0 });
// @ts-expect-error -- protected method
expect(canvas.handleSelection({}), 'selection occurred').toBeTruthy();
expect(
canvas.getActiveObjects().length,
'a rect that contains all objects collects them all',
).toBe(1);
expect(canvas.getActiveObjects()[0], 'rect3 is collected').toBe(rect3);
});
it('does not collect objects with onSelect returning true', () => {
const rect1 = new Rect({ width: 10, height: 10, top: 2, left: 2 });
rect1.onSelect = () => {
return true;
};
const rect2 = new Rect({ width: 10, height: 10, top: 2, left: 2 });
canvas.add(rect1, rect2);
setGroupSelector(canvas, { x: 1, y: 1, deltaX: 20, deltaY: 20 });
// @ts-expect-error -- protected method
expect(canvas.handleSelection({}), 'selection occurred').toBeTruthy();
expect(
canvas.getActiveObjects().length,
'objects are in the same position buy only one gets selected',
).toBe(1);
expect(canvas.getActiveObjects()[0], 'contains rect2 but not rect 1').toBe(
rect2,
);
});
it('does not call onSelect on objects that are not intersected', () => {
const rect1 = new Rect({ width: 10, height: 10, top: 5, left: 5 });
const rect2 = new Rect({ width: 10, height: 10, top: 5, left: 15 });
let onSelectRect1CallCount = 0;
let onSelectRect2CallCount = 0;
rect1.onSelect = () => {
onSelectRect1CallCount++;
return false;
};
rect2.onSelect = () => {
onSelectRect2CallCount++;
return false;
};
canvas.add(rect1, rect2);
// Intersects none
setGroupSelector(canvas, { x: 25, y: 25, deltaX: 1, deltaY: 1 });
// @ts-expect-error -- protected method
expect(canvas.handleSelection({}), 'selection occurred').toBeTruthy();
const onSelectCalls = onSelectRect1CallCount + onSelectRect2CallCount;
expect(onSelectCalls, 'none of the onSelect methods was called').toBe(0);
// Intersects one
setGroupSelector(canvas, { x: 0, y: 0, deltaX: 5, deltaY: 5 });
// @ts-expect-error -- protected method
expect(canvas.handleSelection({}), 'selection occurred').toBeTruthy();
expect(canvas.getActiveObject(), 'rect1 was selected').toBe(rect1);
expect(
onSelectRect1CallCount,
'rect1 onSelect was called while setting active object',
).toBe(1);
expect(onSelectRect2CallCount, 'rect2 onSelect was not called').toBe(0);
// Intersects both
setGroupSelector(canvas, { x: 0, y: 0, deltaX: 15, deltaY: 5 });
// @ts-expect-error -- protected method
expect(canvas.handleSelection({}), 'selection occurred').toBeTruthy();
expect(canvas.getActiveObjects(), 'rect1 selected').toEqual([rect1, rect2]);
expect(
onSelectRect1CallCount,
'rect1 onSelect was called once when collectiong it and once when selecting it',
).toBe(2);
expect(onSelectRect2CallCount, 'rect2 onSelect was called').toBe(1);
});
it('returns false from handleMultiSelection when onSelect returns true', () => {
const rect = new Rect();
const rect2 = new Rect();
rect.onSelect = () => {
return true;
};
canvas._activeObject = rect2;
const selectionKey = canvas.selectionKey;
const event = {};
// @ts-expect-error -- typed as readonly but in test case we want to override
event[selectionKey] = true;
// @ts-expect-error -- protected method
const returned = canvas.handleMultiSelection(event, rect);
expect(returned, 'if onSelect returns true, shouldGroup return false').toBe(
false,
);
});
it('returns true from handleMultiSelection when onSelect returns false and selectionKey is true', () => {
const rect = new Rect();
const rect2 = new Rect();
rect.onSelect = () => {
return false;
};
canvas._activeObject = rect2;
const selectionKey = canvas.selectionKey;
const event = {};
// @ts-expect-error -- typed as readonly but in test case we want to override
event[selectionKey] = true;
// @ts-expect-error -- protected method
const returned = canvas.handleMultiSelection(event, rect);
expect(returned, 'if onSelect returns false, shouldGroup return true').toBe(
true,
);
});
it('returns false from handleMultiSelection when selectionKey is false', () => {
const rect = new Rect();
const rect2 = new Rect();
rect.onSelect = () => {
return false;
};
canvas._activeObject = rect2;
const selectionKey = canvas.selectionKey;
const event = {};
// @ts-expect-error -- typed as readonly but in test case we want to override
event[selectionKey] = false;
// @ts-expect-error -- protected method
const returned = canvas.handleMultiSelection(event, rect);
expect(returned, 'shouldGroup return false').toBe(false);
});
it('fires multiple events from _fireSelectionEvents', () => {
let rect1Deselected = false;
let rect3Selected = false;
const rect1 = new Rect();
const rect2 = new Rect();
const rect3 = new Rect();
const activeSelection = new ActiveSelection();
activeSelection.add(rect1, rect2);
canvas.setActiveObject(activeSelection);
rect1.on('deselected', () => {
rect1Deselected = true;
});
rect3.on('selected', () => {
rect3Selected = true;
});
const currentObjects = canvas.getActiveObjects();
activeSelection.remove(rect1);
activeSelection.add(rect3);
canvas._fireSelectionEvents(currentObjects, {} as TPointerEvent);
expect(rect3Selected, 'rect 3 selected').toBeTruthy();
expect(rect1Deselected, 'rect 1 deselected').toBeTruthy();
});
it('implements getContext method', () => {
expect(canvas.getContext).toBeTypeOf('function');
});
it('implements clearContext method', () => {
expect(canvas.clearContext).toBeTypeOf('function');
canvas.clearContext(canvas.getContext());
});
it('implements clear method and empties the canvas', () => {
expect(canvas.clear).toBeTypeOf('function');
canvas.clear();
expect(canvas.getObjects().length).toBe(0);
});
it('implements renderAll method', () => {
expect(canvas.renderAll).toBeTypeOf('function');
});
it('implements _drawSelection method', () => {
expect(canvas._drawSelection).toBeTypeOf('function');
});
it('finds target objects correctly with findTarget', () => {
expect(canvas.findTarget).toBeTypeOf('function');
const rect = makeRect({ left: 0, top: 0 });
canvas.add(rect);
const { target } = canvas.findTarget(
createPointerEvent({
clientX: 5,
clientY: 5,
target: canvas.upperCanvasEl,
}),
);
expect(target, 'Should return the rect').toBe(rect);
const { target: target2 } = canvas.findTarget(
createPointerEvent({
clientX: 30,
clientY: 30,
target: canvas.upperCanvasEl,
}),
);
expect(target2, 'Should not find target').toBeUndefined();
canvas.remove(rect);
});
it('implements toCanvasElement method that clears the contextTop', () => {
const canvas = new Canvas();
const mockSetCtx = vi.fn();
class UpperMock {
declare el: any;
set ctx(value: any) {
mockSetCtx(value);
}
get ctx() {
return undefined;
}
constructor() {
this.el = {
getContext: vi.fn(),
};
}
}
canvas.elements.upper = new UpperMock();
canvas.toCanvasElement();
expect(mockSetCtx).toHaveBeenCalledWith(undefined);
expect(mockSetCtx).toHaveBeenCalledTimes(2);
});
it('implements toDataURL method that returns valid data URL', () => {
expect(canvas.toDataURL).toBeTypeOf('function');
const dataURL = canvas.toDataURL();
// don't compare actual data url, as it is often browser-dependent
expect(typeof dataURL, 'is a string').toBe('string');
expect(dataURL.substring(0, 21), 'starts with correct prefix').toBe(
'data:image/png;base64',
);
});
it('implements getCenterPoint method that returns canvas center as Point', () => {
expect(canvas.getCenterPoint).toBeTypeOf('function');
const center = canvas.getCenterPoint();
expect(center.x, 'center x is half width').toBe(upperCanvasEl.width / 2);
expect(center.y, 'center y is half height').toBe(upperCanvasEl.height / 2);
});
it('centers objects horizontally with centerObjectH', () => {
expect(canvas.centerObjectH).toBeTypeOf('function');
const rect = makeRect({ left: 102, top: 202 });
canvas.add(rect);
canvas.centerObjectH(rect);
expect(
rect.getCenterPoint().x,
'object\'s "left" property should correspond to canvas element\'s center',
).toBe(upperCanvasEl.width / 2);
});
it('centers objects vertically with centerObjectV', () => {
expect(canvas.centerObjectV).toBeTypeOf('function');
const rect = makeRect({ left: 102, top: 202 });
canvas.add(rect);
canvas.centerObjectV(rect);
expect(
rect.getCenterPoint().y,
'object\'s "top" property should correspond to canvas element\'s center',
).toBe(upperCanvasEl.height / 2);
});
it('centers objects with centerObject', () => {
expect(canvas.centerObject).toBeTypeOf('function');
const rect = makeRect({ left: 102, top: 202 });
canvas.add(rect);
canvas.centerObject(rect);
expect(
rect.getCenterPoint().y,
'object\'s "top" property should correspond to canvas element\'s center',
).toBe(upperCanvasEl.height / 2);
expect(
rect.getCenterPoint().x,
'object\'s "left" property should correspond to canvas element\'s center',
).toBe(upperCanvasEl.width / 2);
});
it('serializes to JSON with toJSON', () => {
expect(canvas.toJSON).toBeTypeOf('function');
expect(JSON.stringify(canvas.toJSON())).toBe(JSON.stringify(EMPTY_JSON));
canvas.backgroundColor = '#ff5555';
canvas.overlayColor = 'rgba(0,0,0,0.2)';
expect(
canvas.toJSON(),
'`background` and `overlayColor` value should be reflected in json',
).toEqual({
version: version,
objects: [],
background: '#ff5555',
overlay: 'rgba(0,0,0,0.2)',
});
canvas.add(makeRect());
expect(canvas.toJSON()).toEqual(RECT_JSON);
});
it('serializes to JSON with active selection', () => {
const rect = new Rect({ width: 50, height: 50, left: 100, top: 100 });
const circle = new Circle({ radius: 50, left: 50, top: 50 });
canvas.add(rect, circle);
const json = JSON.stringify(canvas);
const activeSelection = new ActiveSelection();
activeSelection.add(rect, circle);
canvas.setActiveObject(activeSelection);
const jsonWithActiveGroup = JSON.stringify(canvas);
expect(json).toBe(jsonWithActiveGroup);
});
it('serializes to dataless JSON with toDatalessJSON', () => {
const path = new Path('M 100 100 L 300 100 L 200 300 z', {
sourcePath: 'http://example.com/',
});
canvas.add(path);
expect(canvas.toDatalessJSON()).toEqual(PATH_DATALESS_JSON);
});
it('converts to object with toObject', () => {
expect(canvas.toObject).toBeTypeOf('function');
const expectedObject = {
version: version,
objects: canvas.getObjects(),
};
expect(canvas.toObject()).toEqual(expectedObject);
const rect = makeRect();
canvas.add(rect);
// @ts-expect-error -- constructor function has type
expect(canvas.toObject().objects[0].type).toBe(rect.constructor.type);
});
it('includes clipPath in toObject when present', () => {
const clipPath = makeRect();
const canvasWithClipPath = new Canvas(undefined, { clipPath: clipPath });
const expectedObject = {
version: version,
objects: canvasWithClipPath.getObjects(),
clipPath: {
type: 'Rect',
version: version,
originX: 'center',
originY: 'center',
left: 0,
top: 0,
width: 10,
height: 10,
fill: 'rgb(0,0,0)',
stroke: null,
strokeWidth: 1,
strokeDashArray: null,
strokeLineCap: 'butt',
strokeDashOffset: 0,
strokeLineJoin: 'miter',
strokeMiterLimit: 4,
scaleX: 1,
scaleY: 1,
angle: 0,
flipX: false,
flipY: false,
opacity: 1,
shadow: null,
visible: true,
backgroundColor: '',
fillRule: 'nonzero',
paintFirst: 'fill',
globalCompositeOperation: 'source-over',
skewX: 0,
skewY: 0,
rx: 0,
ry: 0,
strokeUniform: false,
},
};
expect(canvasWithClipPath.toObject).toBeTypeOf('function');
expect(canvasWithClipPath.toObject()).toEqual(expectedObject);
const rect = makeRect();
canvasWithClipPath.add(rect);
expect(canvasWithClipPath.toObject().objects[0].type).toBe(
// @ts-expect-error -- constructor function has type
rect.constructor.type,
);
});
it('converts to dataless object with toDatalessObject', () => {
expect(canvas.toDatalessObject).toBeTypeOf('function');
const expectedObject = {
version: version,
objects: canvas.getObjects(),
};
expect(canvas.toDatalessObject()).toEqual(expectedObject);
const rect = makeRect();
canvas.add(rect);
// @ts-expect-error -- constructor function has type
expect(canvas.toObject().objects[0].type).toBe(rect.constructor.type);
// TODO (kangax): need to test this method with fabric.Path to ensure that path is not populated
});
it('checks if canvas is empty with isEmpty', () => {
expect(canvas.isEmpty).toBeTypeOf('function');
expect(canvas.isEmpty()).toBeTruthy();
canvas.add(makeRect());
expect(canvas.isEmpty()).toBeFalsy();
});
it('loads from JSON string with loadFromJSON', async () => {
expect(canvas.loadFromJSON).toBeTypeOf('function');
await canvas.loadFromJSON(PATH_JSON);
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a path object').toBe('Path');
expect(
canvas.backgroundColor,
'backgroundColor is populated properly',
).toBe('#ff5555');
expect(canvas.overlayColor, 'overlayColor is populated properly').toBe(
'rgba(0,0,0,0.2)',
);
expect(obj.get('left')).toBe(268);
expect(obj.get('top')).toBe(266);
expect(obj.get('width')).toBe(49.803999999999995);
expect(obj.get('height')).toBe(48.027);
expect(obj.get('fill')).toBe('rgb(0,0,0)');
expect(obj.get('stroke')).toBe(null);
expect(obj.get('strokeWidth')).toBe(1);
expect(obj.get('scaleX')).toBe(1);
expect(obj.get('scaleY')).toBe(1);
expect(obj.get('angle')).toBe(0);
expect(obj.get('flipX')).toBe(false);
expect(obj.get('flipY')).toBe(false);
expect(obj.get('opacity')).toBe(1);
expect(obj.get('path').length > 0).toBeTruthy();
});
it('loads from JSON object with loadFromJSON', async () => {
await canvas.loadFromJSON(PATH_JSON);
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a path object').toBe('Path');
expect(
canvas.backgroundColor,
'backgroundColor is populated properly',
).toBe('#ff5555');
expect(canvas.overlayColor, 'overlayColor is populated properly').toBe(
'rgba(0,0,0,0.2)',
);
expect(obj.get('left')).toBe(268);
expect(obj.get('top')).toBe(266);
expect(obj.get('width')).toBe(49.803999999999995);
expect(obj.get('height')).toBe(48.027);
expect(obj.get('fill')).toBe('rgb(0,0,0)');
expect(obj.get('stroke')).toBe(null);
expect(obj.get('strokeWidth')).toBe(1);
expect(obj.get('scaleX')).toBe(1);
expect(obj.get('scaleY')).toBe(1);
expect(obj.get('angle')).toBe(0);
expect(obj.get('flipX')).toBe(false);
expect(obj.get('flipY')).toBe(false);
expect(obj.get('opacity')).toBe(1);
expect(obj.get('path').length > 0).toBeTruthy();
});
it('loads from JSON string with loadFromJSON with images not existing', async () => {
expect(canvas.loadFromJSON).toBeTypeOf('function');
await canvas.loadFromJSON(ERROR_IMAGE_JSON);
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
expect(canvas.getObjects().length).toBe(1);
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a Image object').toBe('Text');
});
it('loads from JSON string with loadFromJSON with images not existing passing reviver', async () => {
expect(canvas.loadFromJSON).toBeTypeOf('function');
await canvas.loadFromJSON(
ERROR_IMAGE_JSON,
async (serializedObject, instance, error) => {
if (error) {
return new FabricText('text-placeholder');
}
},
);
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
expect(canvas.getObjects().length).toBe(2);
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a Image object').toBe('Text');
expect(obj).toBeInstanceOf(FabricText);
expect((obj as FabricText).text).toBe('text-placeholder');
});
it('loads from JSON object without default values', async () => {
await canvas.loadFromJSON(PATH_WITHOUT_DEFAULTS_JSON);
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a path object').toBe('Path');
expect(
canvas.backgroundColor,
'backgroundColor is populated properly',
).toBe('#ff5555');
expect(canvas.overlayColor, 'overlayColor is populated properly').toBe(
'rgba(0,0,0,0.2)',
);
expect(obj.get('originX')).toBe('center');
expect(obj.get('originY')).toBe('center');
expect(obj.get('left')).toBe(268);
expect(obj.get('top')).toBe(266);
expect(obj.get('width')).toBe(49.803999999999995);
expect(obj.get('height')).toBe(48.027);
expect(obj.get('fill')).toBe('rgb(0,0,0)');
expect(obj.get('stroke')).toBe(null);
expect(obj.get('strokeWidth')).toBe(1);
expect(obj.get('scaleX')).toBe(1);
expect(obj.get('scaleY')).toBe(1);
expect(obj.get('angle')).toBe(0);
expect(obj.get('flipX')).toBe(false);
expect(obj.get('flipY')).toBe(false);
expect(obj.get('opacity')).toBe(1);
expect(obj.get('path').length > 0).toBeTruthy();
});
it('loads from JSON with reviver function', async () => {
await canvas.loadFromJSON(PATH_JSON, function (obj, instance) {
expect(obj).toEqual(PATH_OBJ_JSON);
// @ts-expect-error -- constructor function has type
if (instance.constructor.type === 'Path') {
// @ts-expect-error -- custom prop
instance.customID = 'fabric_1';
}
});
const obj = canvas.item(0);
expect(canvas.isEmpty(), 'canvas is not empty').toBeFalsy();
// @ts-expect-error -- constructor function has type
expect(obj.constructor.type, 'first object is a path object').toBe('Path');
expect(
canvas.backgroundColor,
'backgroundColor is populated properly',
).toBe('#ff5555');
expect(canvas.overlayColor, 'overlayColor is populated properly').toBe(
'rgba(0,0,0,0.2)',
);
expect(obj.get('left')).toBe(268);
expect(obj.get('top')).toBe(266);
expect(obj.get('width')).toBe(49.803999999999995);
expect(obj.get('height')).toBe(48.027);
expect(obj.get('fill')).toBe('rgb(0,0,0)');
expect(obj.get('stroke')).toBe(null);
expect(obj.get('strokeWidth')).toBe(1);
expect(obj.get('scaleX')).toBe(1);
expect(obj.get('scaleY')).toBe(1);
expect(obj.get('angle')).toBe(0);
expect(obj.get('flipX')).toBe(false);
expect(obj.get('flipY')).toBe(false);
expect(obj.get('opacity')).toBe(1);
expect(obj.get('customID')).toBe('fabric_1');
expect(obj.get('path').length > 0).toBeTruthy();
});
it('loads from JSON with no objects', async () => {
const canvas1 = getFabricDocument().createElement('canvas');
const canvas2 = getFabricDocument().createElement('canvas');
const c1 = new Canvas(canvas1, {
backgroundColor: 'green',
overlayColor: 'yellow',
});
const c2 = new Canvas(canvas2, {
backgroundColor: 'red',
overlayColor: 'orange',
});
const json = c1.toJSON();
let fired = false;
await c2.loadFromJSON(json).then(() => {
fired = true;
});
expect(fired, 'Callback should be fired even if no objects').toBeTruthy();
expect(c2.backgroundColor, 'Color should be set properly').toBe('green');
expect(c2.overlayColor, 'Color should be set properly').toBe('yellow');
});
it('loads from JSON without "objects" property', async () => {
const canvas1 = getFabricDocument().createElement('canvas');
const canvas2 = getFabricDocument().createElement('canvas');
const c1 = new Canvas(canvas1, {
backgroundColor: 'green',
overlayColor: 'yellow',
});
const c2 = new Canvas(canvas2, {
backgroundColor: 'red',
overlayColor: 'orange',
});
const json = c1.toJSON();
let fired = false;
delete json.objects;
await c2.loadFromJSON(json).then(() => {
fired = true;
});
expect(
fired,
'Callback should be fired even if no "objects" property exists',
).toBeTruthy();
expect(c2.backgroundColor, 'Color should be set properly').toBe('green');
expect(c2.overlayColor, 'Color should be set properly').toBe('yellow');
});
it('loads from JSON with empty Group', async () => {
const canvas1 = getFabricDocument().createElement('canvas');
const canvas2 = getFabricDocument().createElement('canvas');
const c1 = new Canvas(canvas1);
const c2 = new Canvas(canvas2);
const group = new Group();
c1.add(group);
expect(c1.isEmpty(), 'canvas is not empty').toBeFalsy();
const json = c1.toJSON();
let fired = false;
await c2.loadFromJSON(json).then(() => {
fired = true;
});
expect(
fired,
'Callback should be fired even if