import { Point } from '../Point'; import { CENTER, iMatrix } from '../constants'; import type { Group } from '../shapes/Group'; import type { FabricObject } from '../shapes/Object/FabricObject'; import { invertTransform } from '../util/misc/matrix'; import { resolveOrigin } from '../util/misc/resolveOrigin'; import { FitContentLayout } from './LayoutStrategies/FitContentLayout'; import type { LayoutStrategy } from './LayoutStrategies/LayoutStrategy'; import { LAYOUT_TYPE_INITIALIZATION, LAYOUT_TYPE_ADDED, LAYOUT_TYPE_REMOVED, LAYOUT_TYPE_IMPERATIVE, LAYOUT_TYPE_OBJECT_MODIFIED, LAYOUT_TYPE_OBJECT_MODIFYING, } from './constants'; import type { LayoutContext, LayoutResult, RegistrationContext, StrictLayoutContext, } from './types'; import { classRegistry } from '../ClassRegistry'; import type { TModificationEvents } from '../EventTypeDefs'; const LAYOUT_MANAGER = 'layoutManager'; export type SerializedLayoutManager = { type: string; strategy: string; }; export class LayoutManager { private declare _prevLayoutStrategy?: LayoutStrategy; protected declare _subscriptions: Map; strategy: LayoutStrategy; constructor(strategy: LayoutStrategy = new FitContentLayout()) { this.strategy = strategy; this._subscriptions = new Map(); } public performLayout(context: LayoutContext) { const strictContext: StrictLayoutContext = { bubbles: true, strategy: this.strategy, ...context, prevStrategy: this._prevLayoutStrategy, stopPropagation() { this.bubbles = false; }, }; this.onBeforeLayout(strictContext); const layoutResult = this.getLayoutResult(strictContext); if (layoutResult) { this.commitLayout(strictContext, layoutResult); } this.onAfterLayout(strictContext, layoutResult); this._prevLayoutStrategy = strictContext.strategy; } /** * Attach handlers for events that we know will invalidate the layout when * performed on child objects ( general transforms ). * Returns the disposers for later unsubscribing and cleanup * @param {FabricObject} object * @param {RegistrationContext & Partial} context * @returns {VoidFunction[]} disposers remove the handlers */ protected attachHandlers( object: FabricObject, context: RegistrationContext & Partial ): VoidFunction[] { const { target } = context; return ( [ 'modified', 'moving', 'resizing', 'rotating', 'scaling', 'skewing', 'changed', 'modifyPoly', ] as (TModificationEvents & 'modified')[] ).map((key) => object.on(key, (e) => this.performLayout( key === 'modified' ? { type: LAYOUT_TYPE_OBJECT_MODIFIED, trigger: key, e, target, } : { type: LAYOUT_TYPE_OBJECT_MODIFYING, trigger: key, e, target, } ) ) ); } /** * Subscribe an object to transform events that will trigger a layout change on the parent * This is important only for interactive groups. * @param object * @param context */ protected subscribe( object: FabricObject, context: RegistrationContext & Partial ) { this.unsubscribe(object, context); const disposers = this.attachHandlers(object, context); this._subscriptions.set(object, disposers); } /** * unsubscribe object layout triggers */ protected unsubscribe( object: FabricObject, context?: RegistrationContext & Partial ) { (this._subscriptions.get(object) || []).forEach((d) => d()); this._subscriptions.delete(object); } unsubscribeTargets( context: RegistrationContext & Partial ) { context.targets.forEach((object) => this.unsubscribe(object, context)); } subscribeTargets( context: RegistrationContext & Partial ) { context.targets.forEach((object) => this.subscribe(object, context)); } protected onBeforeLayout(context: StrictLayoutContext) { const { target, type } = context; const { canvas } = target; // handle layout triggers subscription // @TODO: gate the registration when the group is interactive if (type === LAYOUT_TYPE_INITIALIZATION || type === LAYOUT_TYPE_ADDED) { this.subscribeTargets(context); } else if (type === LAYOUT_TYPE_REMOVED) { this.unsubscribeTargets(context); } // fire layout event (event will fire only for layouts after initialization layout) target.fire('layout:before', { context, }); canvas && canvas.fire('object:layout:before', { target, context, }); if (type === LAYOUT_TYPE_IMPERATIVE && context.deep) { const { strategy: _, ...tricklingContext } = context; // traverse the tree target.forEachObject( (object) => (object as Group).layoutManager && (object as Group).layoutManager.performLayout({ ...tricklingContext, bubbles: false, target: object as Group, }) ); } } protected getLayoutResult( context: StrictLayoutContext ): Required | undefined { const { target } = context; const result = context.strategy.calcLayoutResult( context, target.getObjects() ); if (!result) { return; } const prevCenter = context.type === LAYOUT_TYPE_INITIALIZATION ? new Point() : target.getRelativeCenterPoint(); const { center: nextCenter, correction = new Point(), relativeCorrection = new Point(), } = result; const offset = prevCenter .subtract(nextCenter) .add(correction) .transform( // in `initialization` we do not account for target's transformation matrix context.type === LAYOUT_TYPE_INITIALIZATION ? iMatrix : invertTransform(target.calcOwnMatrix()), true ) .add(relativeCorrection); return { result, prevCenter, nextCenter, offset, }; } protected commitLayout( context: StrictLayoutContext, layoutResult: Required ) { const { target } = context; const { result: { size }, nextCenter, } = layoutResult; // set dimensions target.set({ width: size.x, height: size.y }); // layout descendants this.layoutObjects(context, layoutResult); // set position // in `initialization` we do not account for target's transformation matrix if (context.type === LAYOUT_TYPE_INITIALIZATION) { // TODO: what about strokeWidth? target.set({ left: context.x ?? nextCenter.x + size.x * resolveOrigin(target.originX), top: context.y ?? nextCenter.y + size.y * resolveOrigin(target.originY), }); } else { target.setPositionByOrigin(nextCenter, CENTER, CENTER); // invalidate target.setCoords(); target.set('dirty', true); } } protected layoutObjects( context: StrictLayoutContext, layoutResult: Required ) { const { target } = context; // adjust objects to account for new center target.forEachObject((object) => { object.group === target && this.layoutObject(context, layoutResult, object); }); // adjust clip path to account for new center context.strategy.shouldLayoutClipPath(context) && this.layoutObject(context, layoutResult, target.clipPath as FabricObject); } /** * @param {FabricObject} object * @param {Point} offset */ protected layoutObject( context: StrictLayoutContext, { offset }: Required, object: FabricObject ) { // TODO: this is here for cache invalidation. // verify if this is necessary since we have explicit // cache invalidation at the end of commitLayout object.set({ left: object.left + offset.x, top: object.top + offset.y, }); } protected onAfterLayout( context: StrictLayoutContext, layoutResult?: LayoutResult ) { const { target, strategy, bubbles, prevStrategy: _, ...bubblingContext } = context; const { canvas } = target; // fire layout event (event will fire only for layouts after initialization layout) target.fire('layout:after', { context, result: layoutResult, }); canvas && canvas.fire('object:layout:after', { context, result: layoutResult, target, }); // bubble const parent = target.parent; if (bubbles && parent?.layoutManager) { // add target to context#path (bubblingContext.path || (bubblingContext.path = [])).push(target); // all parents should invalidate their layout parent.layoutManager.performLayout({ ...bubblingContext, target: parent, }); } target.set('dirty', true); } dispose() { this._subscriptions.forEach((disposers) => disposers.forEach((d) => d())); this._subscriptions.clear(); } toObject() { return { type: LAYOUT_MANAGER, strategy: (this.strategy.constructor as typeof LayoutStrategy).type, }; } toJSON() { return this.toObject(); } } classRegistry.setClass(LayoutManager, LAYOUT_MANAGER);