fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
538 lines (496 loc) • 18.7 kB
text/typescript
import { describe, it, expect, afterEach } from 'vitest';
import { config } from '../config';
import { Canvas } from './Canvas';
import {
ActiveSelection,
getFabricDocument,
runningAnimations,
StaticCanvas,
util,
} from '../../fabric';
import { makeRect } from '../../test/utils';
describe('Canvas dispose', () => {
describe.for([
{ name: 'StaticCanvas', CanvasClass: StaticCanvas },
{ name: 'Canvas', CanvasClass: Canvas },
])('disposing $name', ({ CanvasClass }) => {
afterEach(() => {
config.restoreDefaults();
});
it('dispose', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
expect(
canvas.destroyed,
'should not have been destroyed yet',
).toBeFalsy();
await canvas.dispose();
expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy();
});
it('dispose: clear references sync', () => {
const el = getFabricDocument().createElement('canvas');
const parentEl = getFabricDocument().createElement('div');
el.width = 200;
el.height = 200;
parentEl.className = 'rootNode';
parentEl.appendChild(el);
config.configure({ devicePixelRatio: 1.25 });
el.style.position = 'relative';
const elStyle = el.style.cssText;
expect(elStyle, 'el style should not be empty').toBe(
'position: relative;',
);
const canvas = new CanvasClass(el, {
enableRetinaScaling: true,
renderOnAddRemove: false,
});
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'saved original canvas style for disposal',
).toBe(elStyle);
expect(el.style.cssText, 'canvas el style has been changed').not.toBe(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
);
expect(
el.getAttribute('data-fabric'),
'lowerCanvasEl should be marked by fabric',
).toBe('main');
expect(canvas.dispose).toBeTypeOf('function');
expect(canvas.destroy).toBeTypeOf('function');
canvas.add(makeRect(), makeRect(), makeRect());
canvas.item(0).animate({ scaleX: 10 });
expect(runningAnimations.length, 'should have a running animation').toBe(
1,
);
canvas.dispose();
expect(canvas.disposed, 'dispose should flag disposed').toBe(true);
expect(
el.hasAttribute('data-fabric'),
'dispose should clear lowerCanvasEl data-fabric attr',
).toBe(false);
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'removed original canvas style',
).toBeUndefined();
expect(el.style.cssText, 'restored original canvas style').toBe(elStyle);
expect(el.width, 'restored width').toBe(200);
expect(el.height, 'restored height').toBe(200);
});
it('dispose: clear references async', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
expect(canvas.dispose).toBeTypeOf('function');
expect(canvas.destroy).toBeTypeOf('function');
canvas.add(makeRect(), makeRect(), makeRect());
const lowerCanvas = canvas.lowerCanvasEl;
expect(
lowerCanvas.getAttribute('data-fabric'),
'lowerCanvasEl should be marked by fabric',
).toBe('main');
await canvas.dispose();
expect(canvas.destroyed, 'dispose should flag destroyed').toBe(true);
expect(canvas.getObjects().length, 'dispose should clear canvas').toBe(0);
expect(
canvas.lowerCanvasEl,
'dispose should clear lowerCanvasEl',
).toBeUndefined();
expect(
lowerCanvas.hasAttribute('data-fabric'),
'dispose should clear lowerCanvasEl data-fabric attr',
).toBe(false);
expect(
canvas.contextContainer,
'dispose should clear contextContainer',
).toBeUndefined();
});
it('dispose edge case: multiple calls', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
expect(
canvas.destroyed,
'should not have been destroyed yet',
).toBeFalsy();
const res = await Promise.all([
canvas.dispose(),
canvas.dispose(),
canvas.dispose(),
]);
expect(canvas.disposed, 'should have flagged `disposed`').toBeTruthy();
expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy();
expect(res, 'should have disposed in the first call').toEqual([
true,
false,
false,
]);
});
it('dispose edge case: multiple calls after `requestRenderAll`', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
expect(
canvas.destroyed,
'should not have been destroyed yet',
).toBeFalsy();
canvas.requestRenderAll();
const res = await Promise.allSettled([
canvas.dispose(),
canvas.dispose(),
canvas.dispose(),
]);
expect(canvas.disposed, 'should have flagged `disposed`').toBeTruthy();
expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy();
expect(
res,
'should have disposed in the last call, aborting the other calls',
).toEqual([
{ status: 'rejected', reason: 'aborted' },
{ status: 'rejected', reason: 'aborted' },
{ status: 'fulfilled', value: true },
]);
});
it('dispose edge case: rendering after dispose', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
let called = 0;
expect(await canvas.dispose(), 'should dispose').toBeTruthy();
canvas.on('after:render', () => {
called++;
});
canvas.fire('after:render');
expect(called, 'should have fired').toBe(1);
// @ts-expect-error -- protected property
expect(canvas.nextRenderHandle).toBeUndefined();
canvas.requestRenderAll();
expect(
// @ts-expect-error -- private property
canvas.nextRenderHandle,
'`requestRenderAll` should have no affect',
).toBeUndefined();
canvas.renderAll();
expect(called, 'should not have rendered, should still equal 1').toBe(1);
});
it('dispose edge case: `toCanvasElement` interrupting `requestRenderAll`', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
// @ts-expect-error -- private property
expect(canvas.nextRenderHandle).toBeUndefined();
// @ts-expect-error -- private property
canvas.nextRenderHandle = 1;
canvas.toCanvasElement();
// @ts-expect-error -- private property
expect(canvas.nextRenderHandle, 'should request rendering').toBe(1);
});
it('dispose edge case: `toCanvasElement` after dispose', async () => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
const getAlphaValues = () => {
return canvas
.toCanvasElement()
.getContext('2d')!
.getImageData(10, 10, 20, 20)
.data.filter((_, i) => i % 4 === 3);
};
const hasOpaquePixels = () => getAlphaValues().some((x) => x === 255);
const isFullyTransparent = () => getAlphaValues().every((x) => x === 0);
canvas.add(
makeRect({ fill: 'red', width: 20, height: 20, top: 10, left: 10 }),
);
expect(hasOpaquePixels(), 'control').toBeTruthy();
canvas.disposed = true;
expect(hasOpaquePixels(), 'should render canvas').toBeTruthy();
canvas.destroyed = true;
expect(
isFullyTransparent(),
'should have disabled canvas rendering',
).toBeTruthy();
canvas.destroyed = false;
expect(await canvas.dispose(), 'dispose').toBeTruthy();
});
it('dispose edge case: during animation', () => {
return new Promise<void>((resolve) => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
let called = 0;
const animate = () =>
util.animate({
onChange() {
if (called === 1) {
canvas.dispose().then(() => {
runningAnimations.cancelAll();
resolve();
});
expect(canvas.disposed, 'should flag `disposed`').toBeTruthy();
}
called++;
(canvas as Canvas).contextTopDirty = true;
// @ts-expect-error -- private property
canvas.hasLostContext = true;
canvas.renderAll();
},
onComplete() {
animate();
},
});
animate();
});
});
it('disposing during animation should cancel it by target', () => {
return new Promise<void>((resolve) => {
const canvas = new CanvasClass(undefined, { renderOnAddRemove: false });
let called = 0;
const animate = () =>
util.animate({
target: canvas,
onChange() {
if (called === 1) {
expect(
runningAnimations[0].target,
'should register the animation by target',
).toBe(canvas);
canvas.dispose().then(() => {
expect(
runningAnimations,
'should cancel the animation',
).toEqual([]);
resolve();
});
expect(canvas.disposed, 'should flag `disposed`').toBeTruthy();
}
called++;
// TODO: check typings, because this runs for both static and normal canvas but it is only typed on normal canvas
(canvas as Canvas).contextTopDirty = true;
// @ts-expect-error -- private property
canvas.hasLostContext = true;
canvas.renderAll();
},
onComplete() {
animate();
},
});
animate();
});
});
if (CanvasClass === Canvas) {
it('dispose: clear refs sync for Canvas', () => {
const el = getFabricDocument().createElement('canvas');
const parentEl = getFabricDocument().createElement('div');
el.width = 200;
el.height = 200;
parentEl.className = 'rootNode';
parentEl.appendChild(el);
config.configure({ devicePixelRatio: 1.25 });
expect(
parentEl.firstChild,
'canvas should be appended at parentEl',
).toBe(el);
expect(parentEl.childNodes.length, 'parentEl has 1 child only').toBe(1);
el.style.position = 'relative';
const elStyle = el.style.cssText;
expect(elStyle, 'el style should not be empty').toBe(
'position: relative;',
);
const canvas = new Canvas(el, {
enableRetinaScaling: true,
renderOnAddRemove: false,
});
const { upperCanvasEl, lowerCanvasEl, wrapperEl } = canvas;
const activeSel = new ActiveSelection();
expect(
parentEl.childNodes.length,
'parentEl has still 1 child only',
).toBe(1);
expect(
wrapperEl.childNodes.length,
'wrapper should have 2 children',
).toBe(2);
expect(wrapperEl.tagName, 'We wrapped canvas with DIV').toBe('DIV');
expect(wrapperEl.className, 'DIV class should be set').toBe(
canvas.containerClass,
);
expect(
wrapperEl.childNodes[0],
'First child should be lowerCanvas',
).toBe(lowerCanvasEl);
expect(
wrapperEl.childNodes[1],
'Second child should be upperCanvas',
).toBe(upperCanvasEl);
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'saved original canvas style for disposal',
).toBe(elStyle);
expect(activeSel, 'active selection').toBeInstanceOf(ActiveSelection);
expect(el.style.cssText, 'canvas el style has been changed').not.toBe(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
);
expect(
parentEl.childNodes[0],
'wrapperEl is appended to rootNode',
).toBe(wrapperEl);
expect(
parentEl.childNodes.length,
'parent div should have 1 child',
).toBe(1);
expect(
parentEl.firstChild,
'canvas should not be parent div firstChild',
).not.toBe(canvas.getElement());
expect(canvas.dispose).toBeTypeOf('function');
expect(canvas.destroy).toBeTypeOf('function');
canvas.add(makeRect(), makeRect(), makeRect());
canvas.item(0).animate({ scaleX: 10 });
activeSel.add(canvas.item(1));
expect(
runningAnimations.length,
'should have a running animation',
).toBe(1);
canvas.dispose();
expect(parentEl.childNodes.length, 'parent has always 1 child').toBe(1);
expect(
parentEl.childNodes[0],
'canvas should be back to its firstChild place',
).toBe(lowerCanvasEl);
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'removed original canvas style',
).toBeUndefined();
expect(el.style.cssText, 'restored original canvas style').toBe(
elStyle,
);
expect(el.width, 'restored width').toBe(200);
expect(el.height, 'restored height').toBe(200);
});
it('dispose: clear refs async for Canvas', async () => {
const el = getFabricDocument().createElement('canvas');
const parentEl = getFabricDocument().createElement('div');
el.width = 200;
el.height = 200;
parentEl.className = 'rootNode';
parentEl.appendChild(el);
config.configure({ devicePixelRatio: 1.25 });
expect(
parentEl.firstChild,
'canvas should be appended at partentEl',
).toBe(el);
expect(parentEl.childNodes.length, 'parentEl has 1 child only').toBe(1);
el.style.position = 'relative';
const elStyle = el.style.cssText;
expect(elStyle, 'el style should not be empty').toBe(
'position: relative;',
);
const canvas = new Canvas(el, {
enableRetinaScaling: true,
renderOnAddRemove: false,
});
const { wrapperEl, lowerCanvasEl, upperCanvasEl } = canvas;
const activeSel = new ActiveSelection();
canvas.setActiveObject(activeSel);
expect(
parentEl.childNodes.length,
'parentEl has still 1 child only',
).toBe(1);
expect(
wrapperEl.childNodes.length,
'wrapper should have 2 children',
).toBe(2);
expect(wrapperEl.tagName, 'We wrapped canvas with DIV').toBe('DIV');
expect(wrapperEl.className, 'DIV class should be set').toBe(
canvas.containerClass,
);
expect(
wrapperEl.childNodes[0],
'First child should be lowerCanvas',
).toBe(lowerCanvasEl);
expect(
wrapperEl.childNodes[1],
'Second child should be upperCanvas',
).toBe(upperCanvasEl);
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'saved original canvas style for disposal',
).toBe(elStyle);
expect(
canvas.getActiveObject() === activeSel,
'active selection',
).toBeTruthy();
expect(el.style.cssText, 'canvas el style has been changed').not.toBe(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
);
expect(
parentEl.childNodes[0],
'wrapperEl is appended to rootNode',
).toBe(wrapperEl);
expect(
parentEl.childNodes.length,
'parent div should have 1 child',
).toBe(1);
expect(
parentEl.firstChild,
'canvas should not be parent div firstChild',
).not.toBe(canvas.getElement());
expect(canvas.dispose).toBeTypeOf('function');
expect(canvas.destroy).toBeTypeOf('function');
canvas.add(makeRect(), makeRect(), makeRect());
canvas.item(0).animate({ scaleX: 10 });
activeSel.add(canvas.item(1));
expect(
runningAnimations.length,
'should have a running animation',
).toBe(1);
await canvas.dispose();
expect(
runningAnimations.length,
'dispose should clear running animations',
).toBe(0);
expect(canvas.getObjects().length, 'dispose should clear canvas').toBe(
0,
);
expect(
canvas.getActiveObject(),
'dispose should dispose active selection',
).toBeUndefined();
expect(
activeSel.size(),
'dispose should dispose active selection',
).toBe(0);
expect(parentEl.childNodes.length, 'parent has always 1 child').toBe(1);
expect(
parentEl.childNodes[0],
'canvas should be back to its firstChild place',
).toBe(lowerCanvasEl);
expect(canvas.wrapperEl, 'wrapperEl should be deleted').toBeUndefined();
expect(
canvas.upperCanvasEl,
'upperCanvas should be deleted',
).toBeUndefined();
expect(
canvas.lowerCanvasEl,
'lowerCanvasEl should be deleted',
).toBeUndefined();
expect(
// @ts-expect-error -- private property
canvas.pixelFindCanvasEl,
'pixelFindCanvasEl should be deleted',
).toBeUndefined();
expect(
canvas.contextTop,
'contextTop should be deleted',
).toBeUndefined();
expect(
// @ts-expect-error -- private property
canvas.pixelFindContext,
'pixelFindContext should be deleted',
).toBeNull();
expect(
// @ts-expect-error -- private property
canvas.elements._originalCanvasStyle,
'removed original canvas style',
).toBeUndefined();
expect(el.style.cssText, 'restored original canvas style').toBe(
elStyle,
);
expect(el.width, 'restored width').toBe(200);
expect(el.height, 'restored height').toBe(200);
});
}
});
});