aico-image-editor
Version:
Combine multiple image into and create single combined image
763 lines (714 loc) • 29 kB
JavaScript
(function () {
/** ERASER_START */
/**
* add `eraser` to enlivened props
*/
fabric.Object.ENLIVEN_PROPS.push('eraser');
var __drawClipPath = fabric.Object.prototype._drawClipPath;
var _needsItsOwnCache = fabric.Object.prototype.needsItsOwnCache;
var _toObject = fabric.Object.prototype.toObject;
var _getSvgCommons = fabric.Object.prototype.getSvgCommons;
var __createBaseClipPathSVGMarkup = fabric.Object.prototype._createBaseClipPathSVGMarkup;
var __createBaseSVGMarkup = fabric.Object.prototype._createBaseSVGMarkup;
fabric.Object.prototype.cacheProperties.push('eraser');
fabric.Object.prototype.stateProperties.push('eraser');
/**
* @fires erasing:end
*/
fabric.util.object.extend(fabric.Object.prototype, {
/**
* Indicates whether this object can be erased by {@link fabric.EraserBrush}
* The `deep` option introduces fine grained control over a group's `erasable` property.
* When set to `deep` the eraser will erase nested objects if they are erasable, leaving the group and the other objects untouched.
* When set to `true` the eraser will erase the entire group. Once the group changes the eraser is propagated to its children for proper functionality.
* When set to `false` the eraser will leave all objects including the group untouched.
* @tutorial {@link http://fabricjs.com/erasing#erasable_property}
* @type boolean | 'deep'
* @default true
*/
erasable: true,
/**
* @tutorial {@link http://fabricjs.com/erasing#eraser}
* @type fabric.Eraser
*/
eraser: undefined,
/**
* @override
* @returns Boolean
*/
needsItsOwnCache: function () {
return _needsItsOwnCache.call(this) || !!this.eraser;
},
/**
* draw eraser above clip path
* @override
* @private
* @param {CanvasRenderingContext2D} ctx
* @param {fabric.Object} clipPath
*/
_drawClipPath: function (ctx, clipPath) {
__drawClipPath.call(this, ctx, clipPath);
if (this.eraser) {
// update eraser size to match instance
var size = this._getNonTransformedDimensions();
this.eraser.isType('eraser') && this.eraser.set({
width: size.x,
height: size.y
});
__drawClipPath.call(this, ctx, this.eraser);
}
},
/**
* Returns an object representation of an instance
* @param {Array} [propertiesToInclude] Any properties that you might want to additionally include in the output
* @return {Object} Object representation of an instance
*/
toObject: function (propertiesToInclude) {
var object = _toObject.call(this, ['erasable'].concat(propertiesToInclude));
if (this.eraser && !this.eraser.excludeFromExport) {
object.eraser = this.eraser.toObject(propertiesToInclude);
}
return object;
},
/* _TO_SVG_START_ */
/**
* Returns id attribute for svg output
* @override
* @return {String}
*/
getSvgCommons: function () {
return _getSvgCommons.call(this) + (this.eraser ? 'mask="url(#' + this.eraser.clipPathId + ')" ' : '');
},
/**
* create svg markup for eraser
* use <mask> to achieve erasing for svg, credit: https://travishorn.com/removing-parts-of-shapes-in-svg-b539a89e5649
* must be called before object markup creation as it relies on the `clipPathId` property of the mask
* @param {Function} [reviver]
* @returns
*/
_createEraserSVGMarkup: function (reviver) {
if (this.eraser) {
this.eraser.clipPathId = 'MASK_' + fabric.Object.__uid++;
return [
'<mask id="', this.eraser.clipPathId, '" >',
this.eraser.toSVG(reviver),
'</mask>', '\n'
].join('');
}
return '';
},
/**
* @private
*/
_createBaseClipPathSVGMarkup: function (objectMarkup, options) {
return [
this._createEraserSVGMarkup(options && options.reviver),
__createBaseClipPathSVGMarkup.call(this, objectMarkup, options)
].join('');
},
/**
* @private
*/
_createBaseSVGMarkup: function (objectMarkup, options) {
return [
this._createEraserSVGMarkup(options && options.reviver),
__createBaseSVGMarkup.call(this, objectMarkup, options)
].join('');
}
/* _TO_SVG_END_ */
});
var __restoreObjectsState = fabric.Group.prototype._restoreObjectsState;
fabric.util.object.extend(fabric.Group.prototype, {
/**
* @private
* @param {fabric.Path} path
*/
_addEraserPathToObjects: function (path) {
this._objects.forEach(function (object) {
fabric.EraserBrush.prototype._addPathToObjectEraser.call(
fabric.EraserBrush.prototype,
object,
path
);
});
},
/**
* Applies the group's eraser to its objects
* @tutorial {@link http://fabricjs.com/erasing#erasable_property}
*/
applyEraserToObjects: function () {
var _this = this, eraser = this.eraser;
if (eraser) {
delete this.eraser;
var transform = _this.calcTransformMatrix();
eraser.clone(function (eraser) {
var clipPath = _this.clipPath;
eraser.getObjects('path')
.forEach(function (path) {
// first we transform the path from the group's coordinate system to the canvas'
var originalTransform = fabric.util.multiplyTransformMatrices(
transform,
path.calcTransformMatrix()
);
fabric.util.applyTransformToObject(path, originalTransform);
if (clipPath) {
clipPath.clone(function (_clipPath) {
var eraserPath = fabric.EraserBrush.prototype.applyClipPathToPath.call(
fabric.EraserBrush.prototype,
path,
_clipPath,
transform
);
_this._addEraserPathToObjects(eraserPath);
}, ['absolutePositioned', 'inverted']);
}
else {
_this._addEraserPathToObjects(path);
}
});
});
}
},
/**
* 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);
}
});
/**
* An object's Eraser
* @private
* @class fabric.Eraser
* @extends fabric.Group
* @memberof fabric
*/
fabric.Eraser = fabric.util.createClass(fabric.Group, {
/**
* @readonly
* @static
*/
type: 'eraser',
/**
* @default
*/
originX: 'center',
/**
* @default
*/
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
* handled by {@link fabric.Object#_drawClipPath}
* @override
* @private
*/
_getBounds: function () {
// noop
},
/* _TO_SVG_START_ */
/**
* Returns svg representation of an instance
* use <mask> to achieve erasing for svg, credit: https://travishorn.com/removing-parts-of-shapes-in-svg-b539a89e5649
* for masking we need to add a white rect before all paths
*
* @param {Function} [reviver] Method for further parsing of svg representation.
* @return {String} svg representation of an instance
*/
_toSVG: function (reviver) {
var svgString = ['<g ', 'COMMON_PARTS', ' >\n'];
var x = -this.width / 2, y = -this.height / 2;
var rectSvg = [
'<rect ', 'fill="white" ',
'x="', x, '" y="', y,
'" width="', this.width, '" height="', this.height,
'" />\n'
].join('');
svgString.push('\t\t', rectSvg);
for (var i = 0, len = this._objects.length; i < len; i++) {
svgString.push('\t\t', this._objects[i].toSVG(reviver));
}
svgString.push('</g>\n');
return svgString;
},
/* _TO_SVG_END_ */
});
/**
* Returns {@link fabric.Eraser} instance from an object representation
* @static
* @memberOf fabric.Eraser
* @param {Object} object Object to create an Eraser from
* @param {Function} [callback] Callback to invoke when an eraser instance is created
*/
fabric.Eraser.fromObject = function (object, callback) {
var objects = object.objects;
fabric.util.enlivenObjects(objects, function (enlivenedObjects) {
var options = fabric.util.object.clone(object, true);
delete options.objects;
fabric.util.enlivenObjectEnlivables(object, options, function () {
callback && callback(new fabric.Eraser(enlivenedObjects, options, true));
});
});
};
var __renderOverlay = fabric.Canvas.prototype._renderOverlay;
/**
* @fires erasing:start
* @fires erasing:end
*/
fabric.util.object.extend(fabric.Canvas.prototype, {
/**
* Used by {@link #renderAll}
* @returns boolean
*/
isErasing: function () {
return (
this.isDrawingMode &&
this.freeDrawingBrush &&
this.freeDrawingBrush.type === 'eraser' &&
this.freeDrawingBrush._isErasing
);
},
/**
* While erasing the brush clips out the erasing path from canvas
* so we need to render it on top of canvas every render
* @param {CanvasRenderingContext2D} ctx
*/
_renderOverlay: function (ctx) {
__renderOverlay.call(this, ctx);
if (this.isErasing() && !this.freeDrawingBrush.inverted) {
this.freeDrawingBrush._render();
}
}
});
/**
* EraserBrush class
* Supports selective erasing meaning that only erasable objects are affected by the eraser brush.
* Supports **inverted** erasing meaning that the brush can "undo" erasing.
*
* In order to support selective erasing, the brush clips the entire canvas
* and then draws all non-erasable objects over the erased path using a pattern brush so to speak (masking).
* If brush is **inverted** there is no need to clip canvas. The brush draws all erasable objects without their eraser.
* This achieves the desired effect of seeming to erase or unerase only erasable objects.
* After erasing is done the created path is added to all intersected objects' `eraser` property.
*
* In order to update the EraserBrush call `preparePattern`.
* It may come in handy when canvas changes during erasing (i.e animations) and you want the eraser to reflect the changes.
*
* @tutorial {@link http://fabricjs.com/erasing}
* @class fabric.EraserBrush
* @extends fabric.PencilBrush
* @memberof fabric
*/
fabric.EraserBrush = fabric.util.createClass(
fabric.PencilBrush,
/** @lends fabric.EraserBrush.prototype */ {
type: 'eraser',
/**
* When set to `true` the brush will create a visual effect of undoing erasing
*/
inverted: false,
/**
* @private
*/
_isErasing: false,
/**
*
* @private
* @param {fabric.Object} object
* @returns boolean
*/
_isErasable: function (object) {
return object.erasable !== false;
},
/**
* @private
* This is designed to support erasing a collection with both erasable and non-erasable objects.
* Iterates over collections to allow nested selective erasing.
* Prepares the pattern brush that will draw on the top context to achieve the desired visual effect.
* If brush is **NOT** inverted render all non-erasable objects.
* If brush is inverted render all erasable objects that have been erased with their clip path inverted.
* This will render the erased parts as if they were not erased.
*
* @param {fabric.Collection} collection
* @param {CanvasRenderingContext2D} ctx
* @param {{ visibility: fabric.Object[], eraser: fabric.Object[], collection: fabric.Object[] }} restorationContext
*/
_prepareCollectionTraversal: function (collection, ctx, restorationContext) {
collection.forEachObject(function (obj) {
if (obj.forEachObject && obj.erasable === 'deep') {
// traverse
this._prepareCollectionTraversal(obj, ctx, restorationContext);
}
else if (!this.inverted && obj.erasable && obj.visible) {
// render only non-erasable objects
obj.visible = false;
collection.dirty = true;
restorationContext.visibility.push(obj);
restorationContext.collection.push(collection);
}
else if (this.inverted && obj.visible) {
// render only erasable objects that were erased
if (obj.erasable && obj.eraser) {
obj.eraser.inverted = true;
obj.dirty = true;
collection.dirty = true;
restorationContext.eraser.push(obj);
restorationContext.collection.push(collection);
}
else {
obj.visible = false;
collection.dirty = true;
restorationContext.visibility.push(obj);
restorationContext.collection.push(collection);
}
}
}, this);
},
/**
* Prepare the pattern for the erasing brush
* This pattern will be drawn on the top context, achieving a visual effect of erasing only erasable objects
* @todo decide how overlay color should behave when `inverted === true`, currently draws over it which is undesirable
* @private
*/
preparePattern: function () {
if (!this._patternCanvas) {
this._patternCanvas = fabric.util.createCanvasElement();
}
var canvas = this._patternCanvas;
canvas.width = this.canvas.width;
canvas.height = this.canvas.height;
var patternCtx = canvas.getContext('2d');
if (this.canvas._isRetinaScaling()) {
var retinaScaling = this.canvas.getRetinaScaling();
this.canvas.__initRetinaScaling(retinaScaling, canvas, patternCtx);
}
var backgroundImage = this.canvas.backgroundImage,
bgErasable = backgroundImage && this._isErasable(backgroundImage),
overlayImage = this.canvas.overlayImage,
overlayErasable = overlayImage && this._isErasable(overlayImage);
if (!this.inverted && ((backgroundImage && !bgErasable) || !!this.canvas.backgroundColor)) {
if (bgErasable) { this.canvas.backgroundImage = undefined; }
this.canvas._renderBackground(patternCtx);
if (bgErasable) { this.canvas.backgroundImage = backgroundImage; }
}
else if (this.inverted && (backgroundImage && bgErasable)) {
var color = this.canvas.backgroundColor;
this.canvas.backgroundColor = undefined;
this.canvas._renderBackground(patternCtx);
this.canvas.backgroundColor = color;
}
patternCtx.save();
patternCtx.transform.apply(patternCtx, this.canvas.viewportTransform);
var restorationContext = { visibility: [], eraser: [], collection: [] };
this._prepareCollectionTraversal(this.canvas, patternCtx, restorationContext);
this.canvas._renderObjects(patternCtx, this.canvas._objects);
restorationContext.visibility.forEach(function (obj) { obj.visible = true; });
restorationContext.eraser.forEach(function (obj) {
obj.eraser.inverted = false;
obj.dirty = true;
});
restorationContext.collection.forEach(function (obj) { obj.dirty = true; });
patternCtx.restore();
if (!this.inverted && ((overlayImage && !overlayErasable) || !!this.canvas.overlayColor)) {
if (overlayErasable) { this.canvas.overlayImage = undefined; }
__renderOverlay.call(this.canvas, patternCtx);
if (overlayErasable) { this.canvas.overlayImage = overlayImage; }
}
else if (this.inverted && (overlayImage && overlayErasable)) {
var color = this.canvas.overlayColor;
this.canvas.overlayColor = undefined;
__renderOverlay.call(this.canvas, patternCtx);
this.canvas.overlayColor = color;
}
},
/**
* Sets brush styles
* @private
* @param {CanvasRenderingContext2D} ctx
*/
_setBrushStyles: function (ctx) {
this.callSuper('_setBrushStyles', ctx);
ctx.strokeStyle = 'black';
},
/**
* **Customiztion**
*
* if you need the eraser to update on each render (i.e animating during erasing) override this method by **adding** the following (performance may suffer):
* @example
* ```
* if(ctx === this.canvas.contextTop) {
* this.preparePattern();
* }
* ```
*
* @override fabric.BaseBrush#_saveAndTransform
* @param {CanvasRenderingContext2D} ctx
*/
_saveAndTransform: function (ctx) {
this.callSuper('_saveAndTransform', ctx);
this._setBrushStyles(ctx);
ctx.globalCompositeOperation = ctx === this.canvas.getContext() ? 'destination-out' : 'source-over';
},
/**
* We indicate {@link fabric.PencilBrush} to repaint itself if necessary
* @returns
*/
needsFullRender: function () {
return true;
},
/**
*
* @param {fabric.Point} pointer
* @param {fabric.IEvent} options
* @returns
*/
onMouseDown: function (pointer, options) {
if (!this.canvas._isMainEvent(options.e)) {
return;
}
this._prepareForDrawing(pointer);
// capture coordinates immediately
// this allows to draw dots (when movement never occurs)
this._captureDrawingPath(pointer);
// prepare for erasing
this.preparePattern();
this._isErasing = true;
this.canvas.fire('erasing:start');
this._render();
},
/**
* Rendering Logic:
* 1. Use brush to clip canvas by rendering it on top of canvas (unnecessary if `inverted === true`)
* 2. Render brush with canvas pattern on top context
*
*/
_render: function () {
var ctx;
if (!this.inverted) {
// clip canvas
ctx = this.canvas.getContext();
this.callSuper('_render', ctx);
}
// render brush and mask it with image of non erasables
ctx = this.canvas.contextTop;
this.canvas.clearContext(ctx);
this.callSuper('_render', ctx);
ctx.save();
var t = this.canvas.getRetinaScaling(), s = 1 / t;
ctx.scale(s, s);
ctx.globalCompositeOperation = 'source-in';
ctx.drawImage(this._patternCanvas, 0, 0);
ctx.restore();
},
/**
* Creates fabric.Path object
* @override
* @private
* @param {(string|number)[][]} pathData Path data
* @return {fabric.Path} Path to add on canvas
* @returns
*/
createPath: function (pathData) {
var path = this.callSuper('createPath', pathData);
path.globalCompositeOperation = this.inverted ? 'source-over' : 'destination-out';
path.stroke = this.inverted ? 'white' : 'black';
return path;
},
/**
* Utility to apply a clip path to a path.
* Used to preserve clipping on eraser paths in nested objects.
* Called when a group has a clip path that should be applied to the path before applying erasing on the group's objects.
* @param {fabric.Path} path The eraser path in canvas coordinate plane
* @param {fabric.Object} clipPath The clipPath to apply to the path
* @param {number[]} clipPathContainerTransformMatrix The transform matrix of the object that the clip path belongs to
* @returns {fabric.Path} path with clip path
*/
applyClipPathToPath: function (path, clipPath, clipPathContainerTransformMatrix) {
var pathInvTransform = fabric.util.invertTransform(path.calcTransformMatrix()),
clipPathTransform = clipPath.calcTransformMatrix(),
transform = clipPath.absolutePositioned ?
pathInvTransform :
fabric.util.multiplyTransformMatrices(
pathInvTransform,
clipPathContainerTransformMatrix
);
// when passing down a clip path it becomes relative to the parent
// so we transform it acoordingly and set `absolutePositioned` to false
clipPath.absolutePositioned = false;
fabric.util.applyTransformToObject(
clipPath,
fabric.util.multiplyTransformMatrices(
transform,
clipPathTransform
)
);
// We need to clip `path` with both `clipPath` and it's own clip path if existing (`path.clipPath`)
// so in turn `path` erases an object only where it overlaps with all it's clip paths, regardless of how many there are.
// this is done because both clip paths may have nested clip paths of their own (this method walks down a collection => this may reccur),
// so we can't assign one to the other's clip path property.
path.clipPath = path.clipPath ? fabric.util.mergeClipPaths(clipPath, path.clipPath) : clipPath;
return path;
},
/**
* Utility to apply a clip path to a path.
* Used to preserve clipping on eraser paths in nested objects.
* Called when a group has a clip path that should be applied to the path before applying erasing on the group's objects.
* @param {fabric.Path} path The eraser path
* @param {fabric.Object} object The clipPath to apply to path belongs to object
* @param {Function} callback Callback to be invoked with the cloned path after applying the clip path
*/
clonePathWithClipPath: function (path, object, callback) {
var objTransform = object.calcTransformMatrix();
var clipPath = object.clipPath;
var _this = this;
path.clone(function (_path) {
clipPath.clone(function (_clipPath) {
callback(_this.applyClipPathToPath(_path, _clipPath, objTransform));
}, ['absolutePositioned', 'inverted']);
});
},
/**
* Adds path to object's eraser, walks down object's descendants if necessary
*
* @fires erasing:end on object
* @param {fabric.Object} obj
* @param {fabric.Path} path
*/
_addPathToObjectEraser: function (obj, path) {
var _this = this;
// object is collection, i.e group
if (obj.forEachObject && obj.erasable === 'deep') {
var targets = obj._objects.filter(function (_obj) {
return _obj.erasable;
});
if (targets.length > 0 && obj.clipPath) {
this.clonePathWithClipPath(path, obj, function (_path) {
targets.forEach(function (_obj) {
_this._addPathToObjectEraser(_obj, _path);
});
});
}
else if (targets.length > 0) {
targets.forEach(function (_obj) {
_this._addPathToObjectEraser(_obj, path);
});
}
return;
}
// prepare eraser
var eraser = obj.eraser;
if (!eraser) {
eraser = new fabric.Eraser();
obj.eraser = eraser;
}
// clone and add path
path.clone(function (path) {
// http://fabricjs.com/using-transformations
var desiredTransform = fabric.util.multiplyTransformMatrices(
fabric.util.invertTransform(
obj.calcTransformMatrix()
),
path.calcTransformMatrix()
);
fabric.util.applyTransformToObject(path, desiredTransform);
eraser.addWithUpdate(path);
obj.set('dirty', true);
obj.fire('erasing:end', {
path: path
});
if (obj.group && Array.isArray(_this.__subTargets)) {
_this.__subTargets.push(obj);
}
});
},
/**
* Add the eraser path to canvas drawables' clip paths
*
* @param {fabric.Canvas} source
* @param {fabric.Canvas} path
* @returns {Object} canvas drawables that were erased by the path
*/
applyEraserToCanvas: function (path) {
var canvas = this.canvas;
var drawables = {};
[
'backgroundImage',
'overlayImage',
].forEach(function (prop) {
var drawable = canvas[prop];
if (drawable && drawable.erasable) {
this._addPathToObjectEraser(drawable, path);
drawables[prop] = drawable;
}
}, this);
return drawables;
},
/**
* On mouseup after drawing the path on contextTop canvas
* we use the points captured to create an new fabric path object
* and add it to every intersected erasable object.
*/
_finalizeAndAddPath: function () {
var ctx = this.canvas.contextTop, canvas = this.canvas;
ctx.closePath();
if (this.decimate) {
this._points = this.decimatePoints(this._points, this.decimate);
}
// clear
canvas.clearContext(canvas.contextTop);
this._isErasing = false;
var pathData = this._points && this._points.length > 1 ?
this.convertPointsToSVGPath(this._points) :
null;
if (!pathData || this._isEmptySVGPath(pathData)) {
canvas.fire('erasing:end');
// do not create 0 width/height paths, as they are
// rendered inconsistently across browsers
// Firefox 4, for example, renders a dot,
// whereas Chrome 10 renders nothing
canvas.requestRenderAll();
return;
}
var path = this.createPath(pathData);
// needed for `intersectsWithObject`
path.setCoords();
// commense event sequence
canvas.fire('before:path:created', { path: path });
// finalize erasing
var drawables = this.applyEraserToCanvas(path);
var _this = this;
this.__subTargets = [];
var targets = [];
canvas.forEachObject(function (obj) {
if (obj.erasable && obj.intersectsWithObject(path, true, true)) {
_this._addPathToObjectEraser(obj, path);
targets.push(obj);
}
});
// fire erasing:end
canvas.fire('erasing:end', {
path: path,
targets: targets,
subTargets: this.__subTargets,
drawables: drawables
});
delete this.__subTargets;
canvas.requestRenderAll();
this._resetShadow();
// fire event 'path' created
canvas.fire('path:created', { path: path });
}
}
);
/** ERASER_END */
})();