fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
864 lines (814 loc) • 26.7 kB
text/typescript
import type { TModificationEvents } from '../EventTypeDefs';
import { Point } from '../Point';
import { StaticCanvas } from '../canvas/StaticCanvas';
import { Group } from '../shapes/Group';
import { FabricObject } from '../shapes/Object/FabricObject';
import { Rect } from '../shapes/Rect';
import { LayoutManager } from './LayoutManager';
import { ClipPathLayout } from './LayoutStrategies/ClipPathLayout';
import { FitContentLayout } from './LayoutStrategies/FitContentLayout';
import { FixedLayout } from './LayoutStrategies/FixedLayout';
import {
LAYOUT_TYPE_ADDED,
LAYOUT_TYPE_IMPERATIVE,
LAYOUT_TYPE_INITIALIZATION,
LAYOUT_TYPE_REMOVED,
} from './constants';
import type { LayoutContext, LayoutResult, StrictLayoutContext } from './types';
describe('Layout Manager', () => {
it('should set fit content strategy by default', () => {
expect(new LayoutManager().strategy).toBeInstanceOf(FitContentLayout);
});
describe('Lifecycle', () => {
test.each([true, false])('performLayout with result of %s', (result) => {
const lifecycle: jest.SpyInstance[] = [];
const layoutResult: LayoutResult | undefined = result
? {
result: { center: new Point(), size: new Point() },
prevCenter: new Point(),
nextCenter: new Point(),
offset: new Point(),
}
: undefined;
const manager = new LayoutManager();
const onBeforeLayout = jest
.spyOn(manager, 'onBeforeLayout')
.mockImplementation(() => {
lifecycle.push(onBeforeLayout);
});
const getLayoutResult = jest
.spyOn(manager, 'getLayoutResult')
.mockImplementation(() => {
lifecycle.push(getLayoutResult);
return layoutResult;
});
const commitLayout = jest
.spyOn(manager, 'commitLayout')
.mockImplementation(() => {
lifecycle.push(commitLayout);
});
const onAfterLayout = jest
.spyOn(manager, 'onAfterLayout')
.mockImplementation(() => {
lifecycle.push(onAfterLayout);
});
const targets = [new Group([new FabricObject()]), new FabricObject()];
const target = new Group(targets);
manager.performLayout({
type: LAYOUT_TYPE_INITIALIZATION,
target,
targets,
});
const context = {
bubbles: true,
strategy: manager.strategy,
type: LAYOUT_TYPE_INITIALIZATION,
target,
targets,
prevStrategy: undefined,
};
const expectedLifecycle = [
onBeforeLayout,
getLayoutResult,
...(result ? [commitLayout] : []),
onAfterLayout,
];
expect(lifecycle).toEqual(expectedLifecycle);
expectedLifecycle.forEach(
({
mock: {
calls: [[arg0]],
},
}) => expect(arg0).toMatchObject(context),
);
if (result) {
[commitLayout, onAfterLayout].forEach(
({
mock: {
calls: [[, arg1]],
},
}) => expect(arg1).toEqual(layoutResult),
);
} else {
[onAfterLayout].forEach(
({
mock: {
calls: [[, arg1]],
},
}) => expect(arg1).toBeUndefined(),
);
}
});
});
describe('onBeforeLayout', () => {
describe('triggers', () => {
const triggers: ('modified' | TModificationEvents | 'changed')[] = [
'modified',
'moving',
'resizing',
'rotating',
'scaling',
'skewing',
'changed',
'modifyPoly',
'modifyPath',
];
it('should subscribe object', () => {
const lifecycle: jest.SpyInstance[] = [];
const manager = new LayoutManager();
const unsubscribe = jest
.spyOn(manager, 'unsubscribe')
.mockImplementation(() => {
lifecycle.push(unsubscribe);
});
const object = new FabricObject();
const on = jest.spyOn(object, 'on').mockImplementation(() => {
lifecycle.push(on);
});
manager['subscribe'](object, {});
expect(lifecycle).toEqual([
unsubscribe,
...new Array(triggers.length).fill(on),
]);
expect(on.mock.calls.map(([arg0]) => arg0)).toEqual(triggers);
});
it('a subscribed object should trigger layout', () => {
const manager = new LayoutManager();
const performLayout = jest.spyOn(manager, 'performLayout');
const object = new FabricObject();
const target = new Group([object], { layoutManager: manager });
manager['subscribe'](object, { target });
performLayout.mockClear();
const event = { foo: 'bar' };
triggers.forEach((trigger) => object.fire(trigger, event));
expect(performLayout.mock.calls).toMatchObject([
[
{
e: event,
target,
trigger: 'modified',
type: 'object_modified',
},
],
...triggers.slice(1).map((trigger) => [
{
e: event,
target,
trigger,
type: 'object_modifying',
},
]),
]);
performLayout.mockClear();
expect(manager['_subscriptions'].get(object)).toBeDefined();
manager['unsubscribe'](object, { target });
expect(manager['_subscriptions'].get(object)).toBeUndefined();
triggers.forEach((trigger) => object.fire(trigger, event));
expect(performLayout).not.toHaveBeenCalled();
});
});
describe('triggers and event subscriptions', () => {
let manager: LayoutManager;
let targets: FabricObject[];
let target: Group;
let context: StrictLayoutContext;
beforeEach(() => {
manager = new LayoutManager();
targets = [
new Group([new FabricObject()], { layoutManager: manager }),
new FabricObject(),
];
target = new Group(targets, { layoutManager: manager });
target.canvas = { fire: jest.fn() };
jest.spyOn(target, 'fire');
context = {
bubbles: true,
strategy: manager.strategy,
type: LAYOUT_TYPE_INITIALIZATION,
target,
targets,
prevStrategy: undefined,
stopPropagation() {
this.bubbles = false;
},
};
});
it(`initialization trigger should subscribe targets and call target hooks`, () => {
jest.spyOn(manager, 'subscribe');
context.type = LAYOUT_TYPE_INITIALIZATION;
manager['onBeforeLayout'](context);
expect(manager['subscribe']).toHaveBeenCalledTimes(targets.length);
expect(manager['subscribe']).toHaveBeenCalledWith(targets[0], context);
expect(target.fire).toBeCalledWith('layout:before', {
context,
});
expect(target.canvas.fire).toBeCalledWith('object:layout:before', {
context,
target,
});
});
it(`object removed trigger should unsubscribe targets and call target hooks`, () => {
jest.spyOn(manager, 'unsubscribe');
context.type = LAYOUT_TYPE_REMOVED;
manager['onBeforeLayout'](context);
expect(manager['unsubscribe']).toHaveBeenCalledTimes(targets.length);
expect(manager['unsubscribe']).toHaveBeenCalledWith(
targets[0],
context,
);
expect(target.fire).toBeCalledWith('layout:before', {
context,
});
expect(target.canvas.fire).toBeCalledWith('object:layout:before', {
context,
target,
});
});
it(`object added trigger should subscribe targets and call target hooks`, () => {
jest.spyOn(manager, 'subscribe');
context.type = LAYOUT_TYPE_ADDED;
manager['onBeforeLayout'](context);
expect(manager['subscribe']).toHaveBeenCalledTimes(targets.length);
expect(manager['subscribe']).toHaveBeenCalledWith(targets[0], context);
expect(target.fire).toBeCalledWith('layout:before', {
context,
});
expect(target.canvas.fire).toBeCalledWith('object:layout:before', {
context,
target,
});
});
});
it('passing deep should layout the entire tree', () => {
const manager = new LayoutManager();
const grandchild = new Group([], { layoutManager: manager });
const child = new Group([grandchild, new FabricObject()], {
layoutManager: manager,
});
const targets = [child, new FabricObject()];
const target = new Group(targets, { layoutManager: manager });
const performLayout = jest.spyOn(manager, 'performLayout');
const context: StrictLayoutContext = {
bubbles: true,
strategy: manager.strategy,
type: LAYOUT_TYPE_IMPERATIVE,
deep: true,
target,
prevStrategy: undefined,
stopPropagation() {
this.bubbles = false;
},
};
manager['onBeforeLayout'](context);
expect(performLayout).toHaveBeenCalledTimes(2);
expect(performLayout.mock.calls[0][0]).toMatchObject({
bubbles: false,
type: LAYOUT_TYPE_IMPERATIVE,
deep: true,
target: child,
});
expect(performLayout.mock.calls[1][0]).toMatchObject({
bubbles: false,
type: LAYOUT_TYPE_IMPERATIVE,
deep: true,
target: grandchild,
});
});
});
describe('getLayoutResult', () => {
test.each([
{ type: LAYOUT_TYPE_INITIALIZATION, targets: [] },
{ type: LAYOUT_TYPE_IMPERATIVE },
] as const)('$type trigger', (options) => {
const manager = new LayoutManager();
jest.spyOn(manager.strategy, 'calcLayoutResult').mockReturnValue({
center: new Point(50, 100),
size: new Point(200, 250),
correction: new Point(10, 20),
relativeCorrection: new Point(-30, -40),
});
const rect = new FabricObject({ width: 50, height: 50 });
const target = new Group([rect], { scaleX: 2, scaleY: 0.5, angle: 30 });
const context: StrictLayoutContext = {
bubbles: true,
strategy: manager.strategy,
target,
targets: [rect],
...options,
stopPropagation() {
this.bubbles = false;
},
};
expect(manager['getLayoutResult'](context)).toMatchSnapshot({
cloneDeepWith: (value: any) => {
if (value instanceof Point) {
return new Point(Math.round(value.x), Math.round(value.y));
}
},
});
});
});
describe('commitLayout', () => {
const prepareTest = (
contextOptions: {
type: typeof LAYOUT_TYPE_INITIALIZATION | typeof LAYOUT_TYPE_ADDED;
} & Partial<LayoutContext>,
) => {
const lifecycle: jest.SpyInstance[] = [];
const targets = [new Group([new FabricObject()]), new FabricObject()];
const target = new Group(targets, { strokeWidth: 0 });
const targetSet = jest.spyOn(target, 'set').mockImplementation(() => {
lifecycle.push(targetSet);
});
const targetSetCoords = jest
.spyOn(target, 'setCoords')
.mockImplementation(() => {
lifecycle.push(targetSetCoords);
});
const targetSetPositionByOrigin = jest
.spyOn(target, 'setPositionByOrigin')
.mockImplementation(() => {
lifecycle.push(targetSetPositionByOrigin);
});
const manager = new LayoutManager();
const layoutObjects = jest
.spyOn(manager, 'layoutObjects')
.mockImplementation(() => {
lifecycle.push(layoutObjects);
});
const context: StrictLayoutContext = {
...contextOptions,
bubbles: true,
strategy: manager.strategy,
target,
targets,
prevStrategy: undefined,
stopPropagation() {
this.bubbles = false;
},
};
return {
manager,
context,
layoutResult: {
result: { center: new Point(5, 5), size: new Point(10, 10) },
prevCenter: new Point(),
nextCenter: new Point(5, 5),
offset: new Point(-5, -5),
},
targetMocks: {
set: targetSet,
setCoords: targetSetCoords,
setPositionByOrigin: targetSetPositionByOrigin,
},
mocks: {
layoutObjects,
},
lifecycle,
};
};
it.each([{}, { x: 10 }, { y: 10 }, { x: 10, y: 10 }] as const)(
'initialization trigger with %s should set size and position',
(pos) => {
const {
manager,
context,
layoutResult,
mocks: { layoutObjects },
targetMocks,
lifecycle,
} = prepareTest({ type: LAYOUT_TYPE_INITIALIZATION, ...pos });
const {
result: {
size: { x: width, y: height },
},
} = layoutResult;
manager['commitLayout'](context, layoutResult);
expect(lifecycle).toEqual([
targetMocks.set,
layoutObjects,
targetMocks.set,
]);
expect(targetMocks.set).nthCalledWith(1, { width, height });
expect(layoutObjects).toBeCalledWith(context, layoutResult);
expect(targetMocks.set).nthCalledWith(2, {
left: pos.x ?? 0,
top: pos.y ?? 0,
});
},
);
it('non initialization trigger should set size, position and invalidate target', () => {
const {
manager,
context,
layoutResult,
mocks: { layoutObjects },
targetMocks,
lifecycle,
} = prepareTest({ type: LAYOUT_TYPE_ADDED });
const {
result: {
size: { x: width, y: height },
},
} = layoutResult;
manager['commitLayout'](context, layoutResult);
expect(lifecycle).toEqual([
targetMocks.set,
layoutObjects,
targetMocks.setPositionByOrigin,
targetMocks.setCoords,
targetMocks.set,
]);
expect(targetMocks.set).nthCalledWith(1, { width, height });
expect(layoutObjects).toBeCalledWith(context, layoutResult);
expect(targetMocks.set).nthCalledWith(2, 'dirty', true);
});
});
describe('onAfterLayout', () => {
it.each([true, false])(
'should call target hooks with bubbling %s',
(bubbles) => {
const lifecycle: jest.SpyInstance[] = [];
const manager = new LayoutManager();
const targets = [
new Group([new FabricObject()], { layoutManager: manager }),
new FabricObject(),
];
const target = new Group(targets, { layoutManager: manager });
const targetFire = jest.spyOn(target, 'fire').mockImplementation(() => {
lifecycle.push(targetFire);
});
const parent = new Group([target], { layoutManager: manager });
const parentPerformLayout = jest
.spyOn(parent.layoutManager, 'performLayout')
.mockImplementation(() => {
lifecycle.push(parentPerformLayout);
});
const canvasFire = jest.fn();
target.canvas = { fire: canvasFire };
const context: StrictLayoutContext = {
bubbles,
strategy: manager.strategy,
type: LAYOUT_TYPE_ADDED,
target,
targets,
prevStrategy: undefined,
stopPropagation() {
this.bubbles = false;
},
};
const layoutResult: LayoutResult = {
result: { center: new Point(), size: new Point() },
prevCenter: new Point(),
nextCenter: new Point(),
offset: new Point(),
};
manager['onAfterLayout'](context, layoutResult);
expect(lifecycle).toEqual([
targetFire,
...(bubbles ? [parentPerformLayout] : []),
]);
expect(targetFire).toBeCalledWith('layout:after', {
context,
result: layoutResult,
});
expect(canvasFire).toBeCalledWith('object:layout:after', {
context,
result: layoutResult,
target,
});
bubbles &&
expect(parentPerformLayout.mock.calls[0]).toMatchObject([
{
type: LAYOUT_TYPE_ADDED,
targets,
target: parent,
path: [target],
},
]);
},
);
test('bubbling', () => {
const manager = new LayoutManager();
const manager2 = new LayoutManager();
const targets = [
new Group([new FabricObject()], { layoutManager: manager }),
new FabricObject(),
];
const target = new Group(targets, { layoutManager: manager });
const parent = new Group([target], { layoutManager: manager });
const grandParent = new Group([parent], { layoutManager: manager2 });
const grandParentPerformLayout = jest.spyOn(manager2, 'performLayout');
const context: StrictLayoutContext = {
bubbles: true,
strategy: manager.strategy,
type: LAYOUT_TYPE_ADDED,
target,
targets,
prevStrategy: undefined,
stopPropagation() {
this.bubbles = false;
},
};
const layoutResult: LayoutResult = {
result: { center: new Point(), size: new Point() },
prevCenter: new Point(),
nextCenter: new Point(),
offset: new Point(),
};
manager['onAfterLayout'](context, layoutResult);
expect(grandParentPerformLayout.mock.calls[0]).toMatchObject([
{
type: LAYOUT_TYPE_ADDED,
targets,
target: grandParent,
path: [target, parent],
},
]);
});
it('fires canvas events for a perform layout', () => {
const manager = new LayoutManager();
const targets = [
new Group([new FabricObject()], {
layoutManager: manager,
}),
new FabricObject(),
];
const target = new Group(targets, {
layoutManager: manager,
});
const parent = new Group([target], {
layoutManager: manager,
});
const grandParent = new Group([parent], {
layoutManager: manager,
});
const canvas = new StaticCanvas(undefined, { renderOnAddRemove: false });
const commonContext = {
type: 'imperative',
strategy: manager.strategy,
prevStrategy: manager.strategy,
bubbles: false,
deep: false,
stopPropagation: expect.any(Function),
};
canvas.add(grandParent);
jest.spyOn(canvas, 'fire');
grandParent.triggerLayout({
bubbles: false,
deep: false,
});
// first calls the event for the deep below target
expect(canvas.fire).toHaveBeenCalledWith('object:layout:before', {
target: grandParent,
context: {
target: grandParent,
...commonContext,
},
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:after', {
target: grandParent,
context: {
target: grandParent,
...commonContext,
bubbles: false,
},
result: expect.any(Object),
});
expect(canvas.fire.mock.calls.length).toBe(2);
});
it('fires canvas events for a perform layout deep: true', () => {
const manager = new LayoutManager();
const targets = [
new Group([new FabricObject()], {
layoutManager: manager,
}),
new FabricObject(),
];
const target = new Group(targets, {
layoutManager: manager,
});
const parent = new Group([target], {
layoutManager: manager,
});
const grandParent = new Group([parent], {
layoutManager: manager,
});
const canvas = new StaticCanvas(undefined, {
renderOnAddRemove: false,
});
const commonContext = {
type: 'imperative',
strategy: manager.strategy,
prevStrategy: manager.strategy,
bubbles: false,
deep: true,
stopPropagation: expect.any(Function),
};
canvas.add(grandParent);
jest.spyOn(canvas, 'fire');
grandParent.triggerLayout({
bubbles: false,
deep: true,
});
// first calls the event for the deep below target
expect(canvas.fire).toHaveBeenCalledWith('object:layout:before', {
target: grandParent,
context: {
target: grandParent,
...commonContext,
},
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:after', {
target: grandParent,
context: {
target: grandParent,
...commonContext,
bubbles: false,
},
result: expect.any(Object),
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:before', {
target: parent,
context: {
target: parent,
...commonContext,
},
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:after', {
target: parent,
context: {
target: parent,
...commonContext,
bubbles: false,
},
result: expect.any(Object),
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:before', {
target: target,
context: {
target: target,
...commonContext,
},
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:after', {
target: target,
context: {
target: target,
...commonContext,
bubbles: false,
},
result: expect.any(Object),
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:before', {
target: targets[0],
context: {
target: targets[0],
...commonContext,
},
});
expect(canvas.fire).toHaveBeenCalledWith('object:layout:after', {
target: targets[0],
context: {
target: targets[0],
...commonContext,
bubbles: false,
},
result: expect.any(Object),
});
expect(canvas.fire.mock.calls.length).toBe(8);
});
});
describe('Group initial layout', () => {
it('fit content layout should ignore size passed in options', () => {
const child = new FabricObject({
width: 200,
height: 200,
strokeWidth: 0,
});
const group = new Group([child], {
width: 300,
height: 300,
strokeWidth: 0,
layoutManager: new LayoutManager(),
});
expect(child.getRelativeCenterPoint()).toMatchObject({ x: 0, y: 0 });
expect(group.getCenterPoint()).toMatchObject({ x: 100, y: 100 });
expect(child.getCenterPoint()).toMatchObject(group.getCenterPoint());
});
it('should subscribe objects on initialization', () => {
const child = new FabricObject({
width: 200,
height: 200,
strokeWidth: 0,
});
jest.spyOn(child, 'toJSON').mockReturnValue('child');
const group = new Group([child]);
expect(
Array.from(group.layoutManager['_subscriptions'].keys()),
).toMatchObject([child]);
});
describe('fromObject restore', () => {
const createTestData = (type: string) => ({
width: 2,
height: 3,
left: 6,
top: 4,
strokeWidth: 0,
objects: [
new Rect({
width: 100,
height: 100,
top: 0,
left: 0,
strokeWidth: 0,
}).toObject(),
new Rect({
width: 100,
height: 100,
top: 0,
left: 0,
strokeWidth: 0,
}).toObject(),
],
clipPath: new Rect({
width: 50,
height: 50,
top: 0,
left: 0,
strokeWidth: 0,
}).toObject(),
layoutManager: {
type: 'layoutManager',
strategy: type,
},
});
describe('Fitcontent layout', () => {
it('should subscribe objects', async () => {
const group = await Group.fromObject(
createTestData(FitContentLayout.type),
);
expect(
Array.from(group.layoutManager['_subscriptions'].keys()),
).toMatchObject(group.getObjects());
});
});
describe('FixedLayout layout', () => {
it('should subscribe objects', async () => {
const group = await Group.fromObject(
createTestData(FixedLayout.type),
);
expect(
Array.from(group.layoutManager['_subscriptions'].keys()),
).toMatchObject(group.getObjects());
});
});
describe('ClipPathLayout layout', () => {
it('should subscribe objects', async () => {
const group = await Group.fromObject(
createTestData(ClipPathLayout.type),
);
expect(
Array.from(group.layoutManager['_subscriptions'].keys()),
).toMatchObject(group.getObjects());
});
});
});
test.each([true, false])(
'initialization edge case, with specified layoutManager %s',
(legacy) => {
const child = new FabricObject({
width: 200,
height: 200,
strokeWidth: 0,
});
const group = new Group([child], {
width: 200,
height: 200,
strokeWidth: 0,
layoutManager: legacy ? undefined : new LayoutManager(),
});
expect(group).toMatchObject({ width: 200, height: 200 });
expect(child.getRelativeCenterPoint()).toMatchObject({ x: 0, y: 0 });
expect(group.getCenterPoint()).toMatchObject({ x: 100, y: 100 });
expect(child.getCenterPoint()).toMatchObject(group.getCenterPoint());
},
);
it('fixed layout should respect size passed in options', () => {
const child = new FabricObject({
width: 200,
height: 200,
strokeWidth: 0,
});
const group = new Group([child], {
width: 100,
height: 300,
strokeWidth: 0,
layoutManager: new LayoutManager(new FixedLayout()),
});
expect(group).toMatchObject({ width: 100, height: 300 });
expect(child.getCenterPoint()).toMatchObject({ x: 100, y: 100 });
expect(group.getCenterPoint()).toMatchObject({ x: 100, y: 100 });
});
});
});