UNPKG

fabric

Version:

Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.

722 lines (667 loc) 21.1 kB
import type { CollectionEvents, ObjectEvents } from '../EventTypeDefs'; import { createCollectionMixin } from '../Collection'; import type { TClassProperties, TSVGReviver, TOptions, Abortable, } from '../typedefs'; import { invertTransform, multiplyTransformMatrices, } from '../util/misc/matrix'; import { enlivenObjectEnlivables, enlivenObjects, } from '../util/misc/objectEnlive'; import { applyTransformToObject } from '../util/misc/objectTransforms'; import { FabricObject } from './Object/FabricObject'; import { Rect } from './Rect'; import { classRegistry } from '../ClassRegistry'; import type { FabricObjectProps, SerializedObjectProps } from './Object/types'; import { log } from '../util/internals/console'; import type { ImperativeLayoutOptions, LayoutBeforeEvent, LayoutAfterEvent, } from '../LayoutManager/types'; import { LayoutManager } from '../LayoutManager/LayoutManager'; import { LAYOUT_TYPE_ADDED, LAYOUT_TYPE_IMPERATIVE, LAYOUT_TYPE_INITIALIZATION, LAYOUT_TYPE_REMOVED, } from '../LayoutManager/constants'; import type { SerializedLayoutManager } from '../LayoutManager/LayoutManager'; import type { FitContentLayout } from '../LayoutManager'; import type { DrawContext } from './Object/Object'; /** * 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. * We could have used a boolean in the constructor, as we did previously, but we think the boolean * would stay in the group's constructor interface and create confusion, therefore it was removed. * This layout manager doesn't do anything and therefore keeps the exact layout the group had when {@link Group#toObject} was called. */ class NoopLayoutManager extends LayoutManager { performLayout() {} } export interface GroupEvents extends ObjectEvents, CollectionEvents { 'layout:before': LayoutBeforeEvent; 'layout:after': LayoutAfterEvent; } export interface GroupOwnProps { subTargetCheck: boolean; interactive: boolean; } export interface SerializedGroupProps extends SerializedObjectProps, GroupOwnProps { objects: SerializedObjectProps[]; layoutManager: SerializedLayoutManager; } export interface GroupProps extends FabricObjectProps, GroupOwnProps { layoutManager: LayoutManager; } export const groupDefaultValues: Partial<TClassProperties<Group>> = { strokeWidth: 0, subTargetCheck: false, interactive: false, }; /** * @fires object:added * @fires object:removed * @fires layout:before * @fires layout:after */ export class Group extends createCollectionMixin( FabricObject<GroupProps, SerializedGroupProps, GroupEvents>, ) implements GroupProps { /** * Used to optimize performance * set to `false` if you don't need contained objects to be targets of events * @default * @type boolean */ declare subTargetCheck: boolean; /** * Used to allow targeting of object inside groups. * set to true if you want to select an object inside a group.\ * **REQUIRES** `subTargetCheck` set to true * This will be not removed but slowly replaced with a method setInteractive * that will take care of enabling subTargetCheck and necessary object events. * There is too much attached to group interactivity to just be evaluated by a * boolean in the code * @default * @deprecated * @type boolean */ declare interactive: boolean; declare layoutManager: LayoutManager; /** * Used internally to optimize performance * Once an object is selected, instance is rendered without the selected object. * This way instance is cached only once for the entire interaction with the selected object. * @private */ protected _activeObjects: FabricObject[] = []; static type = 'Group'; static ownDefaults: Record<string, any> = groupDefaultValues; private __objectSelectionTracker: (ev: ObjectEvents['selected']) => void; private __objectSelectionDisposer: (ev: ObjectEvents['deselected']) => void; static getDefaults(): Record<string, any> { return { ...super.getDefaults(), ...Group.ownDefaults, }; } /** * Constructor * * @param {FabricObject[]} [objects] instance objects * @param {Object} [options] Options object */ constructor(objects: FabricObject[] = [], options: Partial<GroupProps> = {}) { super(); Object.assign(this, Group.ownDefaults); this.setOptions(options); this.groupInit(objects, options); } /** * Shared code between group and active selection * Meant to be used by the constructor. */ protected groupInit( objects: FabricObject[], options: { layoutManager?: LayoutManager; top?: number; left?: number; }, ) { this._objects = [...objects]; // Avoid unwanted mutations of Collection to affect the caller this.__objectSelectionTracker = this.__objectSelectionMonitor.bind( this, true, ); this.__objectSelectionDisposer = this.__objectSelectionMonitor.bind( this, false, ); this.forEachObject((object) => { this.enterGroup(object, false); }); // perform initial layout this.layoutManager = options.layoutManager ?? new LayoutManager(); this.layoutManager.performLayout({ type: LAYOUT_TYPE_INITIALIZATION, target: this, targets: [...objects], // @TODO remove this concept from the layout manager. // Layout manager will calculate the correct position, // group options can override it later. x: options.left, y: options.top, }); } /** * Checks if object can enter group and logs relevant warnings * @private * @param {FabricObject} object * @returns */ canEnterGroup(object: FabricObject) { if (object === this || this.isDescendantOf(object)) { // prevent circular object tree log( 'error', 'Group: circular object trees are not supported, this call has no effect', ); return false; } else if (this._objects.indexOf(object) !== -1) { // is already in the objects array log( 'error', 'Group: duplicate objects are not supported inside group, this call has no effect', ); return false; } return true; } /** * Override this method to enhance performance (for groups with a lot of objects). * If Overriding, be sure not pass illegal objects to group - it will break your app. * @private */ protected _filterObjectsBeforeEnteringGroup(objects: FabricObject[]) { return objects.filter((object, index, array) => { // can enter AND is the first occurrence of the object in the passed args (to prevent adding duplicates) return this.canEnterGroup(object) && array.indexOf(object) === index; }); } /** * Add objects * @param {...FabricObject[]} objects */ add(...objects: FabricObject[]) { const allowedObjects = this._filterObjectsBeforeEnteringGroup(objects); const size = super.add(...allowedObjects); this._onAfterObjectsChange(LAYOUT_TYPE_ADDED, allowedObjects); return size; } /** * Inserts an object into collection at specified index * @param {FabricObject[]} objects Object to insert * @param {Number} index Index to insert object at */ insertAt(index: number, ...objects: FabricObject[]) { const allowedObjects = this._filterObjectsBeforeEnteringGroup(objects); const size = super.insertAt(index, ...allowedObjects); this._onAfterObjectsChange(LAYOUT_TYPE_ADDED, allowedObjects); return size; } /** * Remove objects * @param {...FabricObject[]} objects * @returns {FabricObject[]} removed objects */ remove(...objects: FabricObject[]) { const removed = super.remove(...objects); this._onAfterObjectsChange(LAYOUT_TYPE_REMOVED, removed); return removed; } _onObjectAdded(object: FabricObject) { this.enterGroup(object, true); this.fire('object:added', { target: object }); object.fire('added', { target: this }); } /** * @private * @param {FabricObject} object * @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it */ _onObjectRemoved(object: FabricObject, removeParentTransform?: boolean) { this.exitGroup(object, removeParentTransform); this.fire('object:removed', { target: object }); object.fire('removed', { target: this }); } /** * @private * @param {'added'|'removed'} type * @param {FabricObject[]} targets */ _onAfterObjectsChange(type: 'added' | 'removed', targets: FabricObject[]) { this.layoutManager.performLayout({ type, targets, target: this, }); } _onStackOrderChanged() { this._set('dirty', true); } /** * @private * @param {string} key * @param {*} value */ _set(key: string, value: any) { const prev = this[key as keyof this]; super._set(key, value); if (key === 'canvas' && prev !== value) { (this._objects || []).forEach((object) => { object._set(key, value); }); } return this; } /** * @private */ _shouldSetNestedCoords() { return this.subTargetCheck; } /** * Remove all objects * @returns {FabricObject[]} removed objects */ removeAll() { this._activeObjects = []; return this.remove(...this._objects); } /** * keeps track of the selected objects * @private */ __objectSelectionMonitor<T extends boolean>( selected: T, { target: object, }: ObjectEvents[T extends true ? 'selected' : 'deselected'], ) { const activeObjects = this._activeObjects; if (selected) { activeObjects.push(object); this._set('dirty', true); } else if (activeObjects.length > 0) { const index = activeObjects.indexOf(object); if (index > -1) { activeObjects.splice(index, 1); this._set('dirty', true); } } } /** * @private * @param {boolean} watch * @param {FabricObject} object */ _watchObject(watch: boolean, object: FabricObject) { // make sure we listen only once watch && this._watchObject(false, object); if (watch) { object.on('selected', this.__objectSelectionTracker); object.on('deselected', this.__objectSelectionDisposer); } else { object.off('selected', this.__objectSelectionTracker); object.off('deselected', this.__objectSelectionDisposer); } } /** * @private * @param {FabricObject} object * @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane */ enterGroup(object: FabricObject, removeParentTransform?: boolean) { object.group && object.group.remove(object); object._set('parent', this); this._enterGroup(object, removeParentTransform); } /** * @private * @param {FabricObject} object * @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane */ _enterGroup(object: FabricObject, removeParentTransform?: boolean) { if (removeParentTransform) { // can this be converted to utils (sendObjectToPlane)? applyTransformToObject( object, multiplyTransformMatrices( invertTransform(this.calcTransformMatrix()), object.calcTransformMatrix(), ), ); } this._shouldSetNestedCoords() && object.setCoords(); object._set('group', this); object._set('canvas', this.canvas); this._watchObject(true, object); const activeObject = this.canvas && this.canvas.getActiveObject && this.canvas.getActiveObject(); // if we are adding the activeObject in a group if ( activeObject && (activeObject === object || object.isDescendantOf(activeObject)) ) { this._activeObjects.push(object); } } /** * @private * @param {FabricObject} object * @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it */ exitGroup(object: FabricObject, removeParentTransform?: boolean) { this._exitGroup(object, removeParentTransform); object._set('parent', undefined); object._set('canvas', undefined); } /** * Executes the inner fabric logic of exiting a group. * - Stop watching the object * - Remove the object from the optimization map this._activeObjects * - unset the group property of the object * @protected * @param {FabricObject} object * @param {boolean} [removeParentTransform] true if object should exit group without applying group's transform to it */ _exitGroup(object: FabricObject, removeParentTransform?: boolean) { object._set('group', undefined); if (!removeParentTransform) { applyTransformToObject( object, multiplyTransformMatrices( this.calcTransformMatrix(), object.calcTransformMatrix(), ), ); object.setCoords(); } this._watchObject(false, object); const index = this._activeObjects.length > 0 ? this._activeObjects.indexOf(object) : -1; if (index > -1) { this._activeObjects.splice(index, 1); } } /** * Decide if the group should cache or not. Create its own cache level * needsItsOwnCache should be used when the object drawing method requires * a cache step. * Generally you do not cache objects in groups because the group is already cached. * @return {Boolean} */ shouldCache() { const ownCache = FabricObject.prototype.shouldCache.call(this); if (ownCache) { for (let i = 0; i < this._objects.length; i++) { if (this._objects[i].willDrawShadow()) { this.ownCaching = false; return false; } } } return ownCache; } /** * Check if this object or a child object will cast a shadow * @return {Boolean} */ willDrawShadow() { if (super.willDrawShadow()) { return true; } for (let i = 0; i < this._objects.length; i++) { if (this._objects[i].willDrawShadow()) { return true; } } return false; } /** * Check if instance or its group are caching, recursively up * @return {Boolean} */ isOnACache(): boolean { return this.ownCaching || (!!this.parent && this.parent.isOnACache()); } /** * Execute the drawing operation for an object on a specified context * @param {CanvasRenderingContext2D} ctx Context to render on */ drawObject( ctx: CanvasRenderingContext2D, forClipping: boolean | undefined, context: DrawContext, ) { this._renderBackground(ctx); for (let i = 0; i < this._objects.length; i++) { const obj = this._objects[i]; // TODO: handle rendering edge case somehow if (this.canvas?.preserveObjectStacking && obj.group !== this) { ctx.save(); ctx.transform(...invertTransform(this.calcTransformMatrix())); obj.render(ctx); ctx.restore(); } else if (obj.group === this) { obj.render(ctx); } } this._drawClipPath(ctx, this.clipPath, context); } /** * @override * @return {Boolean} */ setCoords() { super.setCoords(); this._shouldSetNestedCoords() && this.forEachObject((object) => object.setCoords()); } triggerLayout(options: ImperativeLayoutOptions = {}) { this.layoutManager.performLayout({ target: this, type: LAYOUT_TYPE_IMPERATIVE, ...options, }); } /** * Renders instance on a given context * @param {CanvasRenderingContext2D} ctx context to render instance on */ render(ctx: CanvasRenderingContext2D) { this._transformDone = true; super.render(ctx); this._transformDone = false; } /** * * @private * @param {'toObject'|'toDatalessObject'} [method] * @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output * @returns {FabricObject[]} serialized objects */ __serializeObjects( method: 'toObject' | 'toDatalessObject', propertiesToInclude?: string[], ) { const _includeDefaultValues = this.includeDefaultValues; return this._objects .filter(function (obj) { return !obj.excludeFromExport; }) .map(function (obj) { const originalDefaults = obj.includeDefaultValues; obj.includeDefaultValues = _includeDefaultValues; const data = obj[method || 'toObject'](propertiesToInclude); obj.includeDefaultValues = originalDefaults; // delete data.version; return data; }); } /** * Returns object representation of an instance * @param {string[]} [propertiesToInclude] Any properties that you might want to additionally include in the output * @return {Object} object representation of an instance */ toObject< T extends Omit< GroupProps & TClassProperties<this>, keyof SerializedGroupProps >, 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[], ), }; } toString() { return `#<Group: (${this.complexity()})>`; } dispose() { this.layoutManager.unsubscribeTargets({ targets: this.getObjects(), target: this, }); this._activeObjects = []; this.forEachObject((object) => { this._watchObject(false, object); object.dispose(); }); super.dispose(); } /** * @private */ _createSVGBgRect(reviver?: TSVGReviver) { if (!this.backgroundColor) { return ''; } const fillStroke = Rect.prototype._toSVG.call(this); const commons = fillStroke.indexOf('COMMON_PARTS'); fillStroke[commons] = 'for="group" '; const markup = fillStroke.join(''); return reviver ? reviver(markup) : markup; } /** * Returns svg representation of an instance * @param {TSVGReviver} [reviver] Method for further parsing of svg representation. * @return {String} svg representation of an instance */ _toSVG(reviver?: TSVGReviver) { const svgString = ['<g ', 'COMMON_PARTS', ' >\n']; const bg = this._createSVGBgRect(reviver); bg && svgString.push('\t\t', bg); for (let i = 0; i < this._objects.length; i++) { svgString.push('\t\t', this._objects[i].toSVG(reviver)); } svgString.push('</g>\n'); return svgString; } /** * Returns styles-string for svg-export, specific version for group * @return {String} */ getSvgStyles(): string { const opacity = typeof this.opacity !== 'undefined' && this.opacity !== 1 ? `opacity: ${this.opacity};` : '', visibility = this.visible ? '' : ' visibility: hidden;'; return [opacity, this.getSvgFilter(), visibility].join(''); } /** * Returns svg clipPath representation of an instance * @param {Function} [reviver] Method for further parsing of svg representation. * @return {String} svg representation of an instance */ toClipPathSVG(reviver?: TSVGReviver): string { const svgString = []; const bg = this._createSVGBgRect(reviver); bg && svgString.push('\t', bg); for (let i = 0; i < this._objects.length; i++) { svgString.push('\t', this._objects[i].toClipPathSVG(reviver)); } return this._createBaseClipPathSVGMarkup(svgString, { reviver, }); } /** * @todo support loading from svg * @private * @static * @memberOf Group * @param {Object} object Object to create a group from * @returns {Promise<Group>} */ static fromObject<T extends TOptions<SerializedGroupProps>>( { type, objects = [], layoutManager, ...options }: T, abortable?: Abortable, ) { return Promise.all([ enlivenObjects<FabricObject>(objects, abortable), enlivenObjectEnlivables(options, abortable), ]).then(([objects, hydratedOptions]) => { const group = new this(objects, { ...options, ...hydratedOptions, layoutManager: new NoopLayoutManager(), }); if (layoutManager) { const layoutClass = classRegistry.getClass<typeof LayoutManager>( layoutManager.type, ); const strategyClass = classRegistry.getClass<typeof FitContentLayout>( layoutManager.strategy, ); group.layoutManager = new layoutClass(new strategyClass()); } else { group.layoutManager = new LayoutManager(); } group.layoutManager.subscribeTargets({ type: LAYOUT_TYPE_INITIALIZATION, target: group, targets: group.getObjects(), }); group.setCoords(); return group; }); } } classRegistry.setClass(Group);