UNPKG

fabric

Version:

Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.

1,612 lines (1,447 loc) 55.1 kB
import { FixedLayout, LayoutManager, ClipPathLayout, FitContentLayout, } from '../LayoutManager'; import { Canvas } from '../canvas/Canvas'; import { Group } from './Group'; import type { GroupProps } from './Group'; import { Rect } from './Rect'; import { FabricObject } from './Object/FabricObject'; import { FabricImage } from './Image'; import { SignalAbortedError } from '../util/internals/console'; import { describe, expect, it, test, vi, afterEach } from 'vitest'; import { StaticCanvas } from '../canvas/StaticCanvas'; import { FabricText, Point, version } from '../../fabric'; import { isTransparent } from '../util'; const makeGenericGroup = (options?: Partial<GroupProps>) => { const objs = [new FabricObject(), new FabricObject()]; const group = new Group(objs, options); return { group, originalObjs: objs, }; }; function makeGroupWith2Objects() { const rect1 = new Rect({ top: 105, left: 115, width: 30, height: 10, strokeWidth: 0, }), rect2 = new Rect({ top: 140, left: 55, width: 10, height: 40, strokeWidth: 0, }); return new Group([rect1, rect2], { strokeWidth: 0 }); } function makeGroupWith2ObjectsWithOpacity() { const g = makeGroupWith2Objects(); const objs = g.getObjects(); objs[0].opacity = 0.5; objs[1].opacity = 0.8; return g; } function makeGroupWith2ObjectsAndNoExport() { const g = makeGroupWith2Objects(); const objs = g.getObjects(); objs[1].excludeFromExport = true; return g; } function makeGroupWith4Objects() { const rect1 = new Rect({ top: 105, left: 115, width: 30, height: 10 }), rect2 = new Rect({ top: 140, left: 55, width: 10, height: 40 }), rect3 = new Rect({ top: 60, left: 10, width: 20, height: 40 }), rect4 = new Rect({ top: 95, left: 95, width: 40, height: 40 }); return new Group([rect1, rect2, rect3, rect4]); } describe('Group', () => { it('avoid mutations to passed objects array', () => { const { group, originalObjs } = makeGenericGroup(); group.add(new FabricObject()); expect(group._objects).not.toBe(originalObjs); expect(originalObjs).toHaveLength(2); expect(group._objects).toHaveLength(3); }); it('fromObject restores values as they are, ignoring specific width/height/top/left that could come from layout', async () => { const objectData = { width: 2, height: 3, left: 7, top: 5.5, strokeWidth: 0, objects: [ new Rect({ width: 100, height: 100, top: 50, left: 50, strokeWidth: 0, }).toObject(), ], }; const group = await Group.fromObject(objectData); expect(group.width).toBe(objectData.width); expect(group.height).toBe(objectData.height); expect(group.left).toBe(objectData.left); expect(group.top).toBe(objectData.top); group.triggerLayout(); expect(group.width).toBe(100); expect(group.height).toBe(100); }); it('fromObject with images', async () => { const objs = [ new FabricObject(), new FabricObject(), new FabricImage(new Image()), ]; const group = new Group(objs); const jsonData = group.toObject(); const abortController = new AbortController(); abortController.abort(); return Group.fromObject(jsonData, { signal: abortController.signal, }).catch((e) => { expect(e instanceof SignalAbortedError).toBe(true); expect(e.message).toBe( `fabric: loadImage 'options.signal' is in 'aborted' state`, ); }); }); describe('With fit-content layout manager', () => { test('will serialize correctly without default values', async () => { const { group } = makeGenericGroup({ clipPath: new Rect({ width: 30, height: 30 }), layoutManager: new LayoutManager(new FitContentLayout()), includeDefaultValues: false, }); const serialized = group.toObject(); expect(serialized.layoutManager).toBe(undefined); }); it('Group initialization will calculate correct width/height ignoring passed width and height', async () => { const objectOptions = { width: 2, height: 3, left: 6, top: 4, strokeWidth: 0, }; const group = new Group( [ new Rect({ width: 100, height: 100, top: 0, left: 0, strokeWidth: 0, }), ], objectOptions, ); expect(group.width).toBe(100); expect(group.height).toBe(100); }); it('Group initialization will calculate width/height ignoring the passed one', async () => { const objectOptions = { width: 2, height: 3, left: 6, top: 4, strokeWidth: 0, }; const group = new Group( [ new Rect({ width: 100, height: 100, top: 0, left: 0, strokeWidth: 0, }), ], objectOptions, ); expect(group.left).toBe(6); expect(group.top).toBe(4); }); it('Group initialization will calculate size and position if nothing is passed', async () => { const objectOptions = { strokeWidth: 0, }; const group = new Group( [ new Rect({ width: 100, height: 100, top: 50, left: 60, strokeWidth: 0, }), ], objectOptions, ); expect(group.left).toBe(60); expect(group.top).toBe(50); expect(group.width).toBe(100); expect(group.height).toBe(100); }); test('fit-content layout will change position or size', async () => { const { group } = makeGenericGroup({ top: 30, left: 10, width: 40, height: 50, }); expect(group.top).toBe(30); expect(group.left).toBe(10); expect(group.width).toBe(1); expect(group.height).toBe(1); group.add(new Rect({ width: 1000, height: 1500, top: -500, left: -400 })); // group position and size will not change expect(group.top).toBe(-500); expect(group.left).toBe(-400); expect(group.width).toBe(1001); expect(group.height).toBe(1501); }); }); describe('With fixed layout', () => { test('will serialize and deserialize correctly', async () => { const { group } = makeGenericGroup({ width: 40, height: 50, layoutManager: new LayoutManager(new FixedLayout()), }); const serialized = group.toObject(); expect(serialized.layoutManager).toMatchObject({ type: 'layoutManager', strategy: 'fixed', }); const restoredGroup = await Group.fromObject(serialized); expect(restoredGroup.layoutManager).toBeInstanceOf(LayoutManager); expect(restoredGroup.layoutManager.strategy).toBeInstanceOf(FixedLayout); }); test('will serialize correctly without default values', async () => { const { group } = makeGenericGroup({ width: 40, height: 50, layoutManager: new LayoutManager(new FixedLayout()), includeDefaultValues: false, }); const serialized = group.toObject(); expect(serialized.layoutManager).toMatchObject({ type: 'layoutManager', strategy: 'fixed', }); }); test('Fixed layout will not change position or size', async () => { const { group } = makeGenericGroup({ top: 30, left: 10, width: 40, height: 50, layoutManager: new LayoutManager(new FixedLayout()), }); expect(group.top).toBe(30); expect(group.left).toBe(10); expect(group.width).toBe(40); expect(group.height).toBe(50); group.add(new Rect({ width: 1000, height: 1000, top: -500, left: -500 })); // group position and size will not change expect(group.top).toBe(30); expect(group.left).toBe(10); expect(group.width).toBe(40); expect(group.height).toBe(50); }); }); describe('With clip-path layout', () => { test('will serialize and deserialize correctly', async () => { const { group } = makeGenericGroup({ clipPath: new Rect({ width: 30, height: 30 }), layoutManager: new LayoutManager(new ClipPathLayout()), }); const serialized = group.toObject(); expect(serialized.layoutManager).toMatchObject({ type: 'layoutManager', strategy: 'clip-path', }); const restoredGroup = await Group.fromObject(serialized); expect(restoredGroup.layoutManager).toBeInstanceOf(LayoutManager); expect(restoredGroup.layoutManager.strategy).toBeInstanceOf( ClipPathLayout, ); }); test('will serialize correctly without default values', async () => { const { group } = makeGenericGroup({ clipPath: new Rect({ width: 30, height: 30 }), layoutManager: new LayoutManager(new ClipPathLayout()), includeDefaultValues: false, }); const serialized = group.toObject(); expect(serialized.layoutManager).toMatchObject({ type: 'layoutManager', strategy: 'clip-path', }); }); test('clip-path layout will not change position or size', async () => { const { group } = makeGenericGroup({ top: 20, left: 40, clipPath: new Rect({ width: 30, height: 10 }), layoutManager: new LayoutManager(new ClipPathLayout()), }); expect(group.top).toBe(20); expect(group.left).toBe(40); // TODO BUG: this should be 30 expect(group.width).toBe(31); expect(group.height).toBe(11); group.add(new Rect({ width: 1000, height: 1000, top: -500, left: -500 })); // group position and size will not change expect(group.top).toBe(20); expect(group.left).toBe(40); // TODO BUG: this should be 30 expect(group.width).toBe(31); expect(group.height).toBe(11); }); }); it('triggerLayout should preform layout, layoutManager is defined', () => { const group = new Group(); expect(group.layoutManager).toBeDefined(); const performLayout = vi.spyOn(group.layoutManager, 'performLayout'); group.triggerLayout(); const fixedLayout = new FixedLayout(); group.triggerLayout({ strategy: fixedLayout }); expect(performLayout).toHaveBeenCalledTimes(2); expect(performLayout).toHaveBeenNthCalledWith(1, { target: group, type: 'imperative', }); expect(performLayout).toHaveBeenNthCalledWith(2, { strategy: fixedLayout, target: group, type: 'imperative', }); }); test('adding and removing an object', () => { const object = new FabricObject(); const group = new Group([object]); const group2 = new Group(); const canvas = new Canvas(); const eventsSpy = vi.spyOn(object, 'fire'); const removeSpy = vi.spyOn(group, 'remove'); const exitSpy = vi.spyOn(group, 'exitGroup'); const enterSpy = vi.spyOn(group2, 'enterGroup'); expect(object.group).toBe(group); expect(object.parent).toBe(group); expect(object.canvas).toBeUndefined(); canvas.add(group, group2); expect(object.canvas).toBe(canvas); group2.add(object); expect(object.group).toBe(group2); expect(object.parent).toBe(group2); expect(object.canvas).toBe(canvas); expect(removeSpy).toBeCalledWith(object); expect(exitSpy).toBeCalledWith(object, undefined); expect(enterSpy).toBeCalledWith(object, true); expect(eventsSpy).toHaveBeenNthCalledWith(1, 'removed', { target: group }); expect(eventsSpy).toHaveBeenNthCalledWith(2, 'added', { target: group2 }); group2.remove(object); expect(eventsSpy).toHaveBeenNthCalledWith(3, 'removed', { target: group2 }); expect(object.group).toBeUndefined(); expect(object.parent).toBeUndefined(); expect(object.canvas).toBeUndefined(); expect(eventsSpy).toBeCalledTimes(3); }); const canvas = new StaticCanvas(undefined, { enableRetinaScaling: false, width: 600, height: 600, }); afterEach(() => { canvas.clear(); canvas.backgroundColor = Canvas.getDefaults().backgroundColor; canvas.calcOffset(); }); it('constructor', () => { const group = makeGroupWith2Objects(); expect(group).toBeTruthy(); expect(group, 'should be instance of Group').toBeInstanceOf(Group); }); it('toString', () => { const group = makeGroupWith2Objects(); expect(group.toString(), 'should return proper representation').toBe( '#<Group: (2)>', ); }); it('getObjects', () => { const rect1 = new Rect(), rect2 = new Rect(); const group = new Group([rect1, rect2]); expect(group.getObjects).toBeTypeOf('function'); expect( Array.isArray(group.getObjects()), 'should be an array', ).toBeTruthy(); expect(group.getObjects().length, 'should have 2 items').toBe(2); expect( group.getObjects(), 'should return deepEqual objects as those passed to constructor', ).toEqual([rect1, rect2]); }); it('add', () => { const group = makeGroupWith2Objects(); const rect1 = new Rect(), rect2 = new Rect(), rect3 = new Rect(); expect(group.add).toBeTypeOf('function'); group.add(rect1); expect( group.item(group.size() - 1), 'last object should be newly added one', ).toBe(rect1); expect(group.getObjects().length, 'there should be 3 objects').toBe(3); group.add(rect2, rect3); expect( group.item(group.size() - 1), 'last object should be last added one', ).toBe(rect3); expect(group.size(), 'there should be 5 objects').toBe(5); }); it('remove', () => { const rect1 = new Rect(), rect2 = new Rect(), rect3 = new Rect(), group = new Group([rect1, rect2, rect3]); let fired = false; const targets: FabricObject[] = []; expect(group.remove).toBeTypeOf('function'); expect(rect1.group, 'group should be referenced').toBe(group); expect(rect1.parent, 'parent should be referenced').toBe(group); group.on('object:removed', (opt) => { targets.push(opt.target); }); rect1.on('removed', (opt) => { expect(opt.target, 'group should not be referenced').toBe(group); expect(rect1.group, 'group should not be referenced').toBe(undefined); expect(rect1.parent, 'parent should not be referenced').toBe(undefined); fired = true; }); const removed = group.remove(rect2); expect(removed, 'should return removed objects').toEqual([rect2]); expect(group.getObjects(), 'should remove object properly').toEqual([ rect1, rect3, ]); const removed2 = group.remove(rect1, rect3); expect(removed2, 'should return removed objects').toEqual([rect1, rect3]); expect(group.isEmpty(), 'group should be empty').toBe(true); expect(fired, 'should have fired removed event on rect1').toBeTruthy(); expect(targets, 'should contain removed objects').toEqual([ rect2, rect1, rect3, ]); }); it('size', () => { const group = makeGroupWith2Objects(); expect(group.size).toBeTypeOf('function'); expect(group.size()).toBe(2); group.add(new Rect()); expect(group.size()).toBe(3); group.remove(group.getObjects()[0]); group.remove(group.getObjects()[0]); expect(group.size()).toBe(1); }); it('set', () => { const group = makeGroupWith2Objects(), firstObject = group.getObjects()[0]; expect(group.set).toBeTypeOf('function'); group.set('opacity', 0.12345); expect( group.get('opacity'), 'group\'s "own" property should be set properly', ).toBe(0.12345); expect( firstObject.get('opacity'), "objects' value of non delegated property should stay same", ).toBe(1); group.set('left', 1234); expect( group.get('left'), 'group\'s own "left" property should be set properly', ).toBe(1234); expect( firstObject.get('left') !== 1234, "objects' value should not be affected", ).toBeTruthy(); group.set({ left: 888, top: 999 }); expect( group.get('left'), 'group\'s own "left" property should be set properly via object', ).toBe(888); expect( group.get('top'), 'group\'s own "top" property should be set properly via object', ).toBe(999); }); it('contains', () => { const rect1 = new Rect(), rect2 = new Rect(), notIncludedRect = new Rect(), group = new Group([rect1, rect2]); expect(group.contains).toBeTypeOf('function'); expect(group.contains(rect1), 'should contain first object').toBeTruthy(); expect(group.contains(rect2), 'should contain second object').toBeTruthy(); expect( group.contains(notIncludedRect), 'should report not-included one properly', ).toBeFalsy(); }); it('toObject', () => { const group = makeGroupWith2Objects(); expect(group.toObject).toBeTypeOf('function'); const clone = group.toObject(); const expectedObject = { version: version, type: 'Group', originX: 'center', originY: 'center', left: 90, top: 130, width: 80, height: 60, fill: 'rgb(0,0,0)', // layout: 'fit-content', stroke: null, strokeWidth: 0, strokeDashArray: null, strokeLineCap: 'butt', strokeDashOffset: 0, strokeLineJoin: 'miter', strokeMiterLimit: 4, scaleX: 1, scaleY: 1, shadow: null, visible: true, backgroundColor: '', angle: 0, flipX: false, flipY: false, opacity: 1, fillRule: 'nonzero', paintFirst: 'fill', globalCompositeOperation: 'source-over', skewX: 0, skewY: 0, objects: clone.objects, strokeUniform: false, subTargetCheck: false, interactive: false, layoutManager: { type: 'layoutManager', strategy: 'fit-content', }, }; expect(clone).toEqual(expectedObject); expect(group, 'should produce different object').not.toBe(clone); expect( group.getObjects() !== clone.objects, 'should produce different object array', ).toBeTruthy(); expect( group.getObjects()[0] !== clone.objects[0], 'should produce different objects in array', ).toBeTruthy(); }); it('toObject without default values', () => { const group = makeGroupWith2Objects(); group.includeDefaultValues = false; const clone = group.toObject(); const objects = [ { version: version, type: 'Rect', left: 25, top: -25, width: 30, height: 10, strokeWidth: 0, }, { version: version, type: 'Rect', left: -35, top: 10, width: 10, height: 40, strokeWidth: 0, }, ]; const expectedObject = { version: version, type: 'Group', left: 90, top: 130, width: 80, height: 60, objects: objects, }; expect(clone).toEqual(expectedObject); }); it('toObject with excludeFromExport set on an object', () => { const group = makeGroupWith2Objects(); const group2 = makeGroupWith2ObjectsAndNoExport(); const clone = group.toObject(); const clone2 = group2.toObject(); expect(clone2.objects).toEqual( group2._objects .filter((obj) => !obj.excludeFromExport) .map((obj) => obj.toObject()), ); // @ts-expect-error -- deleting intentionally delete clone.objects; // @ts-expect-error -- deleting intentionally delete clone2.objects; expect(clone).toEqual(clone2); }); it('render', () => { const group = makeGroupWith2Objects(); expect(group.render).toBeTypeOf('function'); }); it('item', () => { const group = makeGroupWith2Objects(); expect(group.item).toBeTypeOf('function'); expect(group.item(0)).toBe(group.getObjects()[0]); expect(group.item(1)).toBe(group.getObjects()[1]); expect(group.item(9999)).toBe(undefined); }); it('moveObjectTo', () => { const group = makeGroupWith4Objects(), groupEl1 = group.getObjects()[0], groupEl2 = group.getObjects()[1], groupEl3 = group.getObjects()[2], groupEl4 = group.getObjects()[3]; // [ 1, 2, 3, 4 ] expect(group.item(0)).toBe(groupEl1); expect(group.item(1)).toBe(groupEl2); expect(group.item(2)).toBe(groupEl3); expect(group.item(3)).toBe(groupEl4); expect(group.item(9999)).toBe(undefined); group.moveObjectTo(group.item(0), 3); // moved 1 to level 3 — [2, 3, 4, 1] expect(group.item(3)).toBe(groupEl1); expect(group.item(0)).toBe(groupEl2); expect(group.item(1)).toBe(groupEl3); expect(group.item(2)).toBe(groupEl4); expect(group.item(9999)).toBe(undefined); group.moveObjectTo(group.item(0), 2); // moved 2 to level 2 — [3, 4, 2, 1] expect(group.item(3)).toBe(groupEl1); expect(group.item(2)).toBe(groupEl2); expect(group.item(0)).toBe(groupEl3); expect(group.item(1)).toBe(groupEl4); expect(group.item(9999)).toBe(undefined); }); it('complexity', () => { const group = makeGroupWith2Objects(); expect(group.complexity).toBeTypeOf('function'); expect(group.complexity()).toBe(2); }); it('removeAll', () => { const group = makeGroupWith2Objects(), firstObject = group.item(0), initialLeftValue = 115, initialTopValue = 105; expect(initialLeftValue !== firstObject.get('left')).toBeTruthy(); expect(initialTopValue !== firstObject.get('top')).toBeTruthy(); const objects = group.getObjects(); expect(group.removeAll(), 'should remove all objects').toEqual(objects); expect(firstObject.get('left'), 'should restore initial left value').toBe( initialLeftValue, ); expect(firstObject.get('top'), 'should restore initial top value').toBe( initialTopValue, ); }); it('containsPoint', () => { const group = makeGroupWith2Objects(); group.set({ originX: 'center', originY: 'center' }).setCoords(); // Rect #1 top: 100, left: 100, width: 30, height: 10 // Rect #2 top: 120, left: 50, width: 10, height: 40 expect(group.containsPoint).toBeTypeOf('function'); expect(group.containsPoint(new Point(0, 0))).toBeFalsy(); group.scale(2); expect(group.containsPoint(new Point(50, 120))).toBeTruthy(); expect(group.containsPoint(new Point(100, 160))).toBeTruthy(); expect(group.containsPoint(new Point(0, 0))).toBeFalsy(); group.scale(1); group.padding = 30; group.setCoords(); expect(group.containsPoint(new Point(50, 120))).toBeTruthy(); expect(group.containsPoint(new Point(100, 170))).toBeFalsy(); expect(group.containsPoint(new Point(0, 0))).toBeFalsy(); }); it('forEachObject', () => { const group = makeGroupWith2Objects(); expect(group.forEachObject).toBeTypeOf('function'); const iteratedObjects: FabricObject[] = []; group.forEachObject(function (groupObject) { iteratedObjects.push(groupObject); }); expect( iteratedObjects[0], 'iteration give back objects in same order', ).toBe(group.getObjects()[0]); expect( iteratedObjects[1], 'iteration give back objects in same order', ).toBe(group.getObjects()[1]); }); it('fromObject', async () => { const group = makeGroupWith2ObjectsWithOpacity(); expect(Group.fromObject).toBeTypeOf('function'); const groupObject = group.toObject(); const newGroupFromObject = await Group.fromObject(groupObject); const objectFromOldGroup = group.toObject(); const objectFromNewGroup = newGroupFromObject.toObject(); expect(newGroupFromObject).toBeInstanceOf(Group); expect(objectFromOldGroup.objects[0]).toEqual( objectFromNewGroup.objects[0], ); expect(objectFromOldGroup.objects[1]).toEqual( objectFromNewGroup.objects[1], ); expect(objectFromOldGroup).toEqual(objectFromNewGroup); }); it('fromObject with clipPath', async () => { const clipPath = new Rect({ width: 500, height: 250, top: 0, left: 0, absolutePositioned: true, }); const groupObject = new Group([ new Rect({ width: 100, height: 100, fill: 'red' }), new Rect({ width: 100, height: 100, fill: 'yellow', left: 100 }), new Rect({ width: 100, height: 100, fill: 'blue', top: 100 }), new Rect({ width: 100, height: 100, fill: 'green', left: 100, top: 100 }), ]); groupObject.clipPath = clipPath; const groupToObject = groupObject.toObject(); const newGroupFromObject = await Group.fromObject(groupToObject); const objectFromNewGroup = newGroupFromObject.toObject(); expect(newGroupFromObject).toBeInstanceOf(Group); expect( newGroupFromObject.clipPath, 'clipPath has been restored', ).toBeInstanceOf(Rect); expect( objectFromNewGroup, 'double serialization gives same results', ).toEqual(groupToObject); }); it('fromObject restores aCoords', async () => { const group = makeGroupWith2ObjectsWithOpacity(); const groupObject = group.toObject(); groupObject.subTargetCheck = true; const newGroupFromObject = await Group.fromObject(groupObject); expect( newGroupFromObject._objects[0].aCoords.tl, 'acoords 0 are restored', ).toBeTruthy(); expect( newGroupFromObject._objects[1].aCoords.tl, 'acoords 1 are restored', ).toBeTruthy(); }); it('fromObject does not delete objects from source', async () => { const group = makeGroupWith2ObjectsWithOpacity(); const groupObject = group.toObject(); const newGroupFromObject = await Group.fromObject(groupObject); expect( // @ts-expect-error -- objects is not typed as part of the group instance newGroupFromObject.objects, 'the objects array has not been pulled in', ).toBe(undefined); expect( groupObject.objects, 'the objects array has not been deleted from object source', ).not.toBe(undefined); }); it('toSVG', () => { const group = makeGroupWith2Objects(); expect(group.toSVG).toBeTypeOf('function'); const expectedSVG = '<g transform="matrix(1 0 0 1 90 130)" >\n<g style="" >\n\t\t<g transform="matrix(1 0 0 1 25 -25)" >\n<rect style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" x="-15" y="-5" rx="0" ry="0" width="30" height="10" />\n</g>\n\t\t<g transform="matrix(1 0 0 1 -35 10)" >\n<rect style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" x="-5" y="-20" rx="0" ry="0" width="10" height="40" />\n</g>\n</g>\n</g>\n'; expect(group.toSVG()).toBe(expectedSVG); }); it('toSVG with a clipPath', () => { const group = makeGroupWith2Objects(); group.clipPath = new Rect({ width: 100, height: 100 }); expect(group.toSVG()).toMatchSVGSnapshot(); }); it('toSVG with a clipPath absolutePositioned', () => { const group = makeGroupWith2Objects(); group.clipPath = new Rect({ width: 100, height: 100 }); group.clipPath.absolutePositioned = true; expect(group.toSVG()).toMatchSVGSnapshot(); }); it('toSVG with a group as a clipPath', () => { const group = makeGroupWith2Objects(); group.clipPath = makeGroupWith2Objects(); const expectedSVG = '<g transform="matrix(1 0 0 1 90 130)" clip-path="url(#CLIPPATH_0)" >\n<clipPath id="CLIPPATH_0" >\n\t\t<rect transform="matrix(1 0 0 1 115 105)" x="-15" y="-5" rx="0" ry="0" width="30" height="10" />\n\t\t<rect transform="matrix(1 0 0 1 55 140)" x="-5" y="-20" rx="0" ry="0" width="10" height="40" />\n</clipPath>\n<g style="" >\n\t\t<g transform="matrix(1 0 0 1 25 -25)" >\n<rect style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" x="-15" y="-5" rx="0" ry="0" width="30" height="10" />\n</g>\n\t\t<g transform="matrix(1 0 0 1 -35 10)" >\n<rect style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-dashoffset: 0; stroke-linejoin: miter; stroke-miterlimit: 4; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" x="-5" y="-20" rx="0" ry="0" width="10" height="40" />\n</g>\n</g>\n</g>\n'; expect(group.toSVG()).toEqualSVG(expectedSVG); }); it('cloning group with 2 objects', async () => { const group = makeGroupWith2Objects(); const clone = await group.clone(); expect(clone, 'should be different instance').not.toBe(group); expect(clone.toObject(), 'should have same properties').toEqual( group.toObject(), ); }); it('get with locked objects', () => { const group = makeGroupWith2Objects(); expect(group.get('lockMovementX')).toBe(false); // TODO acitveGroup // group.getObjects()[0].lockMovementX = true; // expect(group.get('lockMovementX')).toBe(true); // // group.getObjects()[0].lockMovementX = false; // expect(group.get('lockMovementX')).toBe(false); group.set('lockMovementX', true); expect(group.get('lockMovementX')).toBe(true); // group.set('lockMovementX', false); // group.getObjects()[0].lockMovementY = true; // group.getObjects()[1].lockRotation = true; // // expect(group.get('lockMovementY')).toBe(true); // expect(group.get('lockRotation')).toBe(true); }); it('object stacking methods with group objects', () => { const textBg = new Rect({ fill: '#abc', width: 100, height: 100, }); const text = new FabricText('text'); const obj = new FabricObject(); const group = new Group([textBg, text, obj]); expect(group.sendObjectToBack).toBeTypeOf('function'); expect(group.bringObjectToFront).toBeTypeOf('function'); expect(group.sendObjectBackwards).toBeTypeOf('function'); expect(group.bringObjectForward).toBeTypeOf('function'); expect(group.moveObjectTo).toBeTypeOf('function'); canvas.add(group); expect(group.getObjects()).toEqual([textBg, text, obj]); group.dirty = false; group.bringObjectToFront(textBg); expect(group.getObjects()).toEqual([text, obj, textBg]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.sendObjectToBack(textBg); expect(group.getObjects()).toEqual([textBg, text, obj]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.bringObjectToFront(textBg); expect(group.getObjects()).toEqual([text, obj, textBg]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.bringObjectToFront(textBg); expect(group.getObjects(), 'has no effect').toEqual([text, obj, textBg]); expect(group.dirty === false, 'should not invalidate group').toBeTruthy(); group.dirty = false; group.sendObjectToBack(textBg); expect(group.getObjects()).toEqual([textBg, text, obj]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.sendObjectToBack(textBg); expect(group.getObjects(), 'has no effect').toEqual([textBg, text, obj]); expect(group.dirty === false, 'should not invalidate group').toBeTruthy(); group.dirty = false; group.sendObjectBackwards(obj); expect(group.getObjects()).toEqual([textBg, obj, text]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.bringObjectForward(text); expect(group.getObjects(), 'has no effect').toEqual([textBg, obj, text]); expect(group.dirty === false, 'should not invalidate group').toBeTruthy(); group.dirty = false; group.bringObjectForward(obj); expect(group.getObjects()).toEqual([textBg, text, obj]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.bringObjectForward(textBg); expect(group.getObjects()).toEqual([text, textBg, obj]); expect(group.dirty, 'should invalidate group').toBeTruthy(); group.dirty = false; group.moveObjectTo(obj, 2); expect(group.getObjects(), 'has no effect').toEqual([text, textBg, obj]); expect(group.dirty === false, 'should not invalidate group').toBeTruthy(); group.dirty = false; group.moveObjectTo(obj, 0); expect(group.getObjects()).toEqual([obj, text, textBg]); }); it('group reference on an object', () => { const group = makeGroupWith2Objects(); const firstObjInGroup = group.getObjects()[0]; const secondObjInGroup = group.getObjects()[1]; expect(firstObjInGroup.group).toBe(group); expect(secondObjInGroup.group).toBe(group); expect(firstObjInGroup.parent).toBe(group); expect(secondObjInGroup.parent).toBe(group); group.remove(firstObjInGroup); expect(typeof firstObjInGroup.group, 'group should be undefined').toBe( 'undefined', ); expect(typeof firstObjInGroup.parent, 'parent should be undefined').toBe( 'undefined', ); }); it('insertAt', () => { const rect1 = new Rect({ id: 1 }), rect2 = new Rect({ id: 2 }), rect3 = new Rect({ id: 3 }), rect4 = new Rect({ id: 4 }), rect5 = new Rect({ id: 5 }), rect6 = new Rect({ id: 6 }), rect7 = new Rect({ id: 7 }), rect8 = new Rect({ id: 8 }), group = new Group(), control: Rect[] = [], fired: Rect[] = [], firingControl: Rect[] = []; group.add(rect1, rect2); control.push(rect1, rect2); expect(group.insertAt, 'should respond to `insertAt` method').toBeTypeOf( 'function', ); function equalsControl(description: string) { expect( // @ts-expect-error -- id is not typed as part of the fabric object group.getObjects().map((o) => o.id), 'should equal control array ' + description, // @ts-expect-error -- id is not typed as part of the fabric object ).toEqual(control.map((o) => o.id)); expect( group.getObjects(), 'should equal control array ' + description, ).toEqual(control); expect( // @ts-expect-error -- id is not typed as part of the fabric object fired.map((o) => o.id), 'fired events should equal control array ' + description, // @ts-expect-error -- id is not typed as part of the fabric object ).toEqual(firingControl.map((o) => o.id)); expect( fired, 'fired events should equal control array ' + description, ).toEqual(firingControl); } expect( group._onObjectAdded, 'has a standard _onObjectAdded method', ).toBeTypeOf('function'); [rect1, rect2, rect3, rect4, rect5, rect6, rect7, rect8].forEach((obj) => { obj.on('added', (e) => { expect(e.target).toBe(group); fired.push(obj); }); }); group.insertAt(1, rect3); control.splice(1, 0, rect3); firingControl.push(rect3); equalsControl('rect3'); group.insertAt(0, rect4); control.splice(0, 0, rect4); firingControl.push(rect4); equalsControl('rect4'); group.insertAt(2, rect5); control.splice(2, 0, rect5); firingControl.push(rect5); equalsControl('rect5'); group.insertAt(2, rect6); control.splice(2, 0, rect6); firingControl.push(rect6); equalsControl('rect6'); group.insertAt(3, rect7, rect8); control.splice(3, 0, rect7, rect8); firingControl.push(rect7, rect8); equalsControl('rect7'); }); it('dirty flag propagation from children up', () => { const g1 = makeGroupWith4Objects(); const obj = g1.item(0); g1.dirty = false; obj.dirty = false; g1.ownCaching = true; expect(g1.dirty, 'Group has no dirty flag set').toBe(false); obj.set('fill', 'red'); expect(obj.dirty, 'Obj has dirty flag set').toBe(true); expect(g1.dirty, 'Group has dirty flag set').toBe(true); }); it('dirty flag propagation from children up does not happen if value does not change really', () => { const g1 = makeGroupWith4Objects(); const obj = g1.item(0); obj.fill = 'red'; g1.dirty = false; obj.dirty = false; g1.ownCaching = true; expect(obj.dirty, 'Obj has no dirty flag set').toBe(false); expect(g1.dirty, 'Group has no dirty flag set').toBe(false); obj.set('fill', 'red'); expect(obj.dirty, 'Obj has no dirty flag set').toBe(false); expect(g1.dirty, 'Group has no dirty flag set').toBe(false); }); it('dirty flag propagation from children up with', () => { const g1 = makeGroupWith4Objects(); const obj = g1.item(0); g1.dirty = false; obj.dirty = false; // specify that the group is caching or the test will fail under node since the // object caching is disabled by default g1.ownCaching = true; expect(g1.dirty, 'Group has no dirty flag set').toBe(false); obj.set('angle', 5); expect(obj.dirty, 'Obj has dirty flag still false').toBe(false); expect(g1.dirty, 'Group has dirty flag set').toBe(true); }); it('_getCacheCanvasDimensions returns dimensions and zoom for cache canvas are influenced by group', () => { const g1 = makeGroupWith4Objects(); const obj = g1.item(0); const dims = obj._getCacheCanvasDimensions(); g1.scaleX = 2; const dims2 = obj._getCacheCanvasDimensions(); expect( dims2.width - 2, 'width of cache has increased with group scale', ).toBe((dims.width - 2) * g1.scaleX); }); it('test group - pixels.', () => { const rect1 = new Rect({ top: 2, left: 2, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 6, left: 6, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1, rect2], { opacity: 1, fill: '', strokeWidth: 0, objectCaching: false, }), ctx = canvas.contextContainer; canvas.add(group); canvas.renderAll(); expect(canvas.enableRetinaScaling, 'enable retina scaling is off').toBe( false, ); expect(isTransparent(ctx, 0, 0, 0), '0,0 is transparent').toBe(true); expect(isTransparent(ctx, 1, 1, 0), '1,1 is opaque').toBe(false); expect(isTransparent(ctx, 2, 2, 0), '2,2 is opaque').toBe(false); expect(isTransparent(ctx, 3, 3, 0), '3,3 is transparent').toBe(true); expect(isTransparent(ctx, 4, 4, 0), '4,4 is transparent').toBe(true); expect(isTransparent(ctx, 5, 5, 0), '5,5 is opaque').toBe(false); expect(isTransparent(ctx, 6, 6, 0), '6,6 is opaque').toBe(false); expect(isTransparent(ctx, 7, 7, 0), '7,7 is transparent').toBe(true); }); it('group add', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1], { layoutManager: new LayoutManager() }); const coords = group.aCoords; group.add(rect2); const newCoords = group.aCoords; expect(coords, 'object coords have been recalculated - add').not.toBe( newCoords, ); }); it('group add edge cases', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1]); // duplicate expect(group.canEnterGroup(rect1)).toBeFalsy(); group.add(rect1); expect(group.getObjects()).toEqual([rect1]); // duplicate on same call expect(group.canEnterGroup(rect2)).toBeTruthy(); group.add(rect2, rect2); expect(group.getObjects()).toEqual([rect1, rect2]); // adding self expect(group.canEnterGroup(group)).toBeFalsy(); group.insertAt(0, group); expect(group.getObjects()).toEqual([rect1, rect2]); // nested object should be removed from group const nestedGroup = new Group([rect1]); expect(group.canEnterGroup(nestedGroup)).toBeTruthy(); group.add(nestedGroup); expect(group.getObjects()).toEqual([rect2, nestedGroup]); // circular group const circularGroup = new Group([group]); expect( group.canEnterGroup(circularGroup), 'circular group should be denied entry', ).toBeFalsy(); group.add(circularGroup); expect(group.getObjects(), 'objects should not have changed').toEqual([ rect2, nestedGroup, ]); }); it('group remove', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1, rect2], { layoutManager: new LayoutManager() }); const coords = group.aCoords; group.remove(rect2); const newCoords = group.aCoords; expect(coords, 'object coords have been recalculated - remove').not.toBe( newCoords, ); }); it('willDrawShadow', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect3 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect4 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1, rect2]), group2 = new Group([rect3, rect4]), group3 = new Group([group, group2]); expect( group3.willDrawShadow(), 'group will not cast shadow because objects do not have it', ).toBe(false); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 1, offsetY: 2 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself has shadow', ).toBe(true); // @ts-expect-error -- deleting intentionally delete group3.shadow; // @ts-expect-error -- partial shadow object group2.shadow = { offsetX: 1, offsetY: 2 }; expect( group3.willDrawShadow(), 'group will cast shadow because inner group2 has shadow', ).toBe(true); // @ts-expect-error -- deleting intentionally delete group2.shadow; // @ts-expect-error -- partial shadow object rect1.shadow = { offsetX: 1, offsetY: 2 }; expect( group3.willDrawShadow(), 'group will cast shadow because inner rect1 has shadow', ).toBe(true); expect( group.willDrawShadow(), 'group will cast shadow because inner rect1 has shadow', ).toBe(true); expect( group2.willDrawShadow(), 'group will not cast shadow because no child has shadow', ).toBe(false); }); it('willDrawShadow with no offsets', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect3 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), rect4 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false, }), group = new Group([rect1, rect2]), group2 = new Group([rect3, rect4]), group3 = new Group([group, group2]); expect( group3.willDrawShadow(), 'group will not cast shadow because objects do not have it', ).toBe(false); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 0, offsetY: 0 }; expect( group3.willDrawShadow(), 'group will NOT cast shadow because group itself has shadow but not offsets', ).toBe(false); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 2, offsetY: 0 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself has shadow and one offsetX different than 0', ).toBe(true); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 0, offsetY: 2 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself has shadow and one offsetY different than 0', ).toBe(true); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: -2, offsetY: 0 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself has shadow and one offsetX different than 0', ).toBe(true); // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 0, offsetY: -2 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself has shadow and one offsetY different than 0', ).toBe(true); // @ts-expect-error -- partial shadow object rect1.shadow = { offsetX: 1, offsetY: 2 }; // @ts-expect-error -- partial shadow object group3.shadow = { offsetX: 0, offsetY: 0 }; expect( group3.willDrawShadow(), 'group will cast shadow because group itself will not, but rect 1 will', ).toBe(true); }); it('shouldCache', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), rect3 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), rect4 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), group = new Group([rect1, rect2], { objectCaching: true }), group2 = new Group([rect3, rect4], { objectCaching: true }), group3 = new Group([group, group2], { objectCaching: true }); expect( group3.shouldCache(), 'group3 will cache because no child has shadow', ).toBe(true); expect( group2.shouldCache(), 'group2 will not cache because is drawing on parent group3 cache', ).toBe(false); expect( rect3.shouldCache(), 'rect3 will not cache because is drawing on parent2 group cache', ).toBe(false); // @ts-expect-error -- partial shadow object group2.shadow = { offsetX: 2, offsetY: 0 }; // @ts-expect-error -- partial shadow object rect1.shadow = { offsetX: 0, offsetY: 2 }; expect( group3.shouldCache(), 'group3 will cache because children have shadow', ).toBe(false); expect( group2.shouldCache(), 'group2 will cache because is not drawing on parent group3 cache and no children have shadow', ).toBe(true); expect( group.shouldCache(), 'group will not cache because even if is not drawing on parent group3 cache children have shadow', ).toBe(false); expect( rect1.shouldCache(), 'rect1 will cache because none of its parent is caching', ).toBe(true); expect( rect3.shouldCache(), 'rect3 will not cache because group2 is caching', ).toBe(false); }); it('canvas prop propagation with set', () => { const rect1 = new Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), rect2 = new Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: true, }), group = new Group([rect1, rect2]); group.set('canvas', 'a-canvas'); expect(group.canvas, 'canvas has been set').toBe('a-canvas');