diff --git a/build.js b/build.js index e3ced814ed7..1aafb6873fa 100644 --- a/build.js +++ b/build.js @@ -232,6 +232,9 @@ var filesToInclude = [ ifSpecifiedInclude('textbox', 'src/shapes/textbox.class.js'), ifSpecifiedInclude('interaction', 'src/mixins/default_controls.js'), + + // extends fabric.StaticCanvas, fabric.Canvas, fabric.Object, depends on fabric.PencilBrush and fabric.Rect + ifSpecifiedInclude('erasing', 'src/mixins/eraser_brush.mixin.js'), ]; if (buildMinified) { diff --git a/package.json b/package.json index a36b5b1a50c..2d7df590e8a 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,8 @@ }, "license": "MIT", "scripts": { - "build": "node build.js modules=ALL requirejs exclude=gestures,accessors", - "build:fast": "node build.js modules=ALL requirejs fast exclude=gestures,accessors", + "build": "node build.js modules=ALL requirejs exclude=gestures,accessors,erasing", + "build:fast": "node build.js modules=ALL requirejs fast exclude=gestures,accessors,erasing", "build:watch": "onchange 'src/**/**' 'HEADER.js' 'lib/**/**' -- npm run build_export", "link:watch": "onchange 'src/**/**' 'HEADER.js' 'lib/**/**' -- npm link", "build_with_gestures": "node build.js modules=ALL exclude=accessors", diff --git a/src/mixins/eraser_brush.mixin.js b/src/mixins/eraser_brush.mixin.js new file mode 100644 index 00000000000..d2e5fa9c2df --- /dev/null +++ b/src/mixins/eraser_brush.mixin.js @@ -0,0 +1,716 @@ +(function () { + /** ERASER_START */ + var __setBgOverlayColor = fabric.StaticCanvas.prototype.__setBgOverlayColor; + var ___setBgOverlay = fabric.StaticCanvas.prototype.__setBgOverlay; + var __setSVGBgOverlayColor = fabric.StaticCanvas.prototype._setSVGBgOverlayColor; + fabric.util.object.extend(fabric.StaticCanvas.prototype, { + backgroundColor: undefined, + overlayColor: undefined, + /** + * Create Rect that holds the color to support erasing + * patches {@link CommonMethods#_initGradient} + * @private + * @param {'bakground'|'overlay'} property + * @param {(String|fabric.Pattern|fabric.Rect)} color Color or pattern or rect (in case of erasing) + * @param {Function} callback Callback to invoke when color is set + * @param {Object} options + * @return {fabric.Canvas} instance + * @chainable true + */ + __setBgOverlayColor: function (property, color, callback, options) { + if (color && color.isType && color.isType('rect')) { + // color is already an object + this[property] = color; + color.set(options); + callback && callback(this[property]); + } + else { + var _this = this; + var cb = function () { + _this[property] = new fabric.Rect(fabric.util.object.extend({ + width: _this.width, + height: _this.height, + fill: _this[property], + }, options)); + callback && callback(_this[property]); + }; + __setBgOverlayColor.call(this, property, color, cb); + // invoke cb in case of gradient + // see {@link CommonMethods#_initGradient} + if (color && color.colorStops && !(color instanceof fabric.Gradient)) { + cb(); + } + } + + return this; + }, + + setBackgroundColor: function (backgroundColor, callback, options) { + return this.__setBgOverlayColor('backgroundColor', backgroundColor, callback, options); + }, + + setOverlayColor: function (overlayColor, callback, options) { + return this.__setBgOverlayColor('overlayColor', overlayColor, callback, options); + }, + + /** + * patch serialization - from json + * background/overlay properties could be objects if parsed by this mixin or could be legacy values + * @private + * @param {String} property Property to set (backgroundImage, overlayImage, backgroundColor, overlayColor) + * @param {(Object|String)} value Value to set + * @param {Object} loaded Set loaded property to true if property is set + * @param {Object} callback Callback function to invoke after property is set + */ + __setBgOverlay: function (property, value, loaded, callback) { + var _this = this; + + if ((property === 'backgroundColor' || property === 'overlayColor') && + (value && typeof value === 'object' && value.type === 'rect')) { + fabric.util.enlivenObjects([value], function (enlivedObject) { + _this[property] = enlivedObject[0]; + loaded[property] = true; + callback && callback(); + }); + } + else { + ___setBgOverlay.call(this, property, value, loaded, callback); + } + }, + + /** + * patch serialization - to svg + * background/overlay properties could be objects if parsed by this mixin or could be legacy values + * @private + */ + _setSVGBgOverlayColor: function (markup, property, reviver) { + var filler = this[property + 'Color']; + if (filler && filler.isType && filler.isType('rect')) { + var excludeFromExport = filler.excludeFromExport || (this[property] && this[property].excludeFromExport); + if (filler && !excludeFromExport && filler.toSVG) { + markup.push(filler.toSVG(reviver)); + } + } + else { + __setSVGBgOverlayColor.call(this, markup, property, reviver); + } + }, + + /** + * @private + * @param {CanvasRenderingContext2D} ctx Context to render on + * @param {string} property 'background' or 'overlay' + */ + _renderBackgroundOrOverlay: function (ctx, property) { + var fill = this[property + 'Color'], object = this[property + 'Image'], + v = this.viewportTransform, needsVpt = this[property + 'Vpt']; + if (!fill && !object) { + return; + } + if (fill || object) { + ctx.save(); + if (needsVpt) { + ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]); + } + fill && fill.render(ctx); + object && object.render(ctx); + ctx.restore(); + } + }, + }); + + var _toObject = fabric.Object.prototype.toObject; + var __createBaseSVGMarkup = fabric.Object.prototype._createBaseSVGMarkup; + fabric.util.object.extend(fabric.Object.prototype, { + /** + * Indicates whether this object can be erased by {@link fabric.EraserBrush} + * @type boolean + * @default true + */ + erasable: true, + + /** + * + * @returns {fabric.Group | null} + */ + getEraser: function () { + return this.clipPath && this.clipPath.eraser ? this.clipPath : null; + }, + + /** + * 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 (additionalProperties) { + return _toObject.call(this, ['erasable'].concat(additionalProperties)); + }, + + /** + * use to achieve erasing for svg + * credit: https://travishorn.com/removing-parts-of-shapes-in-svg-b539a89e5649 + * @param {Function} reviver + * @returns {string} markup + */ + eraserToSVG: function (options) { + var eraser = this.getEraser(); + if (eraser) { + var fill = eraser._objects[0].fill; + eraser._objects[0].fill = 'white'; + eraser.clipPathId = 'CLIPPATH_' + fabric.Object.__uid++; + var commons = [ + 'id="' + eraser.clipPathId + '"', + /*options.additionalTransform ? ' transform="' + options.additionalTransform + '" ' : ''*/ + ].join(' '); + var objectMarkup = ['', '', eraser.toSVG(options.reviver), '', '']; + eraser._objects[0].fill = fill; + return objectMarkup.join('\n'); + } + return ''; + }, + + /** + * use to achieve erasing for svg, override + * @param {string[]} objectMarkup + * @param {Object} options + * @returns + */ + _createBaseSVGMarkup: function (objectMarkup, options) { + var eraser = this.getEraser(); + if (eraser) { + var eraserMarkup = this.eraserToSVG(options); + this.clipPath = null; + var markup = __createBaseSVGMarkup.call(this, objectMarkup, options); + this.clipPath = eraser; + return [ + eraserMarkup, + markup.replace('>', 'mask="url(#' + eraser.clipPathId + ')" >') + ].join('\n'); + } + else { + return __createBaseSVGMarkup.call(this, objectMarkup, options); + } + } + }); + + var _groupToObject = fabric.Group.prototype.toObject; + fabric.util.object.extend(fabric.Group.prototype, { + /** + * 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 (additionalProperties) { + return _groupToObject.call(this, ['eraser'].concat(additionalProperties)); + } + }); + + 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 is in charge of rendering the canvas + * It uses both layers to achieve diserd erasing effect + * + * @returns fabric.Canvas + */ + renderAll: function () { + if (this.contextTopDirty && !this._groupSelector && !this.isDrawingMode) { + this.clearContext(this.contextTop); + this.contextTopDirty = false; + } + // while erasing the brush is in charge of rendering the canvas so we return + if (this.isErasing()) { + this.freeDrawingBrush._render(); + return; + } + if (this.hasLostContext) { + this.renderTopLayer(this.contextTop); + } + var canvasToDrawOn = this.contextContainer; + this.renderCanvas(canvasToDrawOn, this._chooseObjectsToRender()); + return this; + } + }); + + + /** + * EraserBrush class + * Supports selective erasing meaning that only erasable objects are affected by the eraser brush. + * In order to support selective erasing all non erasable objects are rendered on the main/bottom ctx + * while the entire canvas is rendered on the top ctx. + * Canvas bakground/overlay image/color are handled as well. + * When erasing occurs, the path clips the top ctx and reveals the bottom ctx. + * This achieves the desired effect of seeming to erase only erasable objects. + * After erasing is done the created path is added to all intersected objects' `clipPath` property. + * + * + * @class fabric.EraserBrush + * @extends fabric.PencilBrush + */ + fabric.EraserBrush = fabric.util.createClass( + fabric.PencilBrush, + /** @lends fabric.EraserBrush.prototype */ { + type: 'eraser', + + /** + * Indicates that the ctx is ready and rendering can begin. + * Used to prevent a race condition caused by {@link fabric.EraserBrush#onMouseMove} firing before {@link fabric.EraserBrush#onMouseDown} has completed + * + * @private + */ + _ready: false, + + /** + * @private + */ + _drawOverlayOnTop: false, + + /** + * @private + */ + _isErasing: false, + + initialize: function (canvas) { + this.callSuper('initialize', canvas); + this._renderBound = this._render.bind(this); + this.render = this.render.bind(this); + }, + + /** + * Used to hide a drawable from the rendering process + * @param {fabric.Object} object + */ + hideObject: function (object) { + if (object) { + object._originalOpacity = object.opacity; + object.set({ opacity: 0 }); + } + }, + + /** + * Restores hiding an object + * {@link favric.EraserBrush#hideObject} + * @param {fabric.Object} object + */ + restoreObjectVisibility: function (object) { + if (object && object._originalOpacity) { + object.set({ opacity: object._originalOpacity }); + object._originalOpacity = undefined; + } + }, + + /** + * Drawing Logic For background drawables: (`backgroundImage`, `backgroundColor`) + * 1. if erasable = true: + * we need to hide the drawable on the bottom ctx so when the brush is erasing it will clip the top ctx and reveal white space underneath + * 2. if erasable = false: + * we need to draw the drawable only on the bottom ctx so the brush won't affect it + * @param {'bottom' | 'top' | 'overlay'} layer + */ + prepareCanvasBackgroundForLayer: function (layer) { + if (layer === 'overlay') { + return; + } + var canvas = this.canvas; + var image = canvas.get('backgroundImage'); + var color = canvas.get('backgroundColor'); + var erasablesOnLayer = layer === 'top'; + if (image && image.erasable === !erasablesOnLayer) { + this.hideObject(image); + } + if (color && color.erasable === !erasablesOnLayer) { + this.hideObject(color); + } + }, + + /** + * Drawing Logic For overlay drawables (`overlayImage`, `overlayColor`) + * We must draw on top ctx to be on top of visible canvas + * 1. if erasable = true: + * we need to draw the drawable on the top ctx as a normal object + * 2. if erasable = false: + * we need to draw the drawable on top of the brush, + * this means we need to repaint for every stroke + * + * @param {'bottom' | 'top' | 'overlay'} layer + * @returns boolean render overlay above brush + */ + prepareCanvasOverlayForLayer: function (layer) { + var canvas = this.canvas; + var image = canvas.get('overlayImage'); + var color = canvas.get('overlayColor'); + if (layer === 'bottom') { + this.hideObject(image); + this.hideObject(color); + return false; + }; + var erasablesOnLayer = layer === 'top'; + var renderOverlayOnTop = (image && !image.erasable) || (color && !color.erasable); + if (image && image.erasable === !erasablesOnLayer) { + this.hideObject(image); + } + if (color && color.erasable === !erasablesOnLayer) { + this.hideObject(color); + } + return renderOverlayOnTop; + }, + + /** + * @private + */ + restoreCanvasDrawables: function () { + var canvas = this.canvas; + this.restoreObjectVisibility(canvas.get('backgroundImage')); + this.restoreObjectVisibility(canvas.get('backgroundColor')); + this.restoreObjectVisibility(canvas.get('overlayImage')); + this.restoreObjectVisibility(canvas.get('overlayColor')); + }, + + /** + * @private + * This is designed to support erasing a group with both erasable and non-erasable objects. + * Iterates over collections to allow nested selective erasing. + * Used by {@link fabric.EraserBrush#prepareCanvasObjectsForLayer} + * to prepare the bottom layer by hiding erasable nested objects + * + * @param {fabric.Collection} collection + */ + prepareCollectionTraversal: function (collection) { + var _this = this; + collection.forEachObject(function (obj) { + if (obj.forEachObject) { + _this.prepareCollectionTraversal(obj); + } + else { + if (obj.erasable) { + _this.hideObject(obj); + } + } + }); + }, + + /** + * @private + * Used by {@link fabric.EraserBrush#prepareCanvasObjectsForLayer} + * to reverse the action of {@link fabric.EraserBrush#prepareCollectionTraversal} + * + * @param {fabric.Collection} collection + */ + restoreCollectionTraversal: function (collection) { + var _this = this; + collection.forEachObject(function (obj) { + if (obj.forEachObject) { + _this.restoreCollectionTraversal(obj); + } + else { + _this.restoreObjectVisibility(obj); + } + }); + }, + + /** + * @private + * This is designed to support erasing a group with both erasable and non-erasable objects. + * + * @param {'bottom' | 'top' | 'overlay'} layer + */ + prepareCanvasObjectsForLayer: function (layer) { + if (layer !== 'bottom') { return; } + this.prepareCollectionTraversal(this.canvas); + }, + + /** + * @private + * @param {'bottom' | 'top' | 'overlay'} layer + */ + restoreCanvasObjectsFromLayer: function (layer) { + if (layer !== 'bottom') { return; } + this.restoreCollectionTraversal(this.canvas); + }, + + /** + * @private + * @param {'bottom' | 'top' | 'overlay'} layer + * @returns boolean render overlay above brush + */ + prepareCanvasForLayer: function (layer) { + this.prepareCanvasBackgroundForLayer(layer); + this.prepareCanvasObjectsForLayer(layer); + return this.prepareCanvasOverlayForLayer(layer); + }, + + /** + * @private + * @param {'bottom' | 'top' | 'overlay'} layer + */ + restoreCanvasFromLayer: function (layer) { + this.restoreCanvasDrawables(); + this.restoreCanvasObjectsFromLayer(layer); + }, + + /** + * Render all non-erasable objects on bottom layer with the exception of overlays to avoid being clipped by the brush. + * Groups are rendered for nested selective erasing, non-erasable objects are visible while erasable objects are not. + */ + renderBottomLayer: function () { + var canvas = this.canvas; + this.prepareCanvasForLayer('bottom'); + canvas.renderCanvas( + canvas.getContext(), + canvas.getObjects().filter(function (obj) { + return !obj.erasable || obj.isType('group'); + }) + ); + this.restoreCanvasFromLayer('bottom'); + }, + + /** + * 1. Render all objects on top layer, erasable and non-erasable + * This is important for cases such as overlapping objects, the background object erasable and the foreground object not erasable. + * 2. Render the brush + */ + renderTopLayer: function () { + var canvas = this.canvas; + this._drawOverlayOnTop = this.prepareCanvasForLayer('top'); + canvas.renderCanvas( + canvas.contextTop, + canvas.getObjects() + ); + this.callSuper('_render'); + this.restoreCanvasFromLayer('top'); + }, + + /** + * Render all non-erasable overlays on top of the brush so that they won't get erased + */ + renderOverlay: function () { + this.prepareCanvasForLayer('overlay'); + var canvas = this.canvas; + var ctx = canvas.contextTop; + this._saveAndTransform(ctx); + canvas._renderOverlay(ctx); + ctx.restore(); + this.restoreCanvasFromLayer('overlay'); + }, + + /** + * @extends @class fabric.BaseBrush + * @param {CanvasRenderingContext2D} ctx + */ + _saveAndTransform: function (ctx) { + this.callSuper('_saveAndTransform', ctx); + ctx.globalCompositeOperation = 'destination-out'; + }, + + /** + * We indicate {@link fabric.PencilBrush} to repaint itself if necessary + * @returns + */ + needsFullRender: function () { + return this.callSuper('needsFullRender') || this._drawOverlayOnTop; + }, + + /** + * + * @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); + + this._isErasing = true; + this.canvas.fire('erasing:start'); + this._ready = true; + this._render(); + }, + + /** + * Rendering is done in 4 steps: + * 1. Draw all non-erasable objects on bottom ctx with the exception of overlays {@link fabric.EraserBrush#renderBottomLayer} + * 2. Draw all objects on top ctx including erasable drawables {@link fabric.EraserBrush#renderTopLayer} + * 3. Draw eraser {@link fabric.PencilBrush#_render} at {@link fabric.EraserBrush#renderTopLayer} + * 4. Draw non-erasable overlays {@link fabric.EraserBrush#renderOverlay} + * + * @param {fabric.Canvas} canvas + */ + _render: function () { + if (!this._ready) { + return; + } + this.isRendering = 1; + this.renderBottomLayer(); + this.renderTopLayer(); + this.renderOverlay(); + this.isRendering = 0; + }, + + /** + * @public + */ + render: function () { + if (this._isErasing) { + if (this.isRendering) { + this.isRendering = fabric.util.requestAnimFrame(this._renderBound); + } + else { + this._render(); + } + return true; + } + return false; + }, + + /** + * Adds path to existing clipPath of object + * + * @param {fabric.Object} obj + * @param {fabric.Path} path + */ + _addPathToObjectEraser: function (obj, path) { + var clipObject; + var _this = this; + // object is collection, i.e group + if (obj.forEachObject) { + obj.forEachObject(function (_obj) { + if (_obj.erasable) { + _this._addPathToObjectEraser(_obj, path); + } + }); + return; + } + if (!obj.getEraser()) { + var size = obj._getNonTransformedDimensions(); + var rect = new fabric.Rect({ + width: size.x, + height: size.y, + clipPath: obj.clipPath, + originX: 'center', + originY: 'center' + }); + clipObject = new fabric.Group([rect], { + eraser: true + }); + } + else { + clipObject = obj.clipPath; + } + + path.clone(function (path) { + path.globalCompositeOperation = 'destination-out'; + // http://fabricjs.com/using-transformations + var desiredTransform = fabric.util.multiplyTransformMatrices( + fabric.util.invertTransform( + obj.calcTransformMatrix() + ), + path.calcTransformMatrix() + ); + fabric.util.applyTransformToObject(path, desiredTransform); + clipObject.addWithUpdate(path); + obj.set({ + clipPath: clipObject, + dirty: true + }); + }); + }, + + /** + * 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', + 'backgroundColor', + 'overlayImage', + 'overlayColor', + ].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).join('') : + 'M 0 0 Q 0 0 0 0 L 0 0'; + if (pathData === 'M 0 0 Q 0 0 0 0 L 0 0') { + 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); + canvas.fire('before:path:created', { path: path }); + + // finalize erasing + var drawables = this.applyEraserToCanvas(path); + var _this = this; + var targets = []; + canvas.forEachObject(function (obj) { + if (obj.erasable && obj.intersectsWithObject(path)) { + _this._addPathToObjectEraser(obj, path); + targets.push(obj); + } + }); + + canvas.fire('erasing:end', { path: path, targets: targets, drawables: drawables }); + + canvas.requestRenderAll(); + path.setCoords(); + this._resetShadow(); + + // fire event 'path' created + canvas.fire('path:created', { path: path }); + } + } + ); + + /** ERASER_END */ +})();