diff --git a/src/canvas.class.js b/src/canvas.class.js index 33bec8a8816..322d211109e 100644 --- a/src/canvas.class.js +++ b/src/canvas.class.js @@ -118,13 +118,23 @@ /** * Indicates which key enable multiple click selection - * values: altKey, shiftKey, ctrlKey + * values: altKey, shiftKey, ctrlKey, cmdKey * @since 1.6.2 * @type String * @default */ selectionKey: 'shiftKey', + /** + * Indicates which key enable alternative selection + * in case of target overlapping with active object + * values: altKey, shiftKey, ctrlKey, cmdKey + * @since 1.6.5 + * @type null|String + * @default + */ + altSelectionKey: null, + /** * Color of selection * @type String @@ -398,7 +408,6 @@ vptPointer = this.restorePointerVpt(pointer), p = fabric.util.transformPoint(vptPointer, invertedM); return fabric.util.transformPoint(p, vpt); - //return { x: p.x * vpt[0], y: p.y * vpt[3] }; }, /** @@ -1006,7 +1015,8 @@ var ignoreZoom = true, pointer = this.getPointer(e, ignoreZoom), activeGroup = this.getActiveGroup(), - activeObject = this.getActiveObject(); + activeObject = this.getActiveObject(), + activeTarget; // first check current group (if one exists) // active group does not check sub targets like normal groups. @@ -1014,14 +1024,25 @@ if (activeGroup && !skipGroup && this._checkTarget(pointer, activeGroup)) { return activeGroup; } - - if (activeObject && this._checkTarget(pointer, activeObject)) { + // if we hit the corner of an activeObject, let's return that. + if (activeObject && activeObject._findTargetCorner(pointer)) { return activeObject; } + if (activeObject && this._checkTarget(pointer, activeObject)) { + if (!this.preserveObjectStacking) { + return activeObject; + } + else { + activeTarget = activeObject; + } + } this.targets = []; var target = this._searchPossibleTargets(this._objects, pointer); + if (e[this.altSelectionKey] && target && activeTarget && target !== activeTarget) { + target = activeTarget; + } this._fireOverOutEvents(target, e); return target; }, diff --git a/src/mixins/object_geometry.mixin.js b/src/mixins/object_geometry.mixin.js index 8e1943496fd..2f0a757bab2 100644 --- a/src/mixins/object_geometry.mixin.js +++ b/src/mixins/object_geometry.mixin.js @@ -355,6 +355,10 @@ return this; }, + /* + * calculate rotation matrix of an object + * @return {Array} rotation matrix for the object + */ _calcRotateMatrix: function() { if (this.angle) { var theta = degreesToRadians(this.angle), cos = Math.cos(theta), sin = Math.sin(theta); diff --git a/src/mixins/stateful.mixin.js b/src/mixins/stateful.mixin.js index ced8da39165..f97d9505a5a 100644 --- a/src/mixins/stateful.mixin.js +++ b/src/mixins/stateful.mixin.js @@ -1,45 +1,75 @@ -/* - Depends on `stateProperties` -*/ -fabric.util.object.extend(fabric.Object.prototype, /** @lends fabric.Object.prototype */ { - - /** - * Returns true if object state (one of its state properties) was changed - * @return {Boolean} true if instance' state has changed since `{@link fabric.Object#saveState}` was called - */ - hasStateChanged: function() { - return this.stateProperties.some(function(prop) { - return this.get(prop) !== this.originalState[prop]; - }, this); - }, - - /** - * Saves state of an object - * @param {Object} [options] Object with additional `stateProperties` array to include when saving state - * @return {fabric.Object} thisArg - */ - saveState: function(options) { - this.stateProperties.forEach(function(prop) { - this.originalState[prop] = this.get(prop); - }, this); - - if (options && options.stateProperties) { - options.stateProperties.forEach(function(prop) { - this.originalState[prop] = this.get(prop); - }, this); - } +(function() { - return this; - }, + var extend = fabric.util.object.extend; - /** - * Setups state of an object - * @return {fabric.Object} thisArg - */ - setupState: function() { - this.originalState = { }; - this.saveState(); + /* + Depends on `stateProperties` + */ + function saveProps(origin, destination, props) { + var tmpObj = { }, deep = true; + props.forEach(function(prop) { + tmpObj[prop] = origin[prop]; + }); + extend(origin[destination], tmpObj, deep); + } - return this; + function _isEqual(origValue, currentValue) { + if (origValue instanceof Array) { + if (origValue.length !== currentValue.length) { + return false + } + var _currentValue = currentValue.concat().sort(), + _origValue = origValue.concat().sort(); + return !_origValue.some(function(v, i) { + return !_isEqual(_currentValue[i], v); + }); + } + else if (origValue instanceof Object) { + for (var key in origValue) { + if (!_isEqual(origValue[key], currentValue[key])) { + return false; + } + } + return true; + } + else { + return origValue === currentValue; + } } -}); + + + fabric.util.object.extend(fabric.Object.prototype, /** @lends fabric.Object.prototype */ { + + /** + * Returns true if object state (one of its state properties) was changed + * @return {Boolean} true if instance' state has changed since `{@link fabric.Object#saveState}` was called + */ + hasStateChanged: function() { + return !_isEqual(this.originalState, this); + }, + + /** + * Saves state of an object + * @param {Object} [options] Object with additional `stateProperties` array to include when saving state + * @return {fabric.Object} thisArg + */ + saveState: function(options) { + saveProps(this, 'originalState', this.stateProperties); + if (options && options.stateProperties) { + saveProps(this, 'originalState', options.stateProperties); + } + return this; + }, + + /** + * Setups state of an object + * @param {Object} [options] Object with additional `stateProperties` array to include when saving state + * @return {fabric.Object} thisArg + */ + setupState: function(options) { + this.originalState = { }; + this.saveState(options); + return this; + } + }); +})(); diff --git a/src/shapes/image.class.js b/src/shapes/image.class.js index eb6ee841ef6..3af86487071 100644 --- a/src/shapes/image.class.js +++ b/src/shapes/image.class.js @@ -13,6 +13,13 @@ return; } + var stateProperties = fabric.Object.prototype.stateProperties.concat(); + stateProperties.push( + 'alignX', + 'alignY', + 'meetOrSlice' + ); + /** * Image class * @class fabric.Image @@ -97,6 +104,14 @@ */ minimumScaleTrigger: 0.5, + /** + * List of properties to consider when checking if + * state of an object is changed ({@link fabric.Object#hasStateChanged}) + * as well as for history (undo/redo) purposes + * @type Array + */ + stateProperties: stateProperties, + /** * Constructor * @param {HTMLImageElement | String} element Image element diff --git a/src/shapes/object.class.js b/src/shapes/object.class.js index 3c6c68d3628..260c623c6ad 100644 --- a/src/shapes/object.class.js +++ b/src/shapes/object.class.js @@ -765,7 +765,7 @@ 'top left width height scaleX scaleY flipX flipY originX originY transformMatrix ' + 'stroke strokeWidth strokeDashArray strokeLineCap strokeLineJoin strokeMiterLimit ' + 'angle opacity fill fillRule globalCompositeOperation shadow clipTo visible backgroundColor ' + - 'alignX alignY meetOrSlice skewX skewY' + 'skewX skewY' ).split(' '), /** diff --git a/src/util/lang_object.js b/src/util/lang_object.js index a21f874a4f5..b9da32003fd 100644 --- a/src/util/lang_object.js +++ b/src/util/lang_object.js @@ -7,10 +7,30 @@ * @param {Object} source Where to copy from * @return {Object} */ - function extend(destination, source) { + function extend(destination, source, deep) { // JScript DontEnum bug is not taken care of - for (var property in source) { - destination[property] = source[property]; + // the deep clone is for internal use, is not meant to avoid + // javascript traps or cloning html element or self referenced objects. + if (deep) { + if (source instanceof Array) { + destination = source.map(function(v) { + return clone(v, deep) + }) + } + else if (source instanceof Object) { + for (var property in source) { + destination[property] = clone(source[property], deep) + } + } + else { + // this sounds odd for an extend but is ok for recursive use + destination = source; + } + } + else { + for (var property in source) { + destination[property] = source[property]; + } } return destination; } @@ -21,8 +41,8 @@ * @param {Object} object Object to clone * @return {Object} */ - function clone(object) { - return extend({ }, object); + function clone(object, deep) { + return extend({ }, object, deep); } /** @namespace fabric.util.object */ diff --git a/test.js b/test.js index e61ecb8a21b..bdf59f854f7 100644 --- a/test.js +++ b/test.js @@ -41,6 +41,7 @@ testrunner.run({ './test/unit/collection.js', './test/unit/point.js', './test/unit/intersection.js', + './test/unit/stateful.js' ] }, function(err, report) { if (err) { diff --git a/test/unit/canvas.js b/test/unit/canvas.js index ce3cdd929dd..f4aeb514587 100644 --- a/test/unit/canvas.js +++ b/test/unit/canvas.js @@ -346,17 +346,23 @@ test('findTarget preserveObjectStacking true', function() { ok(typeof canvas.findTarget == 'function'); canvas.preserveObjectStacking = true; - var rect = makeRect({ left: 0, top: 0 }), - rectOver = makeRect({ left: 0, top: 0 }), + var rect = makeRect({ left: 0, top: 0, width: 30, height: 30 }), + rectOver = makeRect({ left: 0, top: 0, width: 30, height: 30 }), target, - pointer = { clientX: 5, clientY: 5 }; + pointer = { clientX: 15, clientY: 15, 'shiftKey': true }, + pointer2 = { clientX: 4, clientY: 4 }; canvas.add(rect); canvas.add(rectOver); target = canvas.findTarget(pointer); equal(target, rectOver, 'Should return the rectOver, rect is not considered'); canvas.setActiveObject(rect); target = canvas.findTarget(pointer); - equal(target, rect, 'Should return the rect, because it is active'); + equal(target, rectOver, 'Should still return rectOver because is above active object'); + target = canvas.findTarget(pointer2); + equal(target, rect, 'Should rect because a corner of the activeObject has been hit'); + canvas.altSelectionKey = 'shiftKey'; + target = canvas.findTarget(pointer); + equal(target, rect, 'Should rect because active and altSelectionKey is pressed'); canvas.preserveObjectStacking = false; }); diff --git a/test/unit/object.js b/test/unit/object.js index ae06cfe9ad1..f4480ea6742 100644 --- a/test/unit/object.js +++ b/test/unit/object.js @@ -680,28 +680,6 @@ } }); - test('hasStateChanged', function() { - var cObj = new fabric.Object(); - ok(typeof cObj.hasStateChanged == 'function'); - cObj.setupState(); - ok(!cObj.hasStateChanged()); - cObj.saveState(); - cObj.set('left', 123).set('top', 456); - ok(cObj.hasStateChanged()); - }); - - test('saveState', function() { - var cObj = new fabric.Object(); - ok(typeof cObj.saveState == 'function'); - cObj.setupState(); - equal(cObj.saveState(), cObj, 'chainable'); - cObj.set('left', 123).set('top', 456); - cObj.saveState(); - cObj.set('left', 223).set('top', 556); - equal(cObj.originalState.left, 123); - equal(cObj.originalState.top, 456); - }); - test('intersectsWithRectangle', function() { var cObj = new fabric.Object({ left: 50, top: 50, width: 100, height: 100 }); cObj.setCoords(); diff --git a/test/unit/stateful.js b/test/unit/stateful.js new file mode 100644 index 00000000000..93d506aed4e --- /dev/null +++ b/test/unit/stateful.js @@ -0,0 +1,98 @@ +(function(){ + + QUnit.module('fabric.stateful'); + + test('hasStateChanged', function() { + var cObj = new fabric.Object(); + ok(typeof cObj.hasStateChanged == 'function'); + cObj.setupState(); + ok(!cObj.hasStateChanged(), 'state should not be changed'); + cObj.saveState(); + cObj.set('left', 123).set('top', 456); + ok(cObj.hasStateChanged()); + }); + + test('saveState', function() { + var cObj = new fabric.Object(); + ok(typeof cObj.saveState == 'function'); + cObj.setupState(); + equal(cObj.saveState(), cObj, 'chainable'); + cObj.set('left', 123).set('top', 456); + cObj.saveState(); + cObj.set('left', 223).set('top', 556); + equal(cObj.originalState.left, 123); + equal(cObj.originalState.top, 456); + }); + + test('saveState with extra props', function() { + var cObj = new fabric.Object(); + cObj.prop1 = 'a'; + cObj.prop2 = 'b'; + cObj.left = 123; + var extraProps = ['prop1', 'prop2']; + var options = { stateProperties: extraProps }; + cObj.setupState(options); + equal(cObj.originalState.prop1, 'a', 'it saves the extra props'); + equal(cObj.originalState.prop2, 'b', 'it saves the extra props'); + cObj.prop1 = 'c'; + ok(cObj.hasStateChanged(), 'it detects changes in extra props'); + equal(cObj.originalState.left, 123, 'normal props are still there'); + }); + + test('saveState with array', function() { + var cObj = new fabric.Text('Hello'); + cObj.set('textDecoration', ['underline']); + cObj.setupState(); + deepEqual(cObj.textDecoration, cObj.originalState.textDecoration, 'textDecoration in state is deepEqual'); + notEqual(cObj.textDecoration, cObj.originalState.textDecoration, 'textDecoration in not same Object'); + cObj.textDecoration[0] = 'overline'; + ok(cObj.hasStateChanged(), 'hasStateChanged detects changes in nested props'); + + cObj.set('textDecoration', ['overline', 'underline']); + cObj.saveState(); + cObj.set('textDecoration', ['underline', 'overline']); + ok(!cObj.hasStateChanged(), 'order does no matter'); + + cObj.set('textDecoration', ['underline']); + cObj.saveState(); + cObj.set('textDecoration', ['underline', 'overline']); + ok(cObj.hasStateChanged(), 'more properties added'); + + cObj.set('textDecoration', ['underline', 'overline']); + cObj.saveState(); + cObj.set('textDecoration', ['overline']); + ok(cObj.hasStateChanged(), 'less properties'); + }); + + test('saveState with fabric class gradient', function() { + var cObj = new fabric.Object(); + var gradient = new fabric.Gradient({ + type: 'linear', + coords: { + x1: 0, + y1: 10, + x2: 100, + y2: 200, + }, + colorStops: [ + { offset: 0, color: 'red', opacity: 0 }, + { offset: 1, color: 'green' } + ] + }); + + cObj.set('fill', '#FF0000'); + cObj.setupState(); + cObj.setFill(gradient); + ok(cObj.hasStateChanged(), 'hasStateChanged detects changes in nested props'); + cObj.saveState(); + gradient.type = 'radial'; + ok(cObj.hasStateChanged(), 'hasStateChanged detects changes in nested props on first level of nesting'); + cObj.saveState(); + gradient.coords.x1 = 3; + ok(cObj.hasStateChanged(), 'hasStateChanged detects changes in nested props on second level of nesting'); + cObj.saveState(); + gradient.colorStops[0].color = 'blue'; + ok(cObj.hasStateChanged(), 'hasStateChanged detects changes in nested props on third level of nesting'); + }); + +})();