diff --git a/build.js b/build.js index 91fe3924..43d454f8 100644 --- a/build.js +++ b/build.js @@ -196,6 +196,7 @@ var filesToInclude = [ 'src/shapes/polygon.class.js', 'src/shapes/path.class.js', 'src/shapes/group.class.js', + 'src/shapes/layer.class.js', ifSpecifiedInclude('interaction', 'src/shapes/active_selection.class.js'), 'src/shapes/image.class.js', diff --git a/src/canvas.class.js b/src/canvas.class.js index 39964f2c..5b3410a4 100644 --- a/src/canvas.class.js +++ b/src/canvas.class.js @@ -10,6 +10,9 @@ * @extends fabric.StaticCanvas * @tutorial {@link http://fabricjs.com/fabric-intro-part-1#canvas} * @see {@link fabric.Canvas#initialize} for constructor definition + * + * @fires resize + * @fires window:resize * * @fires object:modified at the end of a transform or any change when statefull is true * @fires object:rotating while an object is being rotated from the control diff --git a/src/mixins/canvas_events.mixin.js b/src/mixins/canvas_events.mixin.js index f56ed9fe..e06832c7 100644 --- a/src/mixins/canvas_events.mixin.js +++ b/src/mixins/canvas_events.mixin.js @@ -383,8 +383,9 @@ /** * @private */ - _onResize: function () { + _onResize: function (e) { this.calcOffset(); + this.fire('window:resize', { e: e }); }, /** diff --git a/src/mixins/object_geometry.mixin.js b/src/mixins/object_geometry.mixin.js index 69a8391d..a79bb80e 100644 --- a/src/mixins/object_geometry.mixin.js +++ b/src/mixins/object_geometry.mixin.js @@ -720,6 +720,8 @@ * @param {Number} [options.scaleY] * @param {Number} [options.skewX] * @param {Number} [options.skewY] + * @param {Number} [options.width] + * @param {Number} [options.height] * @private * @returns {fabric.Point} dimensions */ diff --git a/src/shapes/group.class.js b/src/shapes/group.class.js index 3f6fbb5a..32bea57c 100644 --- a/src/shapes/group.class.js +++ b/src/shapes/group.class.js @@ -99,6 +99,7 @@ this.callSuper('initialize', Object.assign({}, options, { angle: 0, skewX: 0, skewY: 0 })); this.forEachObject(function (object) { this.enterGroup(object, false); + object.fire('added:initialized', { target: this }); }, this); this._applyLayoutStrategy({ type: 'initialization', @@ -255,6 +256,11 @@ /* _DEV_MODE_END_ */ return false; } + else if (object.group) { + /* _DEV_MODE_START_ */ + console.warn('fabric.Group: object is about to enter group and leave another'); + /* _DEV_MODE_END_ */ + } return true; }, @@ -455,7 +461,6 @@ /** * @override - * @return {Boolean} */ setCoords: function () { this.callSuper('setCoords'); @@ -491,12 +496,22 @@ * @private * @param {fabric.Object} object * @param {fabric.Point} diff + * @param {boolean} [setCoords] perf enhancement, instead of iterating over objects again */ - _adjustObjectPosition: function (object, diff) { - object.set({ - left: object.left + diff.x, - top: object.top + diff.y, - }); + _adjustObjectPosition: function (object, diff, setCoords) { + // layer doesn't need coords so we don't set them + if (object instanceof fabric.Layer) { + object.forEachObject(function (obj) { + this._adjustObjectPosition(obj, diff, setCoords); + }.bind(this)); + } + else { + object.set({ + left: object.left + diff.x, + top: object.top + diff.y, + }); + setCoords && object.setCoords(); + } }, /** @@ -526,21 +541,25 @@ var newCenter = new fabric.Point(result.centerX, result.centerY); var vector = center.subtract(newCenter).add(new fabric.Point(result.correctionX || 0, result.correctionY || 0)); var diff = transformPoint(vector, invertTransform(this.calcOwnMatrix()), true); + var objectsSetCoords = false; // set dimensions this.set({ width: result.width, height: result.height }); - // adjust objects to account for new center - !context.objectsRelativeToGroup && this.forEachObject(function (object) { - this._adjustObjectPosition(object, diff); - }, this); - // clip path as well - !isFirstLayout && this.layout !== 'clip-path' && this.clipPath && !this.clipPath.absolutePositioned - && this._adjustObjectPosition(this.clipPath, diff); if (!newCenter.eq(center) || initialTransform) { // set position this.setPositionByOrigin(newCenter, 'center', 'center'); initialTransform && this.set(initialTransform); - this.setCoords(); + // perf: avoid iterating over objects twice by setting coords only on instance + // and delegating the task to `_adjustObjectPosition` + this.callSuper('setCoords'); + objectsSetCoords = this.subTargetCheck; } + // adjust objects to account for new center + !context.objectsRelativeToGroup && this.forEachObject(function (object) { + this._adjustObjectPosition(object, diff, objectsSetCoords); + }, this); + // clip path as well + !isFirstLayout && this.layout !== 'clip-path' && this.clipPath && !this.clipPath.absolutePositioned + && this._adjustObjectPosition(this.clipPath, diff, objectsSetCoords); } else if (isFirstLayout) { // fill `result` with initial values for the layout hook @@ -565,7 +584,15 @@ result: result, diff: diff }); - // recursive up + this._bubbleLayout(context); + }, + + + /** + * bubble layout recursive up + * @private + */ + _bubbleLayout: function (context) { if (this.group && this.group._applyLayoutStrategy) { // append the path recursion to context if (!context.path) { @@ -577,7 +604,6 @@ } }, - /** * Override this method to customize layout. * If you need to run logic once layout completes use `onLayout` @@ -779,23 +805,36 @@ if (objects.length === 0) { return null; } - var objCenter, sizeVector, min, max, a, b; + var objCenter, sizeVector, min = new fabric.Point(0, 0), max = new fabric.Point(0, 0), a, b; objects.forEach(function (object, i) { - objCenter = object.getRelativeCenterPoint(); - sizeVector = object._getTransformedDimensions().scalarDivideEquals(2); + if (object instanceof fabric.Layer) { + var bbox = object.getObjectsBoundingBox(object._objects.slice(0)); + if (!bbox) { + return; + } + sizeVector = object._getTransformedDimensions({ + width: bbox.width, + height: bbox.height + }).scalarDivideEquals(2); + objCenter = new fabric.Point(bbox.centerX, bbox.centerY); + } + else { + sizeVector = object._getTransformedDimensions().scalarDivideEquals(2); + objCenter = object.getRelativeCenterPoint(); + } if (object.angle) { var rad = degreesToRadians(object.angle), sin = Math.abs(fabric.util.sin(rad)), cos = Math.abs(fabric.util.cos(rad)), rx = sizeVector.x * cos + sizeVector.y * sin, ry = sizeVector.x * sin + sizeVector.y * cos; - sizeVector = new fabric.Point(rx, ry); + sizeVector.setXY(rx, ry); } a = objCenter.subtract(sizeVector); b = objCenter.add(sizeVector); if (i === 0) { - min = new fabric.Point(Math.min(a.x, b.x), Math.min(a.y, b.y)); - max = new fabric.Point(Math.max(a.x, b.x), Math.max(a.y, b.y)); + min.setXY(Math.min(a.x, b.x), Math.min(a.y, b.y)); + max.setXY(Math.max(a.x, b.x), Math.max(a.y, b.y)); } else { min.setXY(Math.min(min.x, a.x, b.x), Math.min(min.y, a.y, b.y)); @@ -831,6 +870,29 @@ // override by subclass }, + + /** + * Calculate object dimensions from its properties + * @override disregard `strokeWidth` + * @private + * @returns {fabric.Point} dimensions + */ + _getNonTransformedDimensions: function () { + return new fabric.Point(this.width, this.height); + }, + + /** + * @private + * @override we want instance to fill parent so we disregard transformations + * @param {Object} [options] + * @param {Number} [options.width] + * @param {Number} [options.height] + * @returns {fabric.Point} dimensions + */ + _getTransformedDimensions: function (options) { + return this.callSuper('_getTransformedDimensions', Object.assign(options || {}, { strokeWidth: 0 })); + }, + /** * * @private diff --git a/src/shapes/layer.class.js b/src/shapes/layer.class.js new file mode 100644 index 00000000..6cfcb122 --- /dev/null +++ b/src/shapes/layer.class.js @@ -0,0 +1,268 @@ +(function (global) { + + 'use strict'; + + var fabric = global.fabric || (global.fabric = {}); + + if (fabric.Layer) { + fabric.warn('fabric.Layer is already defined'); + return; + } + + /** + * Layer class + * @class fabric.Layer + * @extends fabric.Group + * @see {@link fabric.Layer#initialize} for constructor definition + */ + fabric.Layer = fabric.util.createClass(fabric.Group, /** @lends fabric.Layer.prototype */ { + + /** + * @default + * @type string + */ + type: 'layer', + + /** + * @override + * @default + */ + layout: 'auto', + + /** + * @override + * @default + */ + objectCaching: false, + + /** + * @override + * @default + */ + strokeWidth: 0, + + /** + * @override + * @default + */ + hasControls: false, + + /** + * @override + * @default + */ + hasBorders: false, + + /** + * @override + * @default + */ + lockMovementX: true, + + /** + * @override + * @default + */ + lockMovementY: true, + + /** + * @default + * @override + */ + originX: 'center', + + /** + * @default + * @override + */ + originY: 'center', + + /** + * we don't want to int with the layer, only with it's objects + * this makes group selection possible over a layer + * @override + * @default + */ + selectable: false, + + /** + * Constructor + * + * @param {fabric.Object[]} [objects] instance objects + * @param {Object} [options] Options object + * @return {fabric.Group} thisArg + */ + initialize: function (objects, options) { + this.callSuper('initialize', objects, options); + this.__canvasMonitor = this.__canvasMonitor.bind(this); + this.__groupMonitor = this.__groupMonitor.bind(this); + this.__onAdded = this._watchParent.bind(this, true); + this.__onRemoved = this._watchParent.bind(this, false); + this.on('added:initialized', this.__onAdded); + this.on('added', this.__onAdded); + this.on('removed', this.__onRemoved); + // trigger layout in case parent is passed in options + var parent = this.group || this.canvas; + parent && this.__onAdded({ target: parent }); + }, + + /** + * @override we want instance to fill parent so we disregard transformations + * @param {CanvasRenderingContext2D} ctx Context + */ + transform: function (ctx) { + var m = this.calcTransformMatrix(!this.needsFullTransform(ctx)); + ctx.transform(1, 0, 0, 1, m[4], m[5]); + }, + + /** + * @override apply instance's transformations on objects + * @param {CanvasRenderingContext2D} ctx Context to render on + */ + drawObject: function (ctx) { + this._renderBackground(ctx); + ctx.save(); + var m = this.calcTransformMatrix(!this.needsFullTransform(ctx)); + ctx.transform(m[0], m[1], m[2], m[3], 0, 0); + for (var i = 0, len = this._objects.length; i < len; i++) { + this._objects[i].render(ctx); + } + ctx.restore(); + this._drawClipPath(ctx, this.clipPath); + }, + + /** + * @private + * @override we want instance to fill parent so we disregard transformations + * @param {Object} [options] + * @param {Number} [options.width] + * @param {Number} [options.height] + * @returns {fabric.Point} dimensions + */ + _getTransformedDimensions: function (options) { + options = Object.assign({ + width: this.width, + height: this.height + }, options || {}); + return new fabric.Point(options.width, options.height); + }, + + /** + * we need to invalidate instance's group if objects have changed + * @override + * @private + */ + __objectMonitor: function (opt) { + this.group && this.group.__objectMonitor(opt); + }, + + /** + * @private + * @param {boolean} watch + * @param {{target:fabric.Group|fabric.Canvas}} [opt] + */ + _watchParent: function (watch, opt) { + var target = opt && opt.target; + // make sure we listen only once + this.canvas && this.canvas.off('resize', this.__canvasMonitor); + this.group && this.group.off('layout', this.__groupMonitor); + if (!watch) { + return; + } + else if (target instanceof fabric.Group) { + this._applyLayoutStrategy({ type: 'group' }); + this.group.on('layout', this.__groupMonitor); + } + else if (target instanceof fabric.StaticCanvas) { + this._applyLayoutStrategy({ type: 'canvas' }); + this.canvas.on('resize', this.__canvasMonitor); + } + }, + + /** + * @private + */ + __canvasMonitor: function () { + this._applyLayoutStrategy({ type: 'canvas_resize' }); + }, + + /** + * @private + */ + __groupMonitor: function (context) { + this._applyLayoutStrategy(Object.assign({}, context, { type: 'group_layout' })); + }, + + /** + * @private + * @override we do not want to bubble layout + */ + _bubbleLayout: function () { + // noop + }, + + /** + * Layer will layout itself once it is added to a canvas/group and by listening to it's parent `resize`/`layout` events respectively + * Override this method to customize layout + * @public + * @param {string} layoutDirective + * @param {fabric.Object[]} objects + * @param {object} context object with data regarding what triggered the call + * @param {'initializion'|'canvas'|'canvas_resize'|'layout_change'} context.type + * @param {fabric.Object[]} context.path array of objects starting from the object that triggered the call to the current one + * @returns {Object} options object + */ + getLayoutStrategyResult: function (layoutDirective, objects, context) { // eslint-disable-line no-unused-vars + if ((context.type === 'canvas' || context.type === 'canvas_resize') && this.canvas && !this.group) { + return { + centerX: this.canvas.width / 2, + centerY: this.canvas.height / 2, + width: this.canvas.width, + height: this.canvas.height + }; + } + else if ((context.type === 'group' || context.type === 'group_layout') && this.group) { + var w = this.group.width, h = this.group.height; + return { + centerX: 0, + centerY: 0, + width: w, + height: h + }; + } + }, + + toString: function () { + return '#'; + }, + + dispose: function () { + this.off('added:initialized', this.__onAdded); + this.off('added', this.__onAdded); + this.off('removed', this.__onRemoved); + this._watchParent(false); + this.callSuper('dispose'); + } + + }); + + /** + * Returns fabric.Layer instance from an object representation + * @static + * @memberOf fabric.Layer + * @param {Object} object Object to create an instance from + * @returns {Promise} + */ + fabric.Layer.fromObject = function (object) { + var objects = object.objects || [], + options = fabric.util.object.clone(object, true); + delete options.objects; + return Promise.all([ + fabric.util.enlivenObjects(objects), + fabric.util.enlivenObjectEnlivables(options) + ]).then(function (enlivened) { + return new fabric.Layer(enlivened[0], Object.assign(options, enlivened[1]), true); + }); + }; + +})(typeof exports !== 'undefined' ? exports : this); diff --git a/src/shapes/object.class.js b/src/shapes/object.class.js index 158465fb..2bbcf764 100644 --- a/src/shapes/object.class.js +++ b/src/shapes/object.class.js @@ -817,14 +817,22 @@ this._setOptions(options); }, + /** + * @private + * @param {CanvasRenderingContext2D} ctx + * @returns {boolean} true if object needs to fully transform ctx + */ + needsFullTransform: function (ctx) { + return (this.group && !this.group._transformDone) || + (this.group && this.canvas && ctx === this.canvas.contextTop); + }, + /** * Transforms context when rendering an object * @param {CanvasRenderingContext2D} ctx Context */ transform: function(ctx) { - var needFullTransform = (this.group && !this.group._transformDone) || - (this.group && this.canvas && ctx === this.canvas.contextTop); - var m = this.calcTransformMatrix(!needFullTransform); + var m = this.calcTransformMatrix(!this.needsFullTransform(ctx)); ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]); }, diff --git a/src/static_canvas.class.js b/src/static_canvas.class.js index 82154c11..4c78dc13 100644 --- a/src/static_canvas.class.js +++ b/src/static_canvas.class.js @@ -405,6 +405,7 @@ } this._initRetinaScaling(); this.calcOffset(); + this.fire('resize', dimensions); if (!options.cssOnly) { this.requestRenderAll(); diff --git a/src/util/misc.js b/src/util/misc.js index 3bcf6f79..1f4e2631 100644 --- a/src/util/misc.js +++ b/src/util/misc.js @@ -803,7 +803,7 @@ * @param {Boolean} [options.flipX] * @param {Boolean} [options.flipY] * @param {Number} [options.skewX] - * @param {Number} [options.skewX] + * @param {Number} [options.skewY] * @param {Number} [options.translateX] * @param {Number} [options.translateY] * @return {Number[]} transform matrix diff --git a/test/visual/golden/group-layout/layer.png b/test/visual/golden/group-layout/layer.png new file mode 100644 index 00000000..00df0f0a Binary files /dev/null and b/test/visual/golden/group-layout/layer.png differ diff --git a/test/visual/golden/group-layout/nested-layer.png b/test/visual/golden/group-layout/nested-layer.png new file mode 100644 index 00000000..be7d767e Binary files /dev/null and b/test/visual/golden/group-layout/nested-layer.png differ diff --git a/test/visual/group_layout.js b/test/visual/group_layout.js index c5dee9f2..0ee55706 100644 --- a/test/visual/group_layout.js +++ b/test/visual/group_layout.js @@ -10,7 +10,7 @@ var tests = []; - function createGroupForLayoutTests(text, options) { + function createObjectsForLayoutTests(text) { var circle = new fabric.Circle({ left: 100, top: 50, @@ -27,11 +27,19 @@ fill: 'red', opacity: 0.3 }) - return new fabric.Group([ + return [ rect, circle, itext - ], options); + ]; + } + + function createGroupForLayoutTests(text, options) { + return new fabric.Group(createObjectsForLayoutTests(text), options); + } + + function createLayerForLayoutTests(text, options) { + return new fabric.Layer(createObjectsForLayoutTests(text), options); } function fixedLayout(canvas, callback) { @@ -358,6 +366,66 @@ height: 250 }); + function fitContentNestedLayer(canvas, callback) { + var g = createGroupForLayoutTests('fixed layout,\nlayer on top', { + layout: 'fixed', + backgroundColor: 'blue' + }); + var objects = g.removeAll(); + var layer = new fabric.Layer(objects, { backgroundColor: 'yellow' }); + g.add(layer); + canvas.add(g); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'layer nested in group', + code: fitContentNestedLayer, + golden: 'group-layout/nested-layer.png', + percentage: 0.06, + width: 400, + height: 300 + }); + + function LayerLayout(canvas, callback) { + var layer = createLayerForLayoutTests('Layer', { + backgroundColor: 'blue', + }); + canvas.add(layer); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } + + tests.push({ + test: 'layer', + code: LayerLayout, + golden: 'group-layout/layer.png', + percentage: 0.06, + width: 400, + height: 300 + }); + + function LayerLayoutWithSkew(canvas, callback) { + var layer = createLayerForLayoutTests('Layer', { + backgroundColor: 'blue', + skewX: 45 + }); + canvas.add(layer); + canvas.renderAll(); + callback(canvas.lowerCanvasEl); + } +/* + tests.push({ + test: 'layer with skewX', + code: LayerLayoutWithSkew, + golden: 'group-layout/layer-skewX.png', + percentage: 0.06, + width: 400, + height: 300 + }); + */ + function createObjectsForOriginTests(originX, originY, options) { var rect1 = new fabric.Rect({ left: 150, top: 100, width: 30, height: 10, strokeWidth: 0 }), rect2 = new fabric.Rect({ left: 200, top: 120, width: 10, height: 40, strokeWidth: 0 }),