UNPKG

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
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