-
-
Notifications
You must be signed in to change notification settings - Fork 3.5k
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): Handle the case of activeSelection with objects inside different groups #9651
Changes from 36 commits
8444bab
4249a31
b96b7fc
0c3a83f
4bca8ae
4738d2c
54057f6
e7ed235
901d8a6
a2eaa02
0db05ed
b838249
29bdffe
85d7665
4a08e59
bbb3195
9f8db27
02fc994
9a5f2fe
fe2069c
0076faa
0b2795d
4e4ae51
58d52fd
a4f1806
118b68e
df2972b
5f27bf1
fc06fbc
b83b65e
6b26a1a
6aa468c
46f2167
39e3732
653a067
98768a5
a22cc7c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { expect, test } from '@playwright/test'; | ||
import setup from '../../../setup'; | ||
import { CanvasUtil } from '../../../utils/CanvasUtil'; | ||
import { ObjectUtil } from '../../../utils/ObjectUtil'; | ||
import type { Circle } from 'fabric'; | ||
|
||
setup(); | ||
|
||
test('Activeselection across interactive groups', async ({ page }) => { | ||
const canvasUtil = new CanvasUtil(page); | ||
const circle1Util = new ObjectUtil<Circle>(page, 'circle1'); | ||
const circle2Util = new ObjectUtil<Circle>(page, 'circle2'); | ||
const circle3Util = new ObjectUtil<Circle>(page, 'circle3'); | ||
const circle4Util = new ObjectUtil<Circle>(page, 'circle4'); | ||
expect(await canvasUtil.screenshot()).toMatchSnapshot({ | ||
name: 'initial-layout.png', | ||
}); | ||
await canvasUtil.makeActiveSelectionWith([ | ||
circle1Util, | ||
circle2Util, | ||
circle3Util, | ||
circle4Util, | ||
]); | ||
expect(await canvasUtil.screenshot()).toMatchSnapshot({ | ||
name: 'initial-layout-with-activeSelection.png', | ||
}); | ||
const { x, y } = await circle1Util.getObjectCenter(); | ||
await canvasUtil.clickAndDrag({ x, y }, { x: 30, y: y + 100 }, 80); | ||
expect(await canvasUtil.screenshot()).toMatchSnapshot({ | ||
name: 'after-moving-objects.png', | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
/** | ||
* Runs in the **BROWSER** | ||
* Imports are defined in 'e2e/imports.ts' | ||
*/ | ||
|
||
import { Rect, Circle, Group, ClipPathLayout } from 'fabric'; | ||
import { beforeAll } from '../../test'; | ||
|
||
beforeAll(async (canvas) => { | ||
canvas.setDimensions({ width: 450, height: 450 }); | ||
|
||
canvas.preserveObjectStacking = true; | ||
const circle1 = new Circle({ left: 100, top: 50, radius: 50 }); | ||
const g = new Group( | ||
[ | ||
new Rect({ | ||
top: 200, | ||
width: 50, | ||
height: 50, | ||
fill: 'red', | ||
opacity: 0.3, | ||
}), | ||
circle1, | ||
], | ||
{ | ||
backgroundColor: 'blue', | ||
subTargetCheck: true, | ||
interactive: true, | ||
} | ||
); | ||
canvas.add(g); | ||
const clone1 = await g.clone(); | ||
clone1.set({ | ||
top: clone1.top + 200, | ||
left: clone1.left + 200, | ||
backgroundColor: 'red', | ||
}); | ||
const circle2 = clone1.item(1); | ||
canvas.add(clone1); | ||
const clone3 = await g.clone(); | ||
clone3.set({ | ||
top: clone3.top + 200, | ||
backgroundColor: 'yellow', | ||
clipPath: new Circle({ | ||
radius: 110, | ||
originX: 'center', | ||
originY: 'center', | ||
group: clone3, | ||
}), | ||
}); | ||
clone3.layoutManager.strategy = new ClipPathLayout(); | ||
clone3.triggerLayout(); | ||
const circle3 = clone3.item(1); | ||
canvas.add(clone3); | ||
|
||
const clone4 = await g.clone(); | ||
clone4.set({ | ||
left: clone4.left + 200, | ||
backgroundColor: 'cyan', | ||
clipPath: new Circle({ | ||
radius: 110, | ||
originX: 'center', | ||
originY: 'center', | ||
absolutePositioned: true, | ||
left: 250, | ||
top: 150, | ||
skewX: 20, | ||
}), | ||
}); | ||
clone4.layoutManager.strategy = new ClipPathLayout(); | ||
clone4.triggerLayout(); | ||
const circle4 = clone4.item(1); | ||
canvas.insertAt(0, clone4); | ||
|
||
return { circle1, circle2, circle3, circle4 }; | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import type { TModificationEvents } from '../EventTypeDefs'; | ||
import { ActiveSelection } from '../shapes/ActiveSelection'; | ||
import { Group } from '../shapes/Group'; | ||
import { FabricObject } from '../shapes/Object/FabricObject'; | ||
import { ActiveSelectionLayoutManager } from './ActiveSelectionLayoutManager'; | ||
|
||
describe('ActiveSelectionLayoutManager', () => { | ||
describe('onBeforeLayout', () => { | ||
describe('triggers', () => { | ||
const triggers: ('modified' | TModificationEvents | 'changed')[] = [ | ||
'modified', | ||
'moving', | ||
'resizing', | ||
'rotating', | ||
'scaling', | ||
'skewing', | ||
'changed', | ||
'modifyPoly', | ||
]; | ||
|
||
it('should subscribe activeSelection that contains object', () => { | ||
const manager = new ActiveSelectionLayoutManager(); | ||
const object = new FabricObject(); | ||
const group = new Group([object], { | ||
interactive: true, | ||
subTargetCheck: true, | ||
}); | ||
const as = new ActiveSelection([object], { layoutManager: manager }); | ||
const objectOn = jest.spyOn(object, 'on'); | ||
const objectOff = jest.spyOn(object, 'off'); | ||
const asOn = jest.spyOn(as, 'on'); | ||
manager.subscribeTargets({ | ||
targets: [object], | ||
target: as, | ||
}); | ||
expect(objectOn).not.toHaveBeenCalled(); | ||
expect(objectOff).not.toHaveBeenCalled(); | ||
expect(asOn).toHaveBeenCalledTimes(triggers.length); | ||
expect(objectOff).not.toHaveBeenCalled(); | ||
}); | ||
asturur marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
it('a subscribed activeSelection should trigger layout on the object parent once per parent', () => { | ||
asturur marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const manager = new ActiveSelectionLayoutManager(); | ||
const object = new FabricObject(); | ||
const object2 = new FabricObject(); | ||
const object3 = new FabricObject(); | ||
const object4 = new FabricObject(); | ||
const group = new Group([object, object2], { | ||
interactive: true, | ||
subTargetCheck: true, | ||
}); | ||
const group2 = new Group([object3, object4], { | ||
interactive: true, | ||
subTargetCheck: true, | ||
}); | ||
const as = new ActiveSelection([object, object2, object3, object4], { | ||
layoutManager: manager, | ||
}); | ||
const asPerformLayout = jest.spyOn(manager, 'performLayout'); | ||
const groupPerformLayout = jest.spyOn( | ||
group.layoutManager, | ||
'performLayout' | ||
); | ||
const groupPerformLayout2 = jest.spyOn( | ||
group2.layoutManager, | ||
'performLayout' | ||
); | ||
groupPerformLayout.mockClear(); | ||
groupPerformLayout2.mockClear(); | ||
asPerformLayout.mockClear(); | ||
expect(group.layoutManager['_subscriptions'].get(as)).toBeDefined(); | ||
expect(group2.layoutManager['_subscriptions'].get(as)).toBeDefined(); | ||
expect(manager['_subscriptions'].size).toBe(0); | ||
|
||
const event = { foo: 'bar' }; | ||
triggers.forEach((trigger) => as.fire(trigger, event)); | ||
expect(asPerformLayout).not.toHaveBeenCalled(); | ||
expect(groupPerformLayout.mock.calls).toMatchObject([ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted to do the same for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It happened to me too, and there was a bug in the code in that moment. Maybe is worth double checking There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. still happening There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok i ll look with fresh eyes, maybe there is a situation in which you get an infinite nested loop. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no issues for me, it works fine There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you should have committed it There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i did i pushed up commits with the checks for groupPerformLayout2 mirrored to groupPerformLayout There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missed that! Great! |
||
[ | ||
{ | ||
e: event, | ||
target: group, | ||
trigger: 'modified', | ||
type: 'object_modified', | ||
}, | ||
], | ||
...triggers.slice(1).map((trigger) => [ | ||
{ | ||
e: event, | ||
target: group, | ||
trigger, | ||
type: 'object_modifying', | ||
}, | ||
]), | ||
]); | ||
expect(groupPerformLayout).toHaveBeenCalledTimes(triggers.length); | ||
expect(groupPerformLayout2).toHaveBeenCalledTimes(triggers.length); | ||
|
||
as.remove(object); | ||
groupPerformLayout.mockClear(); | ||
groupPerformLayout2.mockClear(); | ||
asPerformLayout.mockClear(); | ||
|
||
triggers.forEach((trigger) => as.fire(trigger, event)); | ||
expect(asPerformLayout).not.toHaveBeenCalled(); | ||
expect(groupPerformLayout.mock.calls).toMatchObject([ | ||
[ | ||
{ | ||
e: event, | ||
target: group, | ||
trigger: 'modified', | ||
type: 'object_modified', | ||
}, | ||
], | ||
...triggers.slice(1).map((trigger) => [ | ||
{ | ||
e: event, | ||
target: group, | ||
trigger, | ||
type: 'object_modifying', | ||
}, | ||
]), | ||
]); | ||
expect(groupPerformLayout).toHaveBeenCalledTimes(triggers.length); | ||
expect(groupPerformLayout2).toHaveBeenCalledTimes(triggers.length); | ||
|
||
groupPerformLayout.mockClear(); | ||
groupPerformLayout2.mockClear(); | ||
asPerformLayout.mockClear(); | ||
|
||
as.remove(object2); | ||
expect(group.layoutManager['_subscriptions'].get(as)).toBeUndefined(); | ||
expect(group2.layoutManager['_subscriptions'].get(as)).toBeDefined(); | ||
as.removeAll(); | ||
expect(group2.layoutManager['_subscriptions'].get(as)).toBeUndefined(); | ||
|
||
groupPerformLayout.mockClear(); | ||
groupPerformLayout2.mockClear(); | ||
asPerformLayout.mockClear(); | ||
|
||
triggers.forEach((trigger) => as.fire(trigger, event)); | ||
expect(groupPerformLayout).not.toHaveBeenCalled(); | ||
expect(groupPerformLayout2).not.toHaveBeenCalled(); | ||
expect(asPerformLayout).not.toHaveBeenCalled(); | ||
}); | ||
}); | ||
}); | ||
}); |
ShaMan123 marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { LayoutManager } from './LayoutManager'; | ||
import type { RegistrationContext, StrictLayoutContext } from './types'; | ||
import type { Group } from '../shapes/Group'; | ||
|
||
/** | ||
* Today the LayoutManager class also takes care of subscribing event handlers | ||
* to update the group layout when the group is interactive and a transform is applied | ||
* to a child object. | ||
* The ActiveSelection is never interactive, but it could contain objects from | ||
* groups that are. | ||
* The standard LayoutManager would subscribe the children of the activeSelection to | ||
* perform layout changes to the active selection itself, what we need instead is that | ||
* the transformation applied to the active selection will trigger changes to the | ||
* original group of the children ( the one referenced under the parent property ) | ||
* This subclass of the LayoutManager has a single duty to fill the gap of this difference.` | ||
*/ | ||
export class ActiveSelectionLayoutManager extends LayoutManager { | ||
subscribeTargets( | ||
context: RegistrationContext & Partial<StrictLayoutContext> | ||
): void { | ||
const activeSelection = context.target; | ||
const parents = context.targets.reduce((parents, target) => { | ||
target.parent && parents.add(target.parent); | ||
return parents; | ||
}, new Set<Group>()); | ||
parents.forEach((parent) => { | ||
parent.layoutManager.subscribeTargets({ | ||
target: parent, | ||
targets: [activeSelection], | ||
}); | ||
}); | ||
} | ||
|
||
/** | ||
* unsubscribe from parent only if all its children were deselected | ||
*/ | ||
unsubscribeTargets( | ||
context: RegistrationContext & Partial<StrictLayoutContext> | ||
): void { | ||
const activeSelection = context.target; | ||
const selectedObjects = activeSelection.getObjects(); | ||
const parents = context.targets.reduce((parents, target) => { | ||
target.parent && parents.add(target.parent); | ||
return parents; | ||
}, new Set<Group>()); | ||
parents.forEach((parent) => { | ||
!selectedObjects.some((object) => object.parent === parent) && | ||
parent.layoutManager.unsubscribeTargets({ | ||
target: parent, | ||
targets: [activeSelection], | ||
}); | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great test