UNPKG

fabric

Version:

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

1,593 lines (1,328 loc) 73.7 kB
import { StaticCanvas } from './StaticCanvas'; import { Canvas } from './Canvas'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { TMat2D } from '../typedefs'; import { FabricText, Gradient, Pattern, version } from '../../fabric'; import { config } from '../config'; import { Rect } from '../shapes/Rect'; import { Circle } from '../shapes/Circle'; import type { FabricObject } from '../shapes/Object/Object'; import { getFabricDocument } from '../env'; import { FabricImage } from '../shapes/Image'; import { Point } from '../Point'; import { Group } from '../shapes/Group'; import { Path } from '../shapes/Path'; import { Ellipse } from '../shapes/Ellipse'; import { Line } from '../shapes/Line'; import { Polyline } from '../shapes/Polyline'; import { Triangle } from '../shapes/Triangle'; import { Polygon } from '../shapes/Polygon'; import { CANVAS_SVG, CANVAS_SVG_VIEWBOX, PATH_DATALESS_JSON, PATH_JSON, PATH_WITHOUT_DEFAULTS_JSON, RECT_JSON, RECT_JSON_WITH_PADDING, REFERENCE_IMG_OBJECT, IMG_SRC, IMG_WIDTH, IMG_HEIGHT, } from './StaticCanvas.fixtures'; import { isJSDOM, sanitizeSVG } from '../../vitest.extend'; describe('StaticCanvas', () => { const canvas = new StaticCanvas(undefined, { renderOnAddRemove: false, enableRetinaScaling: false, width: 200, height: 200, }); const canvas2 = new StaticCanvas(undefined, { renderOnAddRemove: false, enableRetinaScaling: false, width: 200, height: 200, }); const lowerCanvasEl = canvas.lowerCanvasEl; beforeEach(() => { canvas.clear(); canvas.setDimensions({ width: 200, height: 200 }); canvas2.setDimensions({ width: 200, height: 200 }); canvas.backgroundColor = StaticCanvas.getDefaults().backgroundColor; canvas.backgroundImage = StaticCanvas.getDefaults().backgroundImage; canvas.overlayColor = StaticCanvas.getDefaults().overlayColor; canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; canvas.calcOffset(); canvas.requestRenderAll = StaticCanvas.prototype.requestRenderAll; canvas.cancelRequestedRender(); canvas2.cancelRequestedRender(); canvas.renderOnAddRemove = false; canvas2.renderOnAddRemove = false; }); afterEach(() => { canvas.cancelRequestedRender(); canvas2.cancelRequestedRender(); config.configure({ devicePixelRatio: 1 }); }); it('toBlob', async () => { const canvas = new StaticCanvas(undefined, { width: 300, height: 300 }); const blob = await canvas.toBlob({ multiplier: 3, }); expect(blob).toBeInstanceOf(Blob); expect(blob?.type).toBe('image/png'); }); it('attempts webp format but may fallback to png in node environment', () => { const canvas = new StaticCanvas(undefined, { width: 300, height: 300 }); const dataURL = canvas.toDataURL({ format: 'webp', multiplier: 1, }); /** * In browser environments this would be 'data:image/webp' * In Node.js environment (node-canvas) it falls back to PNG. * @see https://github.com/Automattic/node-canvas/issues/1258 for possible workaround */ expect(dataURL).toMatch(/^data:image\/(webp|png)/); }); it('prevents multiple canvas initialization', () => { const canvas = new StaticCanvas(); expect(canvas.lowerCanvasEl).toBeTruthy(); expect(() => new StaticCanvas(canvas.lowerCanvasEl)).toThrow(); }); it('has correct initial properties', () => { const canvas = new StaticCanvas(); expect('backgroundColor' in canvas).toBeTruthy(); expect('overlayColor' in canvas).toBeTruthy(); expect('includeDefaultValues' in canvas).toBeTruthy(); expect('renderOnAddRemove' in canvas).toBeTruthy(); expect('controlsAboveOverlay' in canvas).toBeTruthy(); expect('allowTouchScrolling' in canvas).toBeTruthy(); expect('imageSmoothingEnabled' in canvas).toBeTruthy(); expect('backgroundVpt' in canvas).toBeTruthy(); expect('overlayVpt' in canvas).toBeTruthy(); expect(Array.isArray(canvas._objects)).toBeTruthy(); expect(canvas._objects.length).toBe(0); expect(canvas.includeDefaultValues).toBe(true); expect(canvas.renderOnAddRemove).toBe(true); expect(canvas.controlsAboveOverlay).toBe(false); expect(canvas.allowTouchScrolling).toBe(false); expect(canvas.imageSmoothingEnabled).toBe(true); expect(canvas.backgroundVpt).toBe(true); expect(canvas.overlayVpt).toBe(true); expect(canvas.viewportTransform).not.toBe(canvas2.viewportTransform); }); it('provides getObjects method', () => { expect(canvas.getObjects).toBeTypeOf('function'); expect(canvas.getObjects()).toEqual([]); expect(canvas.getObjects().length).toBe(0); }); it('provides getElement method', () => { expect(canvas.getElement).toBeTypeOf('function'); expect(canvas.getElement()).toBe(lowerCanvasEl); }); it('provides item method to access objects by index', () => { const rect = makeRect(); expect(canvas.item).toBeTypeOf('function'); canvas.add(rect); expect(canvas.item(0)).toBe(rect); }); it('calculates offset correctly', () => { expect(canvas.calcOffset).toBeTypeOf('function'); expect(canvas.calcOffset()).toEqual({ left: 0, top: 0 }); }); it('adds objects to the canvas', () => { const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); const rect4 = makeRect(); let renderAllCount = 0; function countRenderAll() { renderAllCount++; } canvas.renderOnAddRemove = true; canvas.requestRenderAll = countRenderAll; expect(canvas.add).toBeTypeOf('function'); expect(canvas.add(rect1)).toBe(1); expect(canvas.item(0)).toBe(rect1); expect(renderAllCount).toBe(1); expect(canvas.add(rect2, rect3, rect4)).toBe(4); expect(canvas.getObjects().length).toBe(4); expect(renderAllCount).toBe(2); canvas.add(); expect(renderAllCount).toBe(2); expect(canvas.item(1)).toBe(rect2); expect(canvas.item(2)).toBe(rect3); expect(canvas.item(3)).toBe(rect4); }); it('handles objects that belong to a different canvas', () => { const rect1 = makeRect(); const control: { action: string; canvas: StaticCanvas; target: FabricObject; }[] = []; canvas.on('object:added', (opt) => { control.push({ action: 'added', canvas: canvas, target: opt.target, }); }); canvas.on('object:removed', (opt) => { control.push({ action: 'removed', canvas: canvas, target: opt.target, }); }); canvas2.on('object:added', (opt) => { control.push({ action: 'added', canvas: canvas2, target: opt.target, }); }); canvas.add(rect1); expect(canvas.item(0)).toBe(rect1); canvas2.add(rect1); expect(canvas.item(0)).toBeUndefined(); expect(canvas.size()).toBe(0); expect(canvas2.item(0)).toBe(rect1); const expected = [ { action: 'added', target: rect1, canvas: canvas }, { action: 'removed', target: rect1, canvas: canvas }, { action: 'added', target: rect1, canvas: canvas2 }, ]; expect(control).toEqual(expected); }); it('respects renderOnAddRemove setting', () => { const rect = makeRect(); let renderAllCount = 0; function countRenderAll() { renderAllCount++; } canvas.renderOnAddRemove = false; canvas.requestRenderAll = countRenderAll; canvas.add(rect); expect(renderAllCount).toBe(0); expect(canvas.item(0)).toBe(rect); canvas.add(makeRect(), makeRect(), makeRect()); expect(canvas.getObjects().length).toBe(4); expect(renderAllCount).toBe(0); }); it('fires object:added events', () => { const objectsAdded: FabricObject[] = []; canvas.on('object:added', function (e) { objectsAdded.push(e.target); }); const rect = new Rect({ width: 10, height: 20 }); canvas.add(rect); expect(objectsAdded[0]).toBe(rect); const circle1 = new Circle(); const circle2 = new Circle(); canvas.add(circle1, circle2); expect(objectsAdded[1]).toBe(circle1); expect(objectsAdded[2]).toBe(circle2); const circle3 = new Circle(); canvas.insertAt(2, circle3); expect(objectsAdded[3]).toBe(circle3); }); it('inserts objects at specified positions', () => { const rect1 = makeRect(); const rect2 = makeRect(); let renderAllCount = 0; canvas.add(rect1, rect2); expect(canvas.insertAt).toBeTypeOf('function'); function countRenderAll() { renderAllCount++; } canvas.requestRenderAll = countRenderAll; canvas.renderOnAddRemove = true; expect(renderAllCount).toBe(0); const rect = makeRect(); canvas.insertAt(1, rect); expect(renderAllCount).toBe(1); expect(canvas.item(1)).toBe(rect); canvas.insertAt(2, rect); expect(renderAllCount).toBe(2); expect(canvas.item(2)).toBe(rect); canvas.insertAt(2, rect); expect(renderAllCount).toBe(3); }); it('respects renderOnAddRemove when inserting objects', () => { const rect1 = makeRect(); const rect2 = makeRect(); let renderAllCount = 0; function countRenderAll() { renderAllCount++; } canvas.renderOnAddRemove = false; canvas.requestRenderAll = countRenderAll; canvas.add(rect1, rect2); expect(renderAllCount).toBe(0); const rect = makeRect(); canvas.insertAt(1, rect); expect(renderAllCount).toBe(0); expect(canvas.item(1)).toBe(rect); canvas.insertAt(2, rect); expect(renderAllCount).toBe(0); }); it('removes objects correctly', () => { const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); const rect4 = makeRect(); let renderAllCount = 0; function countRenderAll() { renderAllCount++; } canvas.add(rect1, rect2, rect3, rect4); canvas.requestRenderAll = countRenderAll; canvas.renderOnAddRemove = true; expect(canvas.remove).toBeTypeOf('function'); expect(renderAllCount).toBe(0); expect(canvas.remove(rect1)[0]).toBe(rect1); expect(canvas.item(0)).toBe(rect2); canvas.remove(rect2, rect3); expect(renderAllCount).toBe(2); expect(canvas.item(0)).toBe(rect4); canvas.remove(rect4); expect(renderAllCount).toBe(3); expect(canvas.isEmpty()).toBe(true); }); it('respects renderOnAddRemove when removing objects', () => { const rect1 = makeRect(); const rect2 = makeRect(); let renderAllCount = 0; function countRenderAll() { renderAllCount++; } canvas.requestRenderAll = countRenderAll; canvas.renderOnAddRemove = false; canvas.add(rect1, rect2); expect(renderAllCount).toBe(0); expect(canvas.remove(rect1)[0]).toBe(rect1); expect(renderAllCount).toBe(0); expect(canvas.item(0)).toBe(rect2); }); it('fires object:removed events', () => { const objectsRemoved: FabricObject[] = []; canvas.on('object:removed', function (e) { objectsRemoved.push(e.target); }); const rect = new Rect({ width: 10, height: 20 }); const circle1 = new Circle(); const circle2 = new Circle(); canvas.add(rect, circle1, circle2); expect(canvas.item(0)).toBe(rect); expect(canvas.item(1)).toBe(circle1); expect(canvas.item(2)).toBe(circle2); canvas.remove(rect); expect(objectsRemoved[0]).toBe(rect); expect(rect.canvas).toBeUndefined(); canvas.remove(circle1, circle2); expect(objectsRemoved[1]).toBe(circle1); expect(circle1.canvas).toBeUndefined(); expect(objectsRemoved[2]).toBe(circle2); expect(circle2.canvas).toBeUndefined(); expect(canvas.isEmpty()).toBe(true); }); it('provides clearContext method', () => { expect(canvas.clearContext).toBeTypeOf('function'); canvas.clearContext(canvas.contextContainer); }); it('clears the canvas completely', () => { expect(canvas.clear).toBeTypeOf('function'); const bg = new Rect({ width: 10, height: 20 }); canvas.backgroundColor = '#FF0000'; canvas.overlayColor = '#FF0000'; canvas.backgroundImage = bg; canvas.overlayImage = bg; const objectsRemoved: FabricObject[] = []; canvas.on('object:removed', function (e) { objectsRemoved.push(e.target); }); const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); canvas.add(rect1, rect2, rect3); canvas.clear(); expect(canvas.getObjects().length).toBe(0); expect(objectsRemoved[0]).toBe(rect1); expect(objectsRemoved[1]).toBe(rect2); expect(objectsRemoved[2]).toBe(rect3); expect(canvas.backgroundColor).toBe(''); expect(canvas.overlayColor).toBe(''); expect(canvas.backgroundImage).toBeUndefined(); expect(canvas.overlayImage).toBeUndefined(); }); it('provides renderAll method', () => { expect(canvas.renderAll).toBeTypeOf('function'); canvas.renderAll(); }); // TODO: was also commented out prior to vitest migration // it('sets canvas dimensions correctly', () => { // expect(canvas.setDimensions).toBeTypeOf('function'); // canvas.setDimensions({ width: 4, height: 5 }); // expect(canvas.getWidth()).toBe(4); // expect(canvas.getHeight()).toBe(5); // expect(canvas.lowerCanvasEl.style.width).toBe('5px'); // expect(canvas.lowerCanvasEl.style.height).toBe('4px'); // }); it('exports to canvas element of correct size', () => { expect(canvas.toCanvasElement).toBeTypeOf('function'); const canvasEl = canvas.toCanvasElement(); expect(canvasEl.width).toBe(canvas.getWidth()); expect(canvasEl.height).toBe(canvas.getHeight()); }); it('exports to canvas element with multiplier', () => { expect(canvas.toCanvasElement).toBeTypeOf('function'); const multiplier = 2; const canvasEl = canvas.toCanvasElement(multiplier); expect(canvasEl.width).toBe(canvas.getWidth() * multiplier); expect(canvasEl.height).toBe(canvas.getHeight() * multiplier); }); it('generates data URL correctly', () => { expect(canvas.toDataURL).toBeTypeOf('function'); const rect = new Rect({ width: 100, height: 100, fill: 'red', top: 0, left: 0, }); canvas.add(rect); const dataURL = canvas.toDataURL(); // don't compare actual data url, as it is often browser-dependent expect(typeof dataURL).toBe('string'); expect(dataURL.substring(0, 21)).toBe('data:image/png;base64'); //we can just compare that the dataUrl generated differs from the dataURl of an empty canvas expect(dataURL.substring(200, 210) !== 'AAAAAAAAAA').toBe(true); }); it('supports retina scaling in data URL generation', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); // @ts-expect-error -- multiplier is missing in options and it is mandatory per typescript const dataUrl = c.toDataURL({ enableRetinaScaling: true }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width * config.devicePixelRatio); expect(img.height).toBe(c.height * config.devicePixelRatio); resolve(); }; img.src = dataUrl; }); }); it('handles enableRetinaScaling: true with multiplier = 1', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); const dataUrl = c.toDataURL({ enableRetinaScaling: true, multiplier: 1 }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width * config.devicePixelRatio); expect(img.height).toBe(c.height * config.devicePixelRatio); resolve(); }; img.src = dataUrl; }); }); it('handles enableRetinaScaling: true with multiplier = 3', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); const dataUrl = c.toDataURL({ enableRetinaScaling: true, multiplier: 3 }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width * config.devicePixelRatio * 3); expect(img.height).toBe(c.height * config.devicePixelRatio * 3); resolve(); }; img.src = dataUrl; }); }); it('handles enableRetinaScaling: false with no multiplier', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); // @ts-expect-error -- multiplier is missing in options and it is mandatory per typescript const dataUrl = c.toDataURL({ enableRetinaScaling: false }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width); expect(img.height).toBe(c.height); resolve(); }; img.src = dataUrl; }); }); it('handles enableRetinaScaling: false with multiplier = 1', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); const dataUrl = c.toDataURL({ enableRetinaScaling: false, multiplier: 1, }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width); expect(img.height).toBe(c.height); resolve(); }; img.src = dataUrl; }); }); it('handles enableRetinaScaling: false with multiplier = 3', async () => { config.configure({ devicePixelRatio: 2 }); const c = new StaticCanvas(undefined, { enableRetinaScaling: true, width: 10, height: 10, }); const dataUrl = c.toDataURL({ enableRetinaScaling: false, multiplier: 3, }); c.cancelRequestedRender(); return new Promise<void>((resolve) => { const img = getFabricDocument().createElement('img'); img.onload = () => { expect(img.width).toBe(c.width * 3); expect(img.height).toBe(c.height * 3); resolve(); }; img.src = dataUrl; }); }); it('generates JPEG data URL correctly', () => { try { // @ts-expect-error -- multiplier is mandatory option per typescript types const dataURL = canvas.toDataURL({ format: 'jpeg' }); expect(dataURL.substring(0, 22)).toBe('data:image/jpeg;base64'); } catch { // node-canvas does not support jpeg data urls expect(true).toBeTruthy(); } }); it('supports cropping in data URL generation', async () => { expect(canvas.toDataURL).toBeTypeOf('function'); const croppingWidth = 75; const croppingHeight = 50; // @ts-expect-error -- multiplier is mandatory option per typescript types const dataURL = canvas.toDataURL({ width: croppingWidth, height: croppingHeight, }); const img = await FabricImage.fromURL(dataURL); expect(img.width).toBe(croppingWidth); expect(img.height).toBe(croppingHeight); }); it('centers objects horizontally', () => { expect(canvas.centerObjectH).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); canvas.add(rect); canvas.centerObjectH(rect); expect(rect.getCenterPoint().x).toBe(canvas.width / 2); canvas.setZoom(4); expect(rect.getCenterPoint().x).toBe(canvas.height / 2); canvas.setZoom(1); }); it('centers objects vertically', () => { expect(canvas.centerObjectV).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); canvas.add(rect); canvas.centerObjectV(rect); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); canvas.setZoom(2); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); }); it('centers objects both horizontally and vertically', () => { expect(canvas.centerObject).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); canvas.add(rect); canvas.centerObject(rect); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); expect(rect.getCenterPoint().x).toBe(canvas.height / 2); canvas.setZoom(4); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); expect(rect.getCenterPoint().x).toBe(canvas.height / 2); canvas.setZoom(1); }); it('centers objects horizontally in viewport', () => { expect(canvas.viewportCenterObjectH).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); const pan = 10; canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; canvas.add(rect); const oldY = rect.top; canvas.viewportCenterObjectH(rect); expect(rect.getCenterPoint().x).toBe(canvas.width / 2); expect(rect.top).toBe(oldY); canvas.setZoom(2); canvas.viewportCenterObjectH(rect); expect(rect.getCenterPoint().x).toBe(canvas.width / (2 * canvas.getZoom())); expect(rect.top).toBe(oldY); canvas.absolutePan(new Point(pan, pan)); canvas.viewportCenterObjectH(rect); expect(rect.getCenterPoint().x).toBe( (canvas.width / 2 + pan) / canvas.getZoom(), ); expect(rect.top).toBe(oldY); }); it('centers objects vertically in viewport', () => { expect(canvas.viewportCenterObjectV).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); const pan = 10; canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; canvas.add(rect); const oldX = rect.left; canvas.viewportCenterObjectV(rect); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); expect(rect.left).toBe(oldX); canvas.setZoom(2); canvas.viewportCenterObjectV(rect); expect(rect.getCenterPoint().y).toBe( canvas.height / (2 * canvas.getZoom()), ); expect(rect.left).toBe(oldX); canvas.absolutePan(new Point(pan, pan)); canvas.viewportCenterObjectV(rect); expect(rect.getCenterPoint().y).toBe( (canvas.height / 2 + pan) / canvas.getZoom(), ); expect(rect.left).toBe(oldX); }); it('centers objects in viewport both horizontally and vertically', () => { expect(canvas.viewportCenterObject).toBeTypeOf('function'); const rect = makeRect({ left: 102, top: 202 }); const pan = 10; canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; canvas.add(rect); canvas.viewportCenterObject(rect); expect(rect.getCenterPoint().y).toBe(canvas.height / 2); expect(rect.getCenterPoint().x).toBe(canvas.width / 2); canvas.setZoom(2); canvas.viewportCenterObject(rect); expect(rect.getCenterPoint().y).toBe( canvas.height / (2 * canvas.getZoom()), ); expect(rect.getCenterPoint().x).toBe(canvas.width / (2 * canvas.getZoom())); canvas.absolutePan(new Point(pan, pan)); canvas.viewportCenterObject(rect); expect(rect.getCenterPoint().y).toBe( (canvas.height / 2 + pan) / canvas.getZoom(), ); expect(rect.getCenterPoint().x).toBe( (canvas.width / 2 + pan) / canvas.getZoom(), ); canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; }); it('generates SVG correctly', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; const svg = canvas.toSVG(); expect(svg).toEqualSVG(CANVAS_SVG); }); it('supports different encodings in SVG (ISO-8859-1)', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); canvas.viewportTransform = [1, 0, 0, 1, 0, 0]; // @ts-expect-error -- TS2322: Type 'ISO-8859-1' is not assignable to type 'UTF-8, seems like types don't allow this encoding const svg = canvas.toSVG({ encoding: 'ISO-8859-1' }); const svgDefaultEncoding = canvas.toSVG(); expect(svg).not.toBe(svgDefaultEncoding); expect(svg).toEqualSVG( CANVAS_SVG.replace('encoding="UTF-8"', 'encoding="ISO-8859-1"'), ); }); it('can generate SVG without preamble', () => { expect(canvas.toSVG).toBeTypeOf('function'); const withPreamble = canvas.toSVG(); const withoutPreamble = canvas.toSVG({ suppressPreamble: true }); expect(withPreamble).not.toBe(withoutPreamble); expect(withoutPreamble.slice(0, 4)).toBe('<svg'); }); it('generates SVG with viewBox', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); const svg = canvas.toSVG({ viewBox: { x: 100, y: 100, width: 300, height: 300 }, }); expect(svg).toEqualSVG(CANVAS_SVG_VIEWBOX); }); it('handles reviver function for all objects', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); const circle = new Circle(); const rect = new Rect(); const path1 = new Path('M 100 100 L 300 100 L 200 300 z'); const tria = new Triangle(); const polygon = new Polygon([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const polyline = new Polyline([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const line = new Line(); const text = new FabricText('Text'); const group = new Group([text, line]); const ellipse = new Ellipse(); const image = createImageStub(); const path2 = new Path('M 0 0 L 200 100 L 200 300 z'); const path3 = new Path('M 50 50 L 100 300 L 400 400 z'); const pathGroup = new Group([path2, path3]); canvas.renderOnAddRemove = false; canvas.add( circle, rect, path1, tria, polygon, polyline, group, ellipse, image, pathGroup, ); let reviverCount = 0; function reviver(svg: string) { reviverCount++; return svg; } canvas.toSVG(undefined, reviver); expect(reviverCount).toBe(14); canvas.renderOnAddRemove = true; }); it('handles reviver function with background and overlay images', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); const circle = new Circle(); const rect = new Rect(); const path1 = new Path('M 100 100 L 300 100 L 200 300 z'); const tria = new Triangle(); const polygon = new Polygon([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const polyline = new Polyline([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const line = new Line(); const text = new FabricText('Text'); const group = new Group([text, line]); const ellipse = new Ellipse(); const image = createImageStub(); const imageBG = createImageStub(); const imageOL = createImageStub(); const path2 = new Path('M 0 0 L 200 100 L 200 300 z'); const path3 = new Path('M 50 50 L 100 300 L 400 400 z'); const pathGroup = new Group([path2, path3]); canvas.renderOnAddRemove = false; canvas.add( circle, rect, path1, tria, polygon, polyline, group, ellipse, image, pathGroup, ); canvas.backgroundImage = imageBG; canvas.overlayImage = imageOL; let reviverCount = 0; const len = canvas.size() + group.size() + pathGroup.size(); function reviver(svg: string) { reviverCount++; return svg; } canvas.toSVG(undefined, reviver); expect(reviverCount).toBe(len + 2); canvas.backgroundImage = undefined; canvas.overlayImage = undefined; canvas.renderOnAddRemove = true; }); it('excludes objects marked with excludeFromExport from SVG', () => { expect(canvas.toSVG).toBeTypeOf('function'); canvas.clear(); const circle = new Circle({ excludeFromExport: true }); const rect = new Rect({ excludeFromExport: true }); const path1 = new Path('M 100 100 L 300 100 L 200 300 z'); const tria = new Triangle(); const polygon = new Polygon([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const polyline = new Polyline([ { x: 10, y: 12 }, { x: 20, y: 22 }, ]); const line = new Line(); const text = new FabricText('Text'); const group = new Group([text, line]); const ellipse = new Ellipse(); const image = createImageStub(); const path2 = new Path('M 0 0 L 200 100 L 200 300 z'); const path3 = new Path('M 50 50 L 100 300 L 400 400 z'); const pathGroup = new Group([path2, path3]); canvas.renderOnAddRemove = false; canvas.add( circle, rect, path1, tria, polygon, polyline, group, ellipse, image, pathGroup, ); let reviverCount = 0; const len = canvas.size() + group.size() + pathGroup.size(); function reviver(svg: string) { reviverCount++; return svg; } canvas.toSVG(undefined, reviver); expect(reviverCount).toBe(len - 2); canvas.renderOnAddRemove = true; }); it('correctly includes clipPath in SVG output', () => { const canvasClip = new StaticCanvas(undefined, { width: 400, height: 400, renderOnAddRemove: false, }); canvasClip.clipPath = new Rect({ left: 100.5, top: 100.5, width: 200, height: 200, }); canvasClip.add(new Circle({ left: 200.5, top: 200.5, radius: 200 })); const svg = canvasClip.toSVG(); expect(sanitizeSVG(svg)).toMatchInlineSnapshot(` "<?xml version="1.0" encoding="UTF-8" standalone="no" ?> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="400" height="400" viewBox="0 0 400 400" xml:space="preserve"> <desc>Created with Fabric.js ${version}</desc> <defs> <clipPath id="SVGID" > <rect transform="matrix(1 0 0 1 100.5 100.5)" x="-100" y="-100" rx="0" ry="0" width="200" height="200" /> </clipPath> </defs> <g clip-path="url(#SVGID)" > <g transform="matrix(1 0 0 1 200.5 200.5)" > <circle style="stroke: none; stroke-width: 1; 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;" cx="0" cy="0" r="200" /> </g> </g> </svg>" `); }); it('handles excludeFromExport for background and overlay images', () => { const imageBG = createImageStub(); const imageOL = createImageStub(); canvas.renderOnAddRemove = false; canvas.backgroundImage = imageBG; canvas.overlayImage = imageOL; const expectedSVG = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="200" height="200" viewBox="0 0 200 200" xml:space="preserve">\n<desc>Created with Fabric.js ' + version + '</desc>\n<defs>\n</defs>\n<g transform="matrix(1 0 0 1 0 0)" >\n\t<image 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;" xlink:href="" x="0" y="0" width="0" height="0"></image>\n</g>\n<g transform="matrix(1 0 0 1 0 0)" >\n\t<image 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;" xlink:href="" x="0" y="0" width="0" height="0"></image>\n</g>\n</svg>'; const svg1 = canvas.toSVG(); expect(svg1).toEqualSVG(expectedSVG); imageBG.excludeFromExport = true; imageOL.excludeFromExport = true; const expectedSVG2 = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="200" height="200" viewBox="0 0 200 200" xml:space="preserve">\n<desc>Created with Fabric.js ' + version + '</desc>\n<defs>\n</defs>\n</svg>'; const svg2 = canvas.toSVG(); expect(svg2).toEqualSVG(expectedSVG2); canvas.backgroundImage = undefined; canvas.overlayImage = undefined; canvas.renderOnAddRemove = true; }); it('converts canvas to JSON correctly', () => { expect(canvas.toJSON).toBeTypeOf('function'); expect(JSON.stringify(canvas)).toBe( JSON.stringify({ version: version, objects: [] }), ); canvas.backgroundColor = '#ff5555'; canvas.overlayColor = 'rgba(0,0,0,0.2)'; expect(canvas.toJSON()).toEqual({ version: version, objects: [], background: '#ff5555', overlay: 'rgba(0,0,0,0.2)', }); canvas.add(makeRect()); expect(canvas.toJSON()).toEqual(RECT_JSON); }); it('handles custom properties in toObject correctly', () => { const rect = new Rect({ width: 10, height: 20 }); rect.padding = 123; canvas.add(rect); // @ts-expect-error -- custom prop rect.foo = 'bar'; // @ts-expect-error -- custom prop canvas.bar = 456; const data = canvas.toObject(['padding', 'foo', 'bar', 'baz']); expect('padding' in data.objects[0]).toBeTruthy(); expect('foo' in data.objects[0]).toBeTruthy(); expect('bar' in data.objects[0]).toBeFalsy(); expect('baz' in data.objects[0]).toBeFalsy(); expect('foo' in data).toBeFalsy(); expect('baz' in data).toBeFalsy(); expect('bar' in data).toBeTruthy(); }); it('serializes backgroundImage to JSON correctly', async () => { canvas.backgroundImage = await createImageObject(); const json = canvas.toJSON(); fixImageDimension(json.backgroundImage); expect(json.backgroundImage).toSameImageObject(REFERENCE_IMG_OBJECT); canvas.backgroundImage = undefined; }); it('includes custom props for backgroundImage when specified', async () => { const image = await createImageObject(); canvas.backgroundImage = image; // @ts-expect-error -- custom prop image.custom = 'yes'; const json = canvas.toObject(['custom']); expect(json.backgroundImage.custom).toBe('yes'); canvas.backgroundImage = undefined; }); it('serializes overlayImage to JSON correctly', async () => { canvas.overlayImage = await createImageObject(); const json = canvas.toJSON(); fixImageDimension(json.overlayImage); expect(json.overlayImage).toSameImageObject(REFERENCE_IMG_OBJECT); canvas.overlayImage = undefined; }); it('includes custom props for overlayImage when specified', async () => { const image = await createImageObject(); canvas.overlayImage = image; // @ts-expect-error -- custom prop image.custom = 'yes'; const json = canvas.toObject(['custom']); expect(json.overlayImage.custom).toBe('yes'); canvas.overlayImage = undefined; }); it('generates dataless JSON correctly', () => { 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('serializes to object correctly', () => { 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('respects includeDefaultValues setting', () => { canvas.includeDefaultValues = false; const rect = makeRect(); canvas.add(rect); const cObject = canvas.toObject(); const expectedRect = { version: version, type: 'Rect', width: 10, height: 10, top: 0, left: 0, }; expect(cObject.objects[0]).toEqual(expectedRect); canvas.includeDefaultValues = true; }); it('respects excludeFromExport setting', () => { const rect = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); const clipPath = makeRect(); canvas.clear(); canvas.add(rect, rect2, rect3); canvas.clipPath = clipPath; let canvasObj = canvas.toObject(); expect(canvasObj.objects.length).toBe(3); expect(canvasObj.clipPath).toEqual(clipPath.toObject()); rect.excludeFromExport = true; rect2.excludeFromExport = true; clipPath.excludeFromExport = true; canvasObj = canvas.toObject(); expect(canvasObj.objects.length).toBe(1); expect(canvasObj.clipPath).toBeUndefined(); }); it('respects excludeFromExport for background and overlay elements', () => { const rect = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); const bgColor = new Gradient({ type: 'linear', colorStops: [ { offset: 0, color: 'black' }, { offset: 1, color: 'white' }, ], coords: { x1: 0, x2: 300, y1: 0, y2: 0, }, }); canvas.clear(); canvas.backgroundImage = rect; canvas.overlayImage = rect2; canvas.backgroundColor = bgColor; canvas.overlayColor = 'red'; canvas.add(rect3); const rectToObject = rect.toObject(); const rect2ToObject = rect2.toObject(); const bgc2ToObject = bgColor.toObject(); let canvasToObject = canvas.toObject(); expect(canvasToObject.backgroundImage).toEqual(rectToObject); expect(canvasToObject.overlayImage).toEqual(rect2ToObject); expect(canvasToObject.background).toEqual(bgc2ToObject); expect(canvasToObject.overlay).toBe('red'); rect.excludeFromExport = true; rect2.excludeFromExport = true; bgColor.excludeFromExport = true; canvasToObject = canvas.toObject(); expect(canvasToObject.backgroundImage).toBeUndefined(); expect(canvasToObject.overlayImage).toBeUndefined(); expect(canvasToObject.background).toBeUndefined(); }); it('generates dataless object representation', () => { 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 the 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('includes additional properties in object representation when specified', () => { // @ts-expect-error -- custom prop canvas.freeDrawingColor = 'red'; // @ts-expect-error -- custom prop canvas.foobar = 123; const expectedObject = { version: version, objects: canvas.getObjects(), freeDrawingColor: 'red', foobar: 123, }; expect(canvas.toObject(['freeDrawingColor', 'foobar'])).toEqual( expectedObject, ); const rect = makeRect(); // @ts-expect-error -- custom prop rect.foobar = 456; canvas.add(rect); expect('foobar' in canvas.toObject(['smthelse']).objects[0]).toBeFalsy(); expect('foobar' in canvas.toObject(['foobar']).objects[0]).toBeTruthy(); }); it('correctly reports if canvas is empty', () => { expect(canvas.isEmpty).toBeTypeOf('function'); expect(canvas.isEmpty()).toBeTruthy(); canvas.add(makeRect()); expect(canvas.isEmpty()).toBeFalsy(); }); it('loads from JSON string correctly', async () => { expect(canvas.loadFromJSON).toBeTypeOf('function'); await canvas.loadFromJSON(PATH_JSON); expect(canvas).not.toHaveProperty('objects'); const obj = canvas.item(0); expect(canvas.isEmpty()).toBeFalsy(); // @ts-expect-error -- constructor function has type expect(obj.constructor.type).toBe('Path'); expect(canvas.backgroundColor).toBe('#ff5555'); 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')).toBeNull(); 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).toBeGreaterThan(0); }); it('loads from JSON object correctly', async () => { expect(canvas.loadFromJSON).toBeTypeOf('function'); await canvas.loadFromJSON(PATH_JSON); const obj = canvas.item(0); expect(canvas.isEmpty()).toBeFalsy(); // @ts-expect-error -- constructor function has type expect(obj.constructor.type).toBe('Path'); expect(canvas.backgroundColor).toBe('#ff5555'); expect(canvas.overlayColor).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')).toBeNull(); 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).toBeGreaterThan(0); }); it('loads from JSON object without defaults correctly', async () => { expect(canvas.loadFromJSON).toBeTypeOf('function'); await canvas.loadFromJSON(PATH_WITHOUT_DEFAULTS_JSON); const obj = canvas.item(0); expect(canvas.isEmpty()).toBeFalsy(); // @ts-expect-error -- constructor function has type expect(obj.constructor.type).toBe('Path'); expect(canvas.backgroundColor).toBe('#ff5555'); expect(canvas.overlayColor).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')).toBeNull(); 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).toBeGreaterThan(0); }); it('loads JSON with image background correctly', async () => { const serialized = JSON.parse(JSON.stringify(PATH_JSON)); serialized.background = 'green'; serialized.backgroundImage = { type: 'image', originX: 'left', originY: 'top', left: 13.6, top: -1.4, width: 3000, height: 3351, fill: 'rgb(0,0,0)', stroke: null, strokeWidth: 0, strokeDashArray: null, strokeLineCap: 'butt', strokeDashOffset: 0, strokeLineJoin: 'miter', strokeMiterLimit: 4, scaleX: 0.05, scaleY: 0.05, angle: 0, flipX: false, flipY: false, opacity: 1, shadow: null, visible: true, backgroundColor: '', fillRule: 'nonzero', globalCompositeOperation: 'source-over', skewX: 0, skewY: 0, src: IMG_SRC, filters: [], crossOrigin: '', }; await canvas.loadFromJSON(serialized); expect(canvas.isEmpty()).toBeFalsy(); expect(canvas.backgroundColor).toBe('green'); expect(canvas.backgroundImage).toBeInstanceOf(FabricImage); }); it('handles AbortController signal when loading JSON', async () => { const serialized = JSON.parse(JSON.stringify(PATH_JSON)); serialized.background = 'green'; serialized.backgroundImage = { type: 'image', originX: 'left', originY: 'top', left: 13.6, top: -1.4, width: 3000, height: 3351, fill: 'rgb(0,0,0)', stroke: null, strokeWidth: 0, strokeDashArray: null, strokeLineCap: 'butt', strokeDashOffset: 0, strokeLineJoin: 'miter', strokeMiterLimit: 4, scaleX: 0.05, scaleY: 0.05, angle: 0, flipX: false, flipY: false, opacity: 1, shadow: null, visible: true, backgroundColor: '', fillRule: 'nonzero', globalCompositeOperation: 'source-over', skewX: 0, skewY: 0, src: IMG_SRC, filters: [], crossOrigin: '', }; const abortController = new AbortController(); try { const promise = canvas.loadFromJSON(serialized, undefined, { signal: abortController.signal, }); abortController.abort(); await promise; // Should not reach here expect(true).toBe(false); } catch (err) { expect(err).toHaveProperty('type', 'abort'); } }); it('preserves custom properties when loading from JSON', async () => { const rect = new Rect({ width: 10, height: 20 }); rect.padding = 123; // @ts-expect-error -- custom prop rect.foo = 'bar'; canvas.add(rect); const jsonWithoutFoo = canvas.toObject(['padding']); const jsonWithFoo = canvas.toObject(['padding', 'foo']); expect(jsonWithFoo).toEqual( JSON.parse(JSON.stringify(RECT_JSON_WITH_PADDING)), ); expect(jsonWithoutFoo).not.toEqual( JSON.parse(JSON.stringify(RECT_JSON_WITH_PADDING)), ); canvas.clear(); await canvas.loadFromJSON(jsonWithFoo); const obj = canvas.item(0); expect(obj.padding).toBe(123); expect(obj).toHaveProperty('foo', 'bar'); }); it('loads text objects correctly from JSON', async () => { const json = '{"objects":[{"type":"Text","left":150,"top":200,"width":128,"height":64.32,"fill":"#000000","stroke":"","strokeWidth":"","scaleX":0.8,"scaleY":0.8,"angle":0,"flipX":false,"flipY":false,"opacity":1,"text":"NAME HERE","fontSize":24,"fontWeight":"normal","fontFamily":"Delicious_500","fontStyle":"normal","lineHeight":"","textDecoration":"","textAlign":"center","path":"","strokeStyle":"","backgroundColor":""}],"background":"#ffffff"}'; await canvas.loadFromJSON(json); canvas.renderAll(); // @ts-expect-error -- constructor function has type property expect(canvas.item(0).constructor.type).toBe('Text'); expect(canvas.item(0).left).toBe(150); expect(canvas.item(0).top).toBe(200); expect(canvas.item(0)).toHaveProperty('text', 'NAME HERE'); }); it('loads clipPath correctly from JSON', async () => { const canvas3 = new StaticCanvas(); const json = '{"clipPath": {"type":"Text","left":150,"top":200,"width":128,"height":64.32,"fill":"#000000","stroke":"","strokeWidth":"","scaleX":0.8,"scaleY":0.8,"angle":0,"flipX":false,"flipY":false,"opacity":1,"text":"NAME HERE","fontSize":24,"fontWeight":"normal","fontFamily":"Delicious_500","fontStyle":"normal","lineHeight":"","textDecoration":"","textAlign":"center","path":"","strokeStyle":"","backgroundColor":""}}'; await canvas3.loadFromJSON(json); expect(canvas3.clipPath).toBeInstanceOf(FabricText); expect(canvas3.clipPath!.constructor).toHaveProperty('type', 'Text'); }); it('sends objects to the back of the stack', () => { expect(canvas.sendObjectToBack).toBeTypeOf('function'); const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); canvas.add(rect1, rect2, rect3); canvas.sendObjectToBack(rect3); expect(canvas.item(0)).toBe(rect3); canvas.sendObjectToBack(rect2); expect(canvas.item(0)).toBe(rect2); canvas.sendObjectToBack(rect2); expect(canvas.item(0)).toBe(rect2); }); it('brings objects to the front of the stack', () => { expect(canvas.bringObjectToFront).toBeTypeOf('function'); const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); canvas.add(rect1, rect2, rect3); canvas.bringObjectToFront(rect1); expect(canvas.item(2)).toBe(rect1); canvas.bringObjectToFront(rect2); expect(canvas.item(2)).toBe(rect2); canvas.bringObjectToFront(rect2); expect(canvas.item(2)).toBe(rect2); }); it('sends objects backwards in the stack', () => { expect(canvas.sendObjectBackwards).toBeTypeOf('function'); const rect1 = makeRect(); const rect2 = makeRect(); const rect3 = makeRect(); canvas.add(rect1, rect2, rect3); // [ 1, 2, 3 ] expect(canvas.item(0)).toBe(rect1); expect(canvas.item(1)).toBe(rect2); expect(canvas.item(2)).toBe(rect3); canvas.sendObjectBackwards(rect3); // moved 3 one level back — [1, 3, 2] expect(canvas.item(0)).toBe(rect1); expect(canvas.item(2)).toBe(rect2); expect(canvas.item(1)).toBe(rect3); canvas.sendObjectBackwards(rect3); // moved 3 one level back — [3, 1, 2] expect(canvas.item(1)).toBe(rect1); expect(canvas.item(2)).toBe(rect2); expect(canvas.item(0)).toBe(rect3); canvas.sendObjectBackwards(rect3); // 3 stays at the deepEqual position — [2, 3, 1] expect(ca