Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat() LayoutManager save/restore from object #9564

Merged
merged 9 commits into from
Jan 6, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [next]

- feat(): Add save/restore ability to group LayoutManager [#9564](https://github.com/fabricjs/fabric.js/pull/9564)
- refactor(): Separate defaults for base fabric object vs interactive object. Also some moving around of variables [#9474](https://github.com/fabricjs/fabric.js/pull/9474)
- refactor(): Change how LayoutManager handles restoring groups [#9522](https://github.com/fabricjs/fabric.js/pull/9522)
- fix(BaseConfiguration): set `devicePixelRatio` from window [#9470](https://github.com/fabricjs/fabric.js/pull/9470)
Expand Down
1 change: 1 addition & 0 deletions fabric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export type {
} from './src/shapes/Group';
export { Group } from './src/shapes/Group';
export * from './src/LayoutManager';
export type { SerializedLayoutManager } from './src/LayoutManager';
export type {
ActiveSelectionOptions,
MultiSelectionStacking,
Expand Down
17 changes: 16 additions & 1 deletion src/LayoutManager/LayoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import {
LAYOUT_TYPE_OBJECT_MODIFYING,
} from './constants';
import type { LayoutContext, LayoutResult, StrictLayoutContext } from './types';
import { classRegistry } from '../ClassRegistry';

const LAYOUT_MANAGER = 'layoutManager';

export type SerializedLayoutManager = {
type: string;
strategy: string;
};

export class LayoutManager {
private declare _prevLayoutStrategy?: LayoutStrategy;
Expand Down Expand Up @@ -289,9 +297,16 @@ export class LayoutManager {
this._subscriptions.clear();
}

toJSON() {
toObject() {
return {
type: LAYOUT_MANAGER,
strategy: (this.strategy.constructor as typeof LayoutStrategy).type,
};
}

toJSON() {
return this.toObject();
}
}

classRegistry.setClass(LayoutManager, LAYOUT_MANAGER);
4 changes: 4 additions & 0 deletions src/LayoutManager/LayoutStrategies/ClipPathLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { sendPointToPlane } from '../../util/misc/planeChange';
import type { LayoutStrategyResult, StrictLayoutContext } from '../types';
import { LayoutStrategy } from './LayoutStrategy';
import { getObjectBounds } from './utils';
import { classRegistry } from '../../ClassRegistry';

/**
* Layout will adjust the bounding box to match the clip path bounding box.
Expand All @@ -29,6 +30,7 @@ export class ClipPathLayout extends LayoutStrategy {
if (!clipPath || !this.shouldPerformLayout(context)) {
return;
}
// TODO: remove stroke calculation from this case
const { width, height } = makeBoundingBoxFromPoints(
getObjectBounds(target, clipPath as FabricObject)
);
Expand Down Expand Up @@ -68,3 +70,5 @@ export class ClipPathLayout extends LayoutStrategy {
}
}
}

classRegistry.setClass(ClipPathLayout);
3 changes: 3 additions & 0 deletions src/LayoutManager/LayoutStrategies/FitContentLayout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { StrictLayoutContext } from '../types';
import { LayoutStrategy } from './LayoutStrategy';
import { classRegistry } from '../../ClassRegistry';

/**
* Layout will adjust the bounding box to fit target's objects.
Expand All @@ -16,3 +17,5 @@ export class FitContentLayout extends LayoutStrategy {
return true;
}
}

classRegistry.setClass(FitContentLayout);
3 changes: 3 additions & 0 deletions src/LayoutManager/LayoutStrategies/FixedLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
StrictLayoutContext,
} from '../types';
import { LayoutStrategy } from './LayoutStrategy';
import { classRegistry } from '../../ClassRegistry';

/**
* Layout will keep target's initial size.
Expand All @@ -22,3 +23,5 @@ export class FixedLayout extends LayoutStrategy {
return new Point(target.width || size.x, target.height || size.y);
}
}

classRegistry.setClass(FixedLayout);
152 changes: 147 additions & 5 deletions src/shapes/Group.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import { FixedLayout } from '../LayoutManager';
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';

const makeGenericGroup = (options?: Partial<GroupProps>) => {
const objs = [new FabricObject(), new FabricObject()];
const group = new Group(objs, options);
return {
group,
originalObjs: objs,
};
};

describe('Group', () => {
it('avoid mutations to passed objects array', () => {
const objs = [new FabricObject(), new FabricObject()];
const group = new Group(objs);
const { group, originalObjs } = makeGenericGroup();

group.add(new FabricObject());

expect(group._objects).not.toBe(objs);
expect(objs).toHaveLength(2);
expect(group._objects).not.toBe(originalObjs);
expect(originalObjs).toHaveLength(2);
expect(group._objects).toHaveLength(3);
});

Expand Down Expand Up @@ -45,6 +59,15 @@ describe('Group', () => {
});

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,
Expand Down Expand Up @@ -115,6 +138,125 @@ describe('Group', () => {
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);
// TO DO BUG: this should be 30
expect(group.width).toBe(31);
expect(group.height).toBe(11);
Comment on lines +249 to +250
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Member Author

@asturur asturur Jan 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clippaths do not render border by our spec ( and svg spec ) so if you have a clipPath object with a huge border, that won't do anything and will look like extra padding in the group around the are a you wanted to clip to.

Look here when we render clipPath:

  /**
   * Execute the drawing operation for an object on a specified context
   * @param {CanvasRenderingContext2D} ctx Context to render on
   * @param {boolean} forClipping apply clipping styles
   */
  drawObject(ctx: CanvasRenderingContext2D, forClipping?: boolean) {
    const originalFill = this.fill,
      originalStroke = this.stroke;
    if (forClipping) {
      this.fill = 'black';
      this.stroke = '';
      this._setClippingProperties(ctx);
    } else {
      this._renderBackground(ctx);
    }
    this._render(ctx);
    this._drawClipPath(ctx, this.clipPath);
    this.fill = originalFill;
    this.stroke = originalStroke;
  }

if is 'forClipping', stroke is emptied.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know that

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i can't find the written spec w3g anymore https://www.w3.org/TR/2000/WD-SVG-20000629/masking.html#ClippingPaths but i m sure we can do an SVG and verify.

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);
// TO DO BUG: this should be 30
expect(group.width).toBe(31);
expect(group.height).toBe(11);
});
});

it('triggerLayout should preform layout, layoutManager is defined', () => {
Expand Down
16 changes: 15 additions & 1 deletion src/shapes/Group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
LAYOUT_TYPE_INITIALIZATION,
LAYOUT_TYPE_REMOVED,
} from '../LayoutManager/constants';
import type { SerializedLayoutManager } from '../LayoutManager/LayoutManager';

/**
* This class handles the specific case of creating a group using {@link Group#fromObject} and is not meant to be used in any other case.
Expand All @@ -53,6 +54,7 @@ export interface SerializedGroupProps
extends SerializedObjectProps,
GroupOwnProps {
objects: SerializedObjectProps[];
layoutManager: SerializedLayoutManager;
}

export interface GroupProps extends FabricObjectProps, GroupOwnProps {
Expand Down Expand Up @@ -546,12 +548,17 @@ export class Group
>,
K extends keyof T = never
>(propertiesToInclude: K[] = []): Pick<T, K> & SerializedGroupProps {
const layoutManager = this.layoutManager.toObject();

return {
...super.toObject([
'subTargetCheck',
'interactive',
...propertiesToInclude,
]),
...(layoutManager.strategy !== 'fit-content' || this.includeDefaultValues
? { layoutManager }
: {}),
objects: this.__serializeObjects(
'toObject',
propertiesToInclude as string[]
Expand Down Expand Up @@ -643,6 +650,7 @@ export class Group
*/
static fromObject<T extends TOptions<SerializedGroupProps>>({
objects = [],
layoutManager,
...options
}: T) {
return Promise.all([
Expand All @@ -654,7 +662,13 @@ export class Group
...hydratedOptions,
layoutManager: new NoopLayoutManager(),
});
group.layoutManager = new LayoutManager();
if (layoutManager) {
const layoutClass = classRegistry.getClass(layoutManager.type);
const strategyClass = classRegistry.getClass(layoutManager.strategy);
group.layoutManager = new layoutClass(new strategyClass());
} else {
group.layoutManager = new LayoutManager();
}
group.setCoords();
return group;
});
Expand Down
6 changes: 5 additions & 1 deletion test/unit/activeselection.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@
skewX: 0,
skewY: 0,
strokeUniform: false,
objects: clone.objects
objects: clone.objects,
layoutManager: {
type: 'layoutManager',
strategy: 'fit-content',
},
};

assert.deepEqual(clone, expectedObject);
Expand Down
4 changes: 4 additions & 0 deletions test/unit/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@
strokeUniform: false,
subTargetCheck: false,
interactive: false,
layoutManager: {
type: 'layoutManager',
strategy: 'fit-content',
},
};

assert.deepEqual(clone, expectedObject);
Expand Down
Loading