diff --git a/src/brushes/spray_brush.class.js b/src/brushes/spray_brush.class.js index 50202a4131b..43e3dd4e3ca 100644 --- a/src/brushes/spray_brush.class.js +++ b/src/brushes/spray_brush.class.js @@ -112,7 +112,12 @@ fabric.SprayBrush = fabric.util.createClass( fabric.BaseBrush, /** @lends fabric rects = this._getOptimizedRects(rects); } - var group = new fabric.Group(rects); + var group = new fabric.Group(rects, { + objectCaching: true, + layout: 'fixed', + subTargetCheck: false, + interactive: false + }); this.shadow && group.set('shadow', new fabric.Shadow(this.shadow)); this.canvas.fire('before:path:created', { path: group }); this.canvas.add(group); diff --git a/src/canvas.class.js b/src/canvas.class.js index 4fe6fa45601..1fe1fb1eef4 100644 --- a/src/canvas.class.js +++ b/src/canvas.class.js @@ -441,7 +441,7 @@ } objsToRender.push.apply(objsToRender, activeGroupObjects); } - // in case a single object is selected render it's entire above the other objects + // in case a single object is selected render it's entire parent above the other objects else if (!this.preserveObjectStacking && activeObjects.length === 1) { var target = activeObjects[0], ancestors = target.getAncestors(true); var topAncestor = ancestors.length === 0 ? target : ancestors.pop(); @@ -892,7 +892,7 @@ */ searchPossibleTargets: function (objects, pointer) { var target = this._searchPossibleTargets(objects, pointer); - return target; + return target && target.interactive && this.targets[0] ? this.targets[0] : target; }, /** diff --git a/src/mixins/animation.mixin.js b/src/mixins/animation.mixin.js index 28d84661f36..4890a78094c 100644 --- a/src/mixins/animation.mixin.js +++ b/src/mixins/animation.mixin.js @@ -25,11 +25,11 @@ fabric.util.object.extend(fabric.StaticCanvas.prototype, /** @lends fabric.Stati return fabric.util.animate({ target: this, - startValue: object.left, + startValue: object.getX(), endValue: this.getCenterPoint().x, duration: this.FX_DURATION, onChange: function(value) { - object.set('left', value); + object.setX(value); _this.requestRenderAll(); onChange(); }, @@ -58,11 +58,11 @@ fabric.util.object.extend(fabric.StaticCanvas.prototype, /** @lends fabric.Stati return fabric.util.animate({ target: this, - startValue: object.top, + startValue: object.getY(), endValue: this.getCenterPoint().y, duration: this.FX_DURATION, onChange: function(value) { - object.set('top', value); + object.setY(value); _this.requestRenderAll(); onChange(); }, diff --git a/src/mixins/canvas_events.mixin.js b/src/mixins/canvas_events.mixin.js index fb7c63f2b74..f56ed9fef16 100644 --- a/src/mixins/canvas_events.mixin.js +++ b/src/mixins/canvas_events.mixin.js @@ -712,9 +712,13 @@ } } } + var invalidate = shouldRender || shouldGroup; + // we clear `_objectsToRender` in case of a change in order to repopulate it at rendering + // run before firing the `down` event to give the dev a chance to populate it themselves + invalidate && (this._objectsToRender = undefined); this._handleEvent(e, 'down'); // we must renderAll so that we update the visuals - (shouldRender || shouldGroup) && this.requestRenderAll(); + invalidate && this.requestRenderAll(); }, /** @@ -900,13 +904,19 @@ */ _transformObject: function(e) { var pointer = this.getPointer(e), - transform = this._currentTransform; + transform = this._currentTransform, + target = transform.target, + // transform pointer to target's containing coordinate plane + // both pointer and object should agree on every point + localPointer = target.group ? + fabric.util.sendPointToPlane(pointer, null, target.group.calcTransformMatrix()) : + pointer; transform.reset = false; transform.shiftKey = e.shiftKey; transform.altKey = e[this.centeredKey]; - this._performTransformAction(e, transform, pointer); + this._performTransformAction(e, transform, localPointer); transform.actionPerformed && this.requestRenderAll(); }, diff --git a/src/mixins/canvas_grouping.mixin.js b/src/mixins/canvas_grouping.mixin.js index b59661a38ab..70ef167038e 100644 --- a/src/mixins/canvas_grouping.mixin.js +++ b/src/mixins/canvas_grouping.mixin.js @@ -13,8 +13,19 @@ */ _shouldGroup: function(e, target) { var activeObject = this._activeObject; - return activeObject && this._isSelectionKeyPressed(e) && target && target.selectable && this.selection && - (activeObject !== target || activeObject.type === 'activeSelection') && !target.onSelect({ e: e }); + // check if an active object exists on canvas and if the user is pressing the `selectionKey` while canvas supports multi selection. + return !!activeObject && this._isSelectionKeyPressed(e) && this.selection + // on top of that the user also has to hit a target that is selectable. + && !!target && target.selectable + // if all pre-requisite pass, the target is either something different from the current + // activeObject or if an activeSelection already exists + // TODO at time of writing why `activeObject.type === 'activeSelection'` matter is unclear. + // is a very old condition uncertain if still valid. + && (activeObject !== target || activeObject.type === 'activeSelection') + // make sure `activeObject` and `target` aren't ancestors of each other + && !target.isDescendantOf(activeObject) && !activeObject.isDescendantOf(target) + // target accepts selection + && !target.onSelect({ e: e }); }, /** @@ -50,7 +61,7 @@ _updateActiveSelection: function(target, e) { var activeSelection = this._activeObject, currentActiveObjects = activeSelection._objects.slice(0); - if (activeSelection.contains(target)) { + if (target.group === activeSelection) { activeSelection.remove(target); this._hoveredTarget = target; this._hoveredTargets = this.targets.concat(); @@ -80,17 +91,19 @@ this._fireSelectionEvents(currentActives, e); }, + /** * @private * @param {Object} target + * @returns {fabric.ActiveSelection} */ _createGroup: function(target) { - var objects = this._objects, - isActiveLower = objects.indexOf(this._activeObject) < objects.indexOf(target), - groupObjects = isActiveLower - ? [this._activeObject, target] - : [target, this._activeObject]; - this._activeObject.isEditing && this._activeObject.exitEditing(); + var activeObject = this._activeObject; + var groupObjects = target.isInFrontOf(activeObject) ? + [activeObject, target] : + [target, activeObject]; + activeObject.isEditing && activeObject.exitEditing(); + // handle case: target is nested return new fabric.ActiveSelection(groupObjects, { canvas: this }); diff --git a/src/mixins/collection.mixin.js b/src/mixins/collection.mixin.js index 9966d8986c2..1e91693fc4c 100644 --- a/src/mixins/collection.mixin.js +++ b/src/mixins/collection.mixin.js @@ -20,7 +20,7 @@ fabric.Collection = { add: function (objects, callback) { var size = this._objects.push.apply(this._objects, objects); if (callback) { - for (var i = 0, length = objects.length; i < length; i++) { + for (var i = 0; i < objects.length; i++) { callback.call(this, objects[i]); } } @@ -39,7 +39,7 @@ fabric.Collection = { var args = [index, 0].concat(objects); this._objects.splice.apply(this._objects, args); if (callback) { - for (var i = 2, length = args.length; i < length; i++) { + for (var i = 2; i < args.length; i++) { callback.call(this, args[i]); } } @@ -50,22 +50,21 @@ fabric.Collection = { * @private * @param {fabric.Object[]} objectsToRemove objects to remove * @param {(object:fabric.Object) => any} [callback] function to call for each object removed - * @returns {boolean} true if objects were removed + * @returns {fabric.Object[]} removed objects */ remove: function(objectsToRemove, callback) { - var objects = this._objects, - index, somethingRemoved = false; - - for (var i = 0, length = objectsToRemove.length; i < length; i++) { - index = objects.indexOf(objectsToRemove[i]); + var objects = this._objects, removed = []; + for (var i = 0, object, index; i < objectsToRemove.length; i++) { + object = objectsToRemove[i]; + index = objects.indexOf(object); // only call onObjectRemoved if an object was actually removed if (index !== -1) { - somethingRemoved = true; objects.splice(index, 1); - callback && callback.call(this, objectsToRemove[i]); + removed.push(object); + callback && callback.call(this, object); } } - return somethingRemoved; + return removed; }, /** @@ -82,7 +81,7 @@ fabric.Collection = { */ forEachObject: function(callback, context) { var objects = this.getObjects(); - for (var i = 0, len = objects.length; i < len; i++) { + for (var i = 0; i < objects.length; i++) { callback.call(context, objects[i], i, objects); } return this; @@ -131,6 +130,7 @@ fabric.Collection = { /** * Returns true if collection contains an object.\ * **Prefer using {@link `fabric.Object#isDescendantOf`} for performance reasons** + * instead of a.contains(b) use b.isDescendantOf(a) * @param {Object} object Object to check against * @param {Boolean} [deep=false] `true` to check all descendants, `false` to check only `_objects` * @return {Boolean} `true` if collection contains an object diff --git a/src/mixins/eraser_brush.mixin.js b/src/mixins/eraser_brush.mixin.js index 4400e5aca41..a8647165ff0 100644 --- a/src/mixins/eraser_brush.mixin.js +++ b/src/mixins/eraser_brush.mixin.js @@ -125,7 +125,6 @@ /* _TO_SVG_END_ */ }); - var __restoreObjectsState = fabric.Group.prototype._restoreObjectsState; fabric.util.object.extend(fabric.Group.prototype, { /** * @private @@ -181,15 +180,6 @@ }); } }); - }, - - /** - * Propagate the group's eraser to its objects, crucial for proper functionality of the eraser within the group and nested objects. - * @private - */ - _restoreObjectsState: function () { - this.erasable === true && this.applyEraserToObjects(); - return __restoreObjectsState.call(this); } }); @@ -217,14 +207,6 @@ */ originY: 'center', - drawObject: function (ctx) { - ctx.save(); - ctx.fillStyle = 'black'; - ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); - ctx.restore(); - this.callSuper('drawObject', ctx); - }, - /** * eraser should retain size * dimensions should not change when paths are added or removed @@ -232,8 +214,14 @@ * @override * @private */ - _getBounds: function () { - // noop + layout: 'fixed', + + drawObject: function (ctx) { + ctx.save(); + ctx.fillStyle = 'black'; + ctx.fillRect(-this.width / 2, -this.height / 2, this.width, this.height); + ctx.restore(); + this.callSuper('drawObject', ctx); }, /* _TO_SVG_START_ */ diff --git a/src/mixins/itext_behavior.mixin.js b/src/mixins/itext_behavior.mixin.js index 8c58cf68d5d..9ded51c0747 100644 --- a/src/mixins/itext_behavior.mixin.js +++ b/src/mixins/itext_behavior.mixin.js @@ -25,8 +25,9 @@ */ initAddedHandler: function() { var _this = this; - this.on('added', function() { - var canvas = _this.canvas; + this.on('added', function (opt) { + // make sure we listen to the canvas added event + var canvas = opt.target; if (canvas) { if (!canvas._hasITextHandlers) { canvas._hasITextHandlers = true; @@ -40,8 +41,9 @@ initRemovedHandler: function() { var _this = this; - this.on('removed', function() { - var canvas = _this.canvas; + this.on('removed', function (opt) { + // make sure we listen to the canvas removed event + var canvas = opt.target; if (canvas) { canvas._iTextInstances = canvas._iTextInstances || []; fabric.util.removeFromArray(canvas._iTextInstances, _this); diff --git a/src/mixins/itext_click_behavior.mixin.js b/src/mixins/itext_click_behavior.mixin.js index 69b14ed9a13..b1bac2b8895 100644 --- a/src/mixins/itext_click_behavior.mixin.js +++ b/src/mixins/itext_click_behavior.mixin.js @@ -152,7 +152,8 @@ fabric.util.object.extend(fabric.IText.prototype, /** @lends fabric.IText.protot */ mouseUpHandler: function(options) { this.__isMousedown = false; - if (!this.editable || this.group || + if (!this.editable || + (this.group && !this.group.interactive) || (options.transform && options.transform.actionPerformed) || (options.e.button && options.e.button !== 1)) { return; diff --git a/src/mixins/object_ancestry.mixin.js b/src/mixins/object_ancestry.mixin.js index 594da24a03d..8c74ea48d2b 100644 --- a/src/mixins/object_ancestry.mixin.js +++ b/src/mixins/object_ancestry.mixin.js @@ -1,17 +1,122 @@ fabric.util.object.extend(fabric.Object.prototype, /** @lends fabric.Object.prototype */ { /** - * - * @param {boolean} [strict] returns only ancestors that are objects (without canvas) - * @returns {(fabric.Object | fabric.StaticCanvas)[]} ancestors from bottom to top - */ + * Checks if object is decendant of target + * Should be used instead of @link {fabric.Collection.contains} for performance reasons + * @param {fabric.Object|fabric.StaticCanvas} target + * @returns {boolean} + */ + isDescendantOf: function (target) { + var parent = this.group || this.canvas; + while (parent) { + if (target === parent) { + return true; + } + else if (parent instanceof fabric.StaticCanvas) { + // happens after all parents were traversed through without a match + return false; + } + parent = parent.group || parent.canvas; + } + return false; + }, + + /** + * + * @typedef {fabric.Object[] | [...fabric.Object[], fabric.StaticCanvas]} Ancestors + * + * @param {boolean} [strict] returns only ancestors that are objects (without canvas) + * @returns {Ancestors} ancestors from bottom to top + */ getAncestors: function (strict) { var ancestors = []; - var parent = this.group || (!strict ? this.canvas : undefined); + var parent = this.group || (strict ? undefined : this.canvas); while (parent) { ancestors.push(parent); - parent = parent.group || (!strict ? parent.canvas : undefined); + parent = parent.group || (strict ? undefined : parent.canvas); } return ancestors; + }, + + /** + * Returns an object that represent the ancestry situation. + * + * @typedef {object} AncestryComparison + * @property {Ancestors} common ancestors of `this` and `other` (may include `this` | `other`) + * @property {Ancestors} fork ancestors that are of `this` only + * @property {Ancestors} otherFork ancestors that are of `other` only + * + * @param {fabric.Object} other + * @param {boolean} [strict] finds only ancestors that are objects (without canvas) + * @returns {AncestryComparison | undefined} + * + */ + findCommonAncestors: function (other, strict) { + if (this === other) { + return { + fork: [], + otherFork: [], + common: [this].concat(this.getAncestors(strict)) + }; + } + else if (!other) { + // meh, warn and inform, and not my issue. + // the argument is NOT optional, we can't end up here. + return undefined; + } + var ancestors = this.getAncestors(strict); + var otherAncestors = other.getAncestors(strict); + // if `this` has no ancestors and `this` is top ancestor of `other` we must handle the following case + if (ancestors.length === 0 && otherAncestors.length > 0 && this === otherAncestors[otherAncestors.length - 1]) { + return { + fork: [], + otherFork: [other].concat(otherAncestors.slice(0, otherAncestors.length - 1)), + common: [this] + }; + } + // compare ancestors + for (var i = 0, ancestor; i < ancestors.length; i++) { + ancestor = ancestors[i]; + if (ancestor === other) { + return { + fork: [this].concat(ancestors.slice(0, i)), + otherFork: [], + common: ancestors.slice(i) + }; + } + for (var j = 0; j < otherAncestors.length; j++) { + if (this === otherAncestors[j]) { + return { + fork: [], + otherFork: [other].concat(otherAncestors.slice(0, j)), + common: [this].concat(ancestors) + }; + } + if (ancestor === otherAncestors[j]) { + return { + fork: [this].concat(ancestors.slice(0, i)), + otherFork: [other].concat(otherAncestors.slice(0, j)), + common: ancestors.slice(i) + }; + } + } + } + // nothing shared + return { + fork: [this].concat(ancestors), + otherFork: [other].concat(otherAncestors), + common: [] + }; + }, + + /** + * + * @param {fabric.Object} other + * @param {boolean} [strict] checks only ancestors that are objects (without canvas) + * @returns {boolean} + */ + hasCommonAncestors: function (other, strict) { + var commonAncestors = this.findCommonAncestors(other, strict); + return commonAncestors && !!commonAncestors.ancestors.length; } }); diff --git a/src/mixins/object_geometry.mixin.js b/src/mixins/object_geometry.mixin.js index 43cce0216fa..c1a796cc418 100644 --- a/src/mixins/object_geometry.mixin.js +++ b/src/mixins/object_geometry.mixin.js @@ -66,6 +66,113 @@ */ controls: { }, + /** + * @returns {number} x position according to object's {@link fabric.Object#originX} property in canvas coordinate plane + */ + getX: function () { + return this.getXY().x; + }, + + /** + * @param {number} value x position according to object's {@link fabric.Object#originX} property in canvas coordinate plane + */ + setX: function (value) { + this.setXY(this.getXY().setX(value)); + }, + + /** + * @returns {number} x position according to object's {@link fabric.Object#originX} property in parent's coordinate plane\ + * if parent is canvas then this property is identical to {@link fabric.Object#getX} + */ + getRelativeX: function () { + return this.left; + }, + + /** + * @param {number} value x position according to object's {@link fabric.Object#originX} property in parent's coordinate plane\ + * if parent is canvas then this method is identical to {@link fabric.Object#setX} + */ + setRelativeX: function (value) { + this.left = value; + }, + + /** + * @returns {number} y position according to object's {@link fabric.Object#originY} property in canvas coordinate plane + */ + getY: function () { + return this.getXY().y; + }, + + /** + * @param {number} value y position according to object's {@link fabric.Object#originY} property in canvas coordinate plane + */ + setY: function (value) { + this.setXY(this.getXY().setY(value)); + }, + + /** + * @returns {number} y position according to object's {@link fabric.Object#originY} property in parent's coordinate plane\ + * if parent is canvas then this property is identical to {@link fabric.Object#getY} + */ + getRelativeY: function () { + return this.top; + }, + + /** + * @param {number} value y position according to object's {@link fabric.Object#originY} property in parent's coordinate plane\ + * if parent is canvas then this property is identical to {@link fabric.Object#setY} + */ + setRelativeY: function (value) { + this.top = value; + }, + + /** + * @returns {number} x position according to object's {@link fabric.Object#originX} {@link fabric.Object#originY} properties in canvas coordinate plane + */ + getXY: function () { + var relativePosition = this.getRelativeXY(); + return this.group ? + fabric.util.transformPoint(relativePosition, this.group.calcTransformMatrix()) : + relativePosition; + }, + + /** + * Set an object position to a particular point, the point is intended in absolute ( canvas ) coordinate. + * You can specify {@link fabric.Object#originX} and {@link fabric.Object#originY} values, + * that otherwise are the object's current values. + * @example Set object's bottom left corner to point (5,5) on canvas + * object.setXY(new fabric.Point(5, 5), 'left', 'bottom'). + * @param {fabric.Point} point position in canvas coordinate plane + * @param {'left'|'center'|'right'|number} [originX] Horizontal origin: 'left', 'center' or 'right' + * @param {'top'|'center'|'bottom'|number} [originY] Vertical origin: 'top', 'center' or 'bottom' + */ + setXY: function (point, originX, originY) { + if (this.group) { + point = fabric.util.transformPoint( + point, + fabric.util.invertTransform(this.group.calcTransformMatrix()) + ); + } + this.setRelativeXY(point, originX, originY); + }, + + /** + * @returns {number} x position according to object's {@link fabric.Object#originX} {@link fabric.Object#originY} properties in parent's coordinate plane + */ + getRelativeXY: function () { + return new fabric.Point(this.left, this.top); + }, + + /** + * As {@link fabric.Object#setXY}, but in current parent's coordinate plane ( the current group if any or the canvas) + * @param {fabric.Point} point position according to object's {@link fabric.Object#originX} {@link fabric.Object#originY} properties in parent's coordinate plane + * @param {'left'|'center'|'right'|number} [originX] Horizontal origin: 'left', 'center' or 'right' + * @param {'top'|'center'|'bottom'|number} [originY] Vertical origin: 'top', 'center' or 'bottom' + */ + setRelativeXY: function (point, originX, originY) { + this.setPositionByOrigin(point, originX || this.originX, originY || this.originY); + }, + /** * return correct set of coordinates for intersection * this will return either aCoords or lineCoords. @@ -88,8 +195,15 @@ * The coords are returned in an array. * @return {Array} [tl, tr, br, bl] of points */ - getCoords: function(absolute, calculate) { - return arrayFromCoords(this._getCoords(absolute, calculate)); + getCoords: function (absolute, calculate) { + var coords = arrayFromCoords(this._getCoords(absolute, calculate)); + if (this.group) { + var t = this.group.calcTransformMatrix(); + return coords.map(function (p) { + return util.transformPoint(p, t); + }); + } + return coords; }, /** @@ -630,9 +744,12 @@ scaleY: this.scaleY, skewX: this.skewX, skewY: this.skewY, + width: this.width, + height: this.height, + strokeWidth: this.strokeWidth }, options || {}); // stroke is applied before/after transformations are applied according to `strokeUniform` - var preScalingStrokeValue, postScalingStrokeValue, strokeWidth = this.strokeWidth; + var preScalingStrokeValue, postScalingStrokeValue, strokeWidth = options.strokeWidth; if (this.strokeUniform) { preScalingStrokeValue = 0; postScalingStrokeValue = strokeWidth; @@ -641,8 +758,8 @@ preScalingStrokeValue = strokeWidth; postScalingStrokeValue = 0; } - var dimX = this.width + preScalingStrokeValue, - dimY = this.height + preScalingStrokeValue, + var dimX = options.width + preScalingStrokeValue, + dimY = options.height + preScalingStrokeValue, finalDimensions, noSkew = options.skewX === 0 && options.skewY === 0; if (noSkew) { diff --git a/src/mixins/object_stacking.mixin.js b/src/mixins/object_stacking.mixin.js index 8c8e87d5efd..6af83291af0 100644 --- a/src/mixins/object_stacking.mixin.js +++ b/src/mixins/object_stacking.mixin.js @@ -76,5 +76,35 @@ fabric.util.object.extend(fabric.Object.prototype, /** @lends fabric.Object.prot this.canvas.moveTo(this, index); } return this; + }, + + /** + * + * @param {fabric.Object} other object to compare against + * @returns {boolean | undefined} if objects do not share a common ancestor or they are strictly equal it is impossible to determine which is in front of the other; in such cases the function returns `undefined` + */ + isInFrontOf: function (other) { + if (this === other) { + return undefined; + } + var ancestorData = this.findCommonAncestors(other); + if (!ancestorData) { + return undefined; + } + if (ancestorData.fork.includes(other)) { + return true; + } + if (ancestorData.otherFork.includes(this)) { + return false; + } + var firstCommonAncestor = ancestorData.common[0]; + if (!firstCommonAncestor) { + return undefined; + } + var headOfFork = ancestorData.fork.pop(), + headOfOtherFork = ancestorData.otherFork.pop(), + thisIndex = firstCommonAncestor._objects.indexOf(headOfFork), + otherIndex = firstCommonAncestor._objects.indexOf(headOfOtherFork); + return thisIndex > -1 && thisIndex > otherIndex; } }); diff --git a/src/shapes/active_selection.class.js b/src/shapes/active_selection.class.js index a2b648752d0..4d64b2aaa19 100644 --- a/src/shapes/active_selection.class.js +++ b/src/shapes/active_selection.class.js @@ -52,6 +52,33 @@ this.setCoords(); }, + /** + * @private + */ + _shouldSetNestedCoords: function () { + return true; + }, + + /** + * @private + * @param {fabric.Object} object + * @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane + * @returns {boolean} true if object entered group + */ + enterGroup: function (object, removeParentTransform) { + if (!this.canEnter(object)) { + return false; + } + if (object.group) { + // save ref to group for later in order to return to it + var parent = object.group; + parent._exitGroup(object); + object.__owningGroup = parent; + } + this._enterGroup(object, removeParentTransform); + return true; + }, + /** * we want objects to retain their canvas ref when exiting instance * @private @@ -60,6 +87,36 @@ */ exitGroup: function (object, removeParentTransform) { this._exitGroup(object, removeParentTransform); + var parent = object.__owningGroup; + if (parent) { + // return to owning group + parent.enterGroup(object); + delete object.__owningGroup; + } + }, + + /** + * @private + * @param {'added'|'removed'} type + * @param {fabric.Object[]} targets + */ + _onAfterObjectsChange: function (type, targets) { + var groups = []; + targets.forEach(function (object) { + object.group && !groups.includes(object.group) && groups.push(object.group); + }); + if (type === 'removed') { + // invalidate groups' layout and mark as dirty + groups.forEach(function (group) { + group._onAfterObjectsChange('added', targets); + }); + } + else { + // mark groups as dirty + groups.forEach(function (group) { + group._set('dirty', true); + }); + } }, /** @@ -115,7 +172,7 @@ childrenOverride.hasControls = false; } childrenOverride.forActiveSelection = true; - for (var i = 0, len = this._objects.length; i < len; i++) { + for (var i = 0; i < this._objects.length; i++) { this._objects[i]._renderControls(ctx, childrenOverride); } ctx.restore(); diff --git a/src/shapes/group.class.js b/src/shapes/group.class.js index 9e9c47905d6..e26412d6d91 100644 --- a/src/shapes/group.class.js +++ b/src/shapes/group.class.js @@ -58,7 +58,7 @@ /** * Used to optimize performance - * set to `false` if you don't need caontained objects to be target of events + * set to `false` if you don't need contained objects to be targets of events * @default * @type boolean */ @@ -66,8 +66,8 @@ /** * 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 + * set to true if you want to select an object inside a group.\ + * **REQUIRES** `subTargetCheck` set to true * @default * @type boolean */ @@ -97,20 +97,9 @@ this.__objectSelectionDisposer = this.__objectSelectionMonitor.bind(this, false); this._firstLayoutDone = false; this.callSuper('initialize', options); - if (objectsRelativeToGroup) { - this.forEachObject(function (object) { - this.enterGroup(object, false); - }, this); - } - else { - // we need to preserve object's center point in relation to canvas and apply group's transform to it - var inv = invertTransform(this.calcTransformMatrix()); - this.forEachObject(function (object) { - this.enterGroup(object, false); - var center = transformPoint(object.getCenterPoint(), inv); - object.setPositionByOrigin(center, 'center', 'center'); - }, this); - } + this.forEachObject(function (object) { + this.enterGroup(object, false); + }, this); this._applyLayoutStrategy({ type: 'initialization', options: options, @@ -140,6 +129,13 @@ return this; }, + /** + * @private + */ + _shouldSetNestedCoords: function () { + return this.subTargetCheck; + }, + /** * Add objects * @param {...fabric.Object} objects @@ -155,17 +151,19 @@ * @param {Number} index Index to insert object at */ insertAt: function (objects, index) { - fabric.Collection.insertAt.call(this, objects, index, this._onObjectAdded); + fabric.Collection.insertAt.call(this, objects, index, this._onObjectAdded); this._onAfterObjectsChange('added', Array.isArray(objects) ? objects : [objects]); }, /** * Remove objects * @param {...fabric.Object} objects + * @returns {fabric.Object[]} removed objects */ remove: function () { - fabric.Collection.remove.call(this, arguments, this._onObjectRemoved); - this._onAfterObjectsChange('removed', Array.from(arguments)); + var removed = fabric.Collection.remove.call(this, arguments, this._onObjectRemoved); + this._onAfterObjectsChange('removed', removed); + return removed; }, /** @@ -174,9 +172,7 @@ */ removeAll: function () { this._activeObjects = []; - var remove = this._objects.slice(); - this.remove.apply(this, remove); - return remove; + return this.remove.apply(this, this._objects.slice()); }, /** @@ -224,23 +220,53 @@ object[directive]('deselected', this.__objectSelectionDisposer); }, + /** + * Checks if object can enter group and logs relevant warnings + * @private + * @param {fabric.Object} object + * @returns + */ + canEnter: function (object) { + if (object === this || this.isDescendantOf(object)) { + /* _DEV_MODE_START_ */ + console.warn('fabric.Group: trying to add group to itself, this call has no effect'); + /* _DEV_MODE_END_ */ + return false; + } + else if (object.group && object.group === this) { + /* _DEV_MODE_START_ */ + console.warn('fabric.Group: duplicate objects are not supported inside group, this call has no effect'); + /* _DEV_MODE_END_ */ + return false; + } + return true; + }, + /** * @private * @param {fabric.Object} object * @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane + * @returns {boolean} true if object entered group */ enterGroup: function (object, removeParentTransform) { + if (!this.canEnter(object)) { + return false; + } if (object.group) { - if (object.group === this) { - /* _DEV_MODE_START_ */ - console.warn('fabric.Group: duplicate objects are not supported inside group, this call has no effect'); - /* _DEV_MODE_END_ */ - return; - } object.group.remove(object); } - // can be this converted to utils? + this._enterGroup(object, removeParentTransform); + return true; + }, + + /** + * @private + * @param {fabric.Object} object + * @param {boolean} [removeParentTransform] true if object is in canvas coordinate plane + */ + _enterGroup: function (object, removeParentTransform) { if (removeParentTransform) { + // can this be converted to utils (sendObjectToPlane)? applyTransformToObject( object, multiplyTransformMatrices( @@ -249,14 +275,13 @@ ) ); } - object.setCoords(); + this._shouldSetNestedCoords() && object.setCoords(); object._set('group', this); object._set('canvas', this.canvas); this.interactive && this._watchObject(true, object); var activeObject = this.canvas && this.canvas.getActiveObject && this.canvas.getActiveObject(); // if we are adding the activeObject in a group - // TODO migrate back to isDescendantOf - if (activeObject && (activeObject === object || (object.contains && object.contains(activeObject)))) { + if (activeObject && (activeObject === object || object.isDescendantOf(activeObject))) { this._activeObjects.push(object); } }, @@ -346,7 +371,7 @@ shouldCache: function() { var ownCache = fabric.Object.prototype.shouldCache.call(this); if (ownCache) { - for (var i = 0, len = this._objects.length; i < len; i++) { + for (var i = 0; i < this._objects.length; i++) { if (this._objects[i].willDrawShadow()) { this.ownCaching = false; return false; @@ -364,7 +389,7 @@ if (fabric.Object.prototype.willDrawShadow.call(this)) { return true; } - for (var i = 0, len = this._objects.length; i < len; i++) { + for (var i = 0; i < this._objects.length; i++) { if (this._objects[i].willDrawShadow()) { return true; } @@ -385,7 +410,8 @@ * @param {CanvasRenderingContext2D} ctx Context to render on */ drawObject: function(ctx) { - for (var i = 0, len = this._objects.length; i < len; i++) { + this._renderBackground(ctx); + for (var i = 0; i < this._objects.length; i++) { this._objects[i].render(ctx); } this._drawClipPath(ctx, this.clipPath); @@ -401,7 +427,7 @@ if (!this.statefullCache) { return false; } - for (var i = 0, len = this._objects.length; i < len; i++) { + for (var i = 0; i < this._objects.length; i++) { if (this._objects[i].isCacheDirty(true)) { if (this._cacheCanvas) { // if this group has not a cache canvas there is nothing to clean @@ -420,7 +446,7 @@ */ setCoords: function () { this.callSuper('setCoords'); - (this.subTargetCheck || this.type === 'activeSelection') && this.forEachObject(function (object) { + this._shouldSetNestedCoords() && this.forEachObject(function (object) { object.setCoords(); }); }, @@ -462,7 +488,7 @@ /** * initial layout logic: - * calculate bbox of objects (if necessary) and translate it according to options recieved from the constructor (left, top, width, height) + * calculate bbox of objects (if necessary) and translate it according to options received from the constructor (left, top, width, height) * so it is placed in the center of the bbox received from the constructor * * @private @@ -850,6 +876,19 @@ /* _TO_SVG_START_ */ + /** + * @private + */ + _createSVGBgRect: function (reviver) { + if (!this.backgroundColor) { + return ''; + } + var fillStroke = fabric.Rect.prototype._toSVG.call(this, reviver); + var commons = fillStroke.indexOf('COMMON_PARTS'); + fillStroke[commons] = 'for="group" '; + return fillStroke.join(''); + }, + /** * Returns svg representation of an instance * @param {Function} [reviver] Method for further parsing of svg representation. @@ -857,7 +896,9 @@ */ _toSVG: function (reviver) { var svgString = ['\n']; - for (var i = 0, len = this._objects.length; i < len; i++) { + var bg = this._createSVGBgRect(reviver); + bg && svgString.push('\t\t', bg); + for (var i = 0; i < this._objects.length; i++) { svgString.push('\t\t', this._objects[i].toSVG(reviver)); } svgString.push('\n'); @@ -886,7 +927,9 @@ */ toClipPathSVG: function (reviver) { var svgString = []; - for (var i = 0, len = this._objects.length; i < len; i++) { + var bg = this._createSVGBgRect(reviver); + bg && svgString.push('\t', bg); + for (var i = 0; i < this._objects.length; i++) { svgString.push('\t', this._objects[i].toClipPathSVG(reviver)); } return this._createBaseClipPathSVGMarkup(svgString, { reviver: reviver }); diff --git a/src/static_canvas.class.js b/src/static_canvas.class.js index 614afcd3df8..ca254eadb6e 100644 --- a/src/static_canvas.class.js +++ b/src/static_canvas.class.js @@ -596,8 +596,8 @@ * @chainable */ remove: function () { - var didRemove = fabric.Collection.remove.call(this, arguments, this._onObjectRemoved); - didRemove && this.renderOnAddRemove && this.requestRenderAll(); + var removed = fabric.Collection.remove.call(this, arguments, this._onObjectRemoved); + removed.length > 0 && this.renderOnAddRemove && this.requestRenderAll(); return this; }, @@ -617,7 +617,7 @@ obj._set('canvas', this); obj.setCoords(); this.fire('object:added', { target: obj }); - obj.fire('added'); + obj.fire('added', { target: this }); }, /** @@ -626,8 +626,8 @@ */ _onObjectRemoved: function(obj) { this.fire('object:removed', { target: obj }); - obj.fire('removed'); - delete obj.canvas; + obj.fire('removed', { target: this }); + obj._set('canvas', undefined); }, /** @@ -966,7 +966,7 @@ * @chainable */ _centerObject: function(object, center) { - object.setPositionByOrigin(center, 'center', 'center'); + object.setXY(center, 'center', 'center'); object.setCoords(); this.renderOnAddRemove && this.requestRenderAll(); return this; diff --git a/src/util/misc.js b/src/util/misc.js index 6c9c122d0ef..5fe519b357e 100644 --- a/src/util/misc.js +++ b/src/util/misc.js @@ -339,10 +339,10 @@ */ transformPointRelativeToCanvas: function (point, canvas, relationBefore, relationAfter) { if (relationBefore !== 'child' && relationBefore !== 'sibling') { - throw new Error('fabric.js: recieved bad argument ' + relationBefore); + throw new Error('fabric.js: received bad argument ' + relationBefore); } if (relationAfter !== 'child' && relationAfter !== 'sibling') { - throw new Error('fabric.js: recieved bad argument ' + relationAfter); + throw new Error('fabric.js: received bad argument ' + relationAfter); } if (relationBefore === relationAfter) { return point; diff --git a/test/unit/collection.js b/test/unit/collection.js index 064fae5d4d7..e2e5504457d 100644 --- a/test/unit/collection.js +++ b/test/unit/collection.js @@ -95,16 +95,19 @@ assert.ok(typeof collection.remove === 'function', 'has remove method'); var returned = collection.remove([obj]); assert.ok(returned, 'removed obj'); - assert.ok(!collection.remove([{ prop: 'foo' }]), 'nothing removed'); + assert.ok(Array.isArray(collection.remove([])), 'should return empty array'); + assert.equal(collection.remove([{ prop: 'foo' }]).length, 0, 'nothing removed'); assert.equal(collection._objects.indexOf(obj), -1, 'obj is no more in array'); assert.equal(collection._objects.length, previousLength - 1, 'length has changed'); assert.equal(fired, 0, 'fired is 0'); var callback = function() { fired++; }; - collection.remove([obj2], callback); + var removed = collection.remove([obj2], callback); assert.equal(fired, 1, 'fired is incremented if there is a callback'); - collection.remove([obj2], callback); + assert.deepEqual(removed, [obj2], 'should return removed objects'); + removed = collection.remove([obj2], callback); + assert.deepEqual(removed, [], 'should return removed objects'); assert.equal(fired, 1, 'fired is not incremented again if there is no object to remove'); collection.add([obj2]); @@ -112,7 +115,8 @@ collection.remove([obj2], callback); previousLength = collection._objects.length; fired = 0; - collection.remove([obj, obj3], callback); + removed = collection.remove([obj, obj3, obj2], callback); + assert.deepEqual(removed, [obj, obj3], 'should return removed objects'); assert.equal(collection._objects.length, previousLength - 2, 'we have 2 objects less'); assert.equal(fired, 2, 'fired is incremented for every object removed'); }); diff --git a/test/unit/group.js b/test/unit/group.js index 88aa19625f9..b3183c00058 100644 --- a/test/unit/group.js +++ b/test/unit/group.js @@ -100,10 +100,12 @@ group = new fabric.Group([rect1, rect2, rect3]); assert.ok(typeof group.remove === 'function'); - group.remove(rect2); + var removed = group.remove(rect2); + assert.deepEqual(removed, [rect2], 'should return removed objects'); assert.deepEqual(group.getObjects(), [rect1, rect3], 'should remove object properly'); - group.remove(rect1, rect3); + var removed = group.remove(rect1, rect3); + assert.deepEqual(removed, [rect1, rect3], 'should return removed objects'); assert.equal(group.isEmpty(), true, 'group should be empty'); }); @@ -316,7 +318,8 @@ assert.ok(initialLeftValue !== firstObject.get('left')); assert.ok(initialTopValue !== firstObject.get('top')); - group.removeAll(); + var objects = group.getObjects(); + assert.deepEqual(group.removeAll(), objects, 'should remove all objects'); assert.equal(firstObject.get('left'), initialLeftValue, 'should restore initial left value'); assert.equal(firstObject.get('top'), initialTopValue, 'should restore initial top value'); }); @@ -424,6 +427,7 @@ var group = makeGroupWith2ObjectsWithOpacity(); var groupObject = group.toObject(); + groupObject.subTargetCheck = true; fabric.Group.fromObject(groupObject).then(function(newGroupFromObject) { assert.ok(newGroupFromObject._objects[0].lineCoords.tl, 'acoords 0 are restored'); @@ -692,6 +696,35 @@ assert.notEqual(coords, newCoords, 'object coords have been recalculated - add'); }); + QUnit.test('group add edge cases', function (assert) { + var rect1 = new fabric.Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false }), + rect2 = new fabric.Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false }), + group = new fabric.Group([rect1]); + + // duplicate + assert.notOk(group.canEnter(rect1)); + // group.add(rect1); + // assert.deepEqual(group.getObjects(), [rect1], 'objects should not have changed'); + // duplicate on same call + assert.ok(group.canEnter(rect2)); + // group.add(rect2, rect2); + // assert.deepEqual(group.getObjects(), [rect1, rect2], '`rect2` should have entered once'); + // adding self + assert.notOk(group.canEnter(group)); + // group.add(group); + // assert.deepEqual(group.getObjects(), [rect1, rect2], 'objects should not have changed'); + // nested object should be removed from group + var nestedGroup = new fabric.Group([rect1]); + assert.ok(group.canEnter(nestedGroup)); + // group.add(nestedGroup); + // assert.deepEqual(group.getObjects(), [rect2, nestedGroup], '`rect1` was removed from group once it entered `nestedGroup`'); + // circular group + var circularGroup = new fabric.Group([rect2, group]); + assert.notOk(group.canEnter(circularGroup), 'circular group should be denied entry'); + // group.add(circularGroup); + // assert.deepEqual(group.getObjects(), [rect2, nestedGroup], 'objects should not have changed'); + }); + QUnit.test('group remove', function(assert) { var rect1 = new fabric.Rect({ top: 1, left: 1, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false}), rect2 = new fabric.Rect({ top: 5, left: 5, width: 2, height: 2, strokeWidth: 0, fill: 'red', opacity: 1, objectCaching: false}), diff --git a/test/unit/itext_click_behaviour.js b/test/unit/itext_click_behaviour.js index 7d43901c7d3..fb5af39f3dd 100644 --- a/test/unit/itext_click_behaviour.js +++ b/test/unit/itext_click_behaviour.js @@ -167,21 +167,40 @@ iText.selected = true; iText.__lastSelected = true; iText.mouseUpHandler({ e: {} }); - assert.equal(iText.isEditing, false, 'iText did not enter editing'); + assert.equal(iText.isEditing, false, 'iText should not enter editing'); iText.exitEditing(); }); - QUnit.test('_mouseUpHandler on a selected text in a group DOES NOT enter edit', function(assert) { + QUnit.test('_mouseUpHandler on a selected text in a group does NOT enter editing', function(assert) { var iText = new fabric.IText('test'); iText.initDelayedCursor = function() {}; iText.renderCursorOrSelection = function() {}; assert.equal(iText.isEditing, false, 'iText not editing'); - iText.canvas = canvas; + var group = new fabric.Group([iText], { subTargetCheck: false }); + canvas.add(group); iText.selected = true; iText.__lastSelected = true; - iText.group = true; - iText.mouseUpHandler({ e: {} }); - assert.equal(iText.isEditing, false, 'iText did not entered editing'); + canvas.__onMouseUp({ clientX: 1, clientY: 1 }); + assert.equal(canvas._target, group, 'group should be found as target'); + assert.equal(iText.isEditing, false, 'iText should not enter editing'); + iText.exitEditing(); + }); + QUnit.test('_mouseUpHandler on a text in a group', function (assert) { + var iText = new fabric.IText('test'); + iText.initDelayedCursor = function () { }; + iText.renderCursorOrSelection = function () { }; + assert.equal(iText.isEditing, false, 'iText not editing'); + var group = new fabric.Group([iText], { subTargetCheck: true, interactive: true }); + canvas.add(group); + iText.selected = true; + iText.__lastSelected = true; + canvas.__onMouseUp({ clientX: 1, clientY: 1 }); + assert.equal(iText.isEditing, true, 'iText should enter editing'); iText.exitEditing(); + group.interactive = false; + iText.selected = true; + iText.__lastSelected = true; + canvas.__onMouseUp({ clientX: 1, clientY: 1 }); + assert.equal(iText.isEditing, false, 'iText should not enter editing'); }); QUnit.test('_mouseUpHandler on a corner of selected text DOES NOT enter edit', function(assert) { var iText = new fabric.IText('test'); @@ -193,7 +212,7 @@ iText.__lastSelected = true; iText.__corner = 'mt'; iText.mouseUpHandler({ e: {} }); - assert.equal(iText.isEditing, false, 'iText did not entered editing'); + assert.equal(iText.isEditing, false, 'iText should not enter editing'); iText.exitEditing(); canvas.renderAll(); }); diff --git a/test/unit/object.js b/test/unit/object.js index 54807171622..b664497acc4 100644 --- a/test/unit/object.js +++ b/test/unit/object.js @@ -622,7 +622,10 @@ var object = new fabric.Object(); var addedEventFired = false; - object.on('added', function() { addedEventFired = true; }); + object.on('added', function (opt) { + addedEventFired = true; + assert.ok(opt.target === canvas, 'target should equal to canvas'); + }); canvas.add(object); assert.ok(addedEventFired); @@ -645,7 +648,10 @@ canvas.add(object); - object.on('removed', function() { removedEventFired = true; }); + object.on('removed', function (opt) { + removedEventFired = true; + assert.ok(opt.target === canvas, 'target should equal to canvas'); + }); canvas.remove(object); assert.ok(removedEventFired); @@ -803,6 +809,289 @@ assert.equal(object.moveTo(), object, 'should be chainable'); }); + QUnit.test('isDescendantOf', function (assert) { + var object = new fabric.Object(); + var parent = new fabric.Object(); + assert.ok(typeof object.isDescendantOf === 'function'); + parent.canvas = canvas; + object.group = parent; + assert.ok(object.isDescendantOf(parent)); + object.group = { + group: parent + }; + assert.ok(object.isDescendantOf(parent)); + assert.ok(object.isDescendantOf(canvas)); + object.group = undefined; + assert.ok(object.isDescendantOf(parent) === false); + assert.ok(object.isDescendantOf(canvas) === false); + object.canvas = canvas; + assert.ok(object.isDescendantOf(canvas)); + assert.ok(object.isDescendantOf(object) === false); + }); + + QUnit.test('getAncestors', function (assert) { + var object = new fabric.Object(); + var parent = new fabric.Object(); + var other = new fabric.Object(); + assert.ok(typeof object.getAncestors === 'function'); + assert.deepEqual(object.getAncestors(), []); + object.group = parent; + assert.deepEqual(object.getAncestors(), [parent]); + parent.canvas = canvas; + assert.deepEqual(object.getAncestors(), [parent, canvas]); + parent.group = other; + assert.deepEqual(object.getAncestors(), [parent, other]); + other.canvas = canvas; + assert.deepEqual(object.getAncestors(), [parent, other, canvas]); + delete object.group; + assert.deepEqual(object.getAncestors(), []); + }); + + function prepareObjectsForTreeTesting() { + var TObject = fabric.util.createClass(fabric.Object, { + toJSON: function () { + return { + id: this.id, + objects: this._objects?.map(o => o.id), + parent: this.parent?.id, + canvas: this.canvas?.id + } + }, + toString: function () { + return JSON.stringify(this.toJSON(), null, '\t'); + } + }); + var Collection = fabric.util.createClass(TObject, fabric.Collection, { + initialize: function ({ id }) { + this.id = id; + this._objects = []; + }, + add: function () { + fabric.Collection.add.call(this, arguments, this._onObjectAdded); + }, + insertAt: function (objects, index) { + fabric.Collection.insertAt.call(this, objects, index, this._onObjectAdded); + }, + remove: function () { + fabric.Collection.remove.call(this, arguments, this._onObjectRemoved); + }, + _onObjectAdded: function (object) { + object.group = this; + }, + _onObjectRemoved: function (object) { + delete object.group; + }, + removeAll: function () { + this.remove.apply(this, this._objects); + }, + }); + var canvas = fabric.util.object.extend(new Collection({ id: 'canvas' }), { + _onObjectAdded: function (object) { + object.canvas = this; + }, + _onObjectRemoved: function (object) { + delete object.canvas; + }, + }); + return { + object: new TObject({ id: 'object' }), + other: new TObject({ id: 'other' }), + a: new Collection({ id: 'a' }), + b: new Collection({ id: 'b' }), + c: new Collection({ id: 'c' }), + canvas + } + } + + QUnit.test('findCommonAncestors', function (assert) { + function findCommonAncestors(object, other, strict, expected, message) { + var common = object.findCommonAncestors(other, strict); + assert.deepEqual( + common.fork.map((obj) => obj.id), + expected.fork.map((obj) => obj.id), + message || `fork property should match check between '${object.id}' and '${other.id}'` + ); + assert.deepEqual( + common.otherFork.map((obj) => obj.id), + expected.otherFork.map((obj) => obj.id), + message || `otherFork property should match check between '${object.id}' and '${other.id}'` + ); + assert.deepEqual( + common.common.map((obj) => obj.id), + expected.common.map((obj) => obj.id), + message || `common property should match check between '${object.id}' and '${other.id}'` + ); + var oppositeCommon = other.findCommonAncestors(object, strict); + assert.deepEqual( + oppositeCommon.fork.map((obj) => obj.id), + expected.otherFork.map((obj) => obj.id), + message || `fork property should match opposite check between '${other.id}' and '${object.id}'` + ); + assert.deepEqual( + oppositeCommon.otherFork.map((obj) => obj.id), + expected.fork.map((obj) => obj.id), + message || `otherFork property should match opposite check between '${other.id}' and '${object.id}'` + ); + assert.deepEqual( + oppositeCommon.common.map((obj) => obj.id), + expected.common.map((obj) => obj.id), + message || `common property should match opposite check between '${other.id}' and '${object.id}'` + ); + } + var { object, other, a, b, c, canvas } = prepareObjectsForTreeTesting(); + assert.ok(typeof object.findCommonAncestors === 'function'); + assert.ok(Array.isArray(a._objects)); + assert.ok(a._objects !== b._objects); + // same object + findCommonAncestors(object, object, false, { fork: [], otherFork: [] , common: [object] }); + // foreign objects + findCommonAncestors(object, other, false, { fork: [object], otherFork: [other] , common: [] }); + // same level + a.add(object, other); + findCommonAncestors(object, other, false, { fork: [object], otherFork: [other], common: [a] }); + findCommonAncestors(object, a, false, { fork: [object], otherFork: [], common: [a] }); + findCommonAncestors(other, a, false, { fork: [other], otherFork: [], common: [a] }); + findCommonAncestors(a, object, false, { fork: [], otherFork: [object], common: [a] }); + findCommonAncestors(a, object, true, { fork: [], otherFork: [object], common: [a] }, 'strict option should have no effect when outside canvas'); + // different level + a.remove(object); + b.add(object); + a.add(b); + findCommonAncestors(object, b, false, { fork: [object], otherFork: [], common: [b, a] }); + findCommonAncestors(b, a, false, { fork: [b], otherFork: [], common: [a] }); + findCommonAncestors(object, other, false, { fork: [object, b], otherFork: [other], common: [a] }); + // with common ancestor + assert.equal(c.size(), 0, 'c should be empty'); + c.add(a); + assert.equal(c.size(), 1, 'c should contain a'); + findCommonAncestors(object, b, false, { fork: [object], otherFork: [], common: [b, a, c] }); + findCommonAncestors(b, a, false, { fork: [b], otherFork: [], common: [a, c] }); + findCommonAncestors(object, other, false, { fork: [object, b], otherFork: [other], common: [a, c] }); + findCommonAncestors(object, c, false, { fork: [object, b, a], otherFork: [], common: [c] }); + findCommonAncestors(other, c, false, { fork: [other, a], otherFork: [], common: [c] }); + findCommonAncestors(b, c, false, { fork: [b, a], otherFork: [], common: [c] }); + findCommonAncestors(a, c, false, { fork: [a], otherFork: [], common: [c] }); + // deeper asymmetrical + c.removeAll(); + assert.equal(c.size(), 0, 'c should be cleared'); + a.remove(other); + c.add(other, a); + findCommonAncestors(object, b, false, { fork: [object], otherFork: [], common: [b, a, c] }); + findCommonAncestors(b, a, false, { fork: [b], otherFork: [], common: [a, c] }); + findCommonAncestors(a, other, false, { fork: [a], otherFork: [other], common: [c] }); + findCommonAncestors(object, other, false, { fork: [object, b, a], otherFork: [other], common: [c] }); + findCommonAncestors(object, c, false, { fork: [object, b, a], otherFork: [], common: [c] }); + findCommonAncestors(other, c, false, { fork: [other], otherFork: [], common: [c] }); + findCommonAncestors(b, c, false, { fork: [b, a], otherFork: [], common: [c] }); + findCommonAncestors(a, c, false, { fork: [a], otherFork: [], common: [c] }); + // with canvas + a.removeAll(); + b.removeAll(); + c.removeAll(); + canvas.add(object, other); + findCommonAncestors(object, other, true, { fork: [object], otherFork: [other], common: [] }); + findCommonAncestors(object, other, false, { fork: [object], otherFork: [other], common: [canvas] }); + findCommonAncestors(object, canvas, true, { fork: [object], otherFork: [canvas], common: [] }); + findCommonAncestors(object, canvas, false, { fork: [object], otherFork: [], common: [canvas] }); + findCommonAncestors(other, canvas, false, { fork: [other], otherFork: [], common: [canvas] }); + // parent precedes canvas when checking ancestor + a.add(object); + assert.ok(object.canvas === canvas, 'object should have canvas set'); + findCommonAncestors(object, other, true, { fork: [object, a], otherFork: [other], common: [] }); + findCommonAncestors(object, other, false, { fork: [object, a], otherFork: [other, canvas], common: [] }); + canvas.insertAt(a, 0); + findCommonAncestors(object, other, true, { fork: [object, a], otherFork: [other], common: [] }); + findCommonAncestors(object, other, false, { fork: [object, a], otherFork: [other], common: [canvas] }); + findCommonAncestors(a, other, false, { fork: [a], otherFork: [other], common: [canvas] }); + findCommonAncestors(a, canvas, false, { fork: [a], otherFork: [], common: [canvas] }); + findCommonAncestors(object, canvas, false, { fork: [object, a], otherFork: [], common: [canvas] }); + findCommonAncestors(other, canvas, false, { fork: [other], otherFork: [], common: [canvas] }); + }); + + QUnit.assert.isInFrontOf = function (object, other, expected) { + var actual = object.isInFrontOf(other); + this.pushResult({ + expected: expected, + actual: actual, + result: actual === expected, + message: `'${expected ? object.id : other.id}' should be in front of '${expected ? other.id : object.id}'` + }); + if (actual === expected && typeof expected === 'boolean') { + var actual2 = other.isInFrontOf(object); + this.pushResult({ + expected: !expected, + actual: actual2, + result: actual2 === !expected, + message: `should match opposite check between '${object.id}' and '${other.id}'` + }); + } + }; + + QUnit.test('isInFrontOf', function (assert) { + var { object, other, a, b, c, canvas } = prepareObjectsForTreeTesting(); + assert.ok(typeof object.isInFrontOf === 'function'); + assert.ok(Array.isArray(a._objects)); + assert.ok(a._objects !== b._objects); + // same object + assert.isInFrontOf(object, object, undefined); + // foreign objects + assert.isInFrontOf(object, other, undefined); + // same level + a.add(object, other); + assert.isInFrontOf(object, other, false); + assert.isInFrontOf(object, a, true); + assert.isInFrontOf(other, a, true); + // different level + a.remove(object); + b.add(object); + a.add(b); + assert.isInFrontOf(object, b, true); + assert.isInFrontOf(b, a, true); + assert.isInFrontOf(object, other, true); + // with common ancestor + assert.equal(c.size(), 0, 'c should be empty'); + c.add(a); + assert.equal(c.size(), 1, 'c should contain a'); + assert.isInFrontOf(object, b, true); + assert.isInFrontOf(b, a, true); + assert.isInFrontOf(object, other, true); + assert.isInFrontOf(object, c, true); + assert.isInFrontOf(other, c, true); + assert.isInFrontOf(b, c, true); + assert.isInFrontOf(a, c, true); + // deeper asymmetrical + c.removeAll(); + assert.equal(c.size(), 0, 'c should be cleared'); + a.remove(other); + c.add(other, a); + assert.isInFrontOf(object, b, true); + assert.isInFrontOf(b, a, true); + assert.isInFrontOf(a, other, true); + assert.isInFrontOf(object, other, true); + assert.isInFrontOf(object, c, true); + assert.isInFrontOf(other, c, true); + assert.isInFrontOf(b, c, true); + assert.isInFrontOf(a, c, true); + // with canvas + a.removeAll(); + b.removeAll(); + c.removeAll(); + canvas.add(object, other); + assert.isInFrontOf(object, other, false); + assert.isInFrontOf(object, canvas, true); + assert.isInFrontOf(other, canvas, true); + // parent precedes canvas when checking ancestor + a.add(object); + assert.ok(object.canvas === canvas, 'object should have canvas set'); + assert.isInFrontOf(object, other, undefined); + canvas.insertAt(a, 0); + assert.isInFrontOf(object, other, false); + assert.isInFrontOf(a, other, false); + assert.isInFrontOf(a, canvas, true); + assert.isInFrontOf(object, canvas, true); + assert.isInFrontOf(other, canvas, true); + }); + QUnit.test('getTotalObjectScaling with zoom', function(assert) { var object = new fabric.Object({ scaleX: 3, scaleY: 2}); canvas.setZoom(3); diff --git a/test/visual/group_layout.js b/test/visual/group_layout.js index ebdae56cf34..94db9052bc0 100644 --- a/test/visual/group_layout.js +++ b/test/visual/group_layout.js @@ -76,6 +76,27 @@ height: 300 }); + function fitContentLayoutRelative(canvas, callback) { + var g = createGroupForLayoutTests('fit-content layout', { + backgroundColor: 'blue' + }); + g.clone().then((clone) => { + canvas.add(clone); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + }) + } + + tests.push({ + test: 'fit-content layout', + code: fitContentLayoutRelative, + golden: 'group-layout/fit-content.png', + newModule: 'Group Layout', + percentage: 0.06, + width: 400, + height: 300 + }); + function fitContentReLayout(canvas, callback) { var g = createGroupForLayoutTests('fit-content layout', { backgroundColor: 'blue' @@ -334,5 +355,5 @@ }); } */ - // tests.forEach(visualTestLoop(QUnit)); + tests.forEach(visualTestLoop(QUnit)); })();