UNPKG

fabric-with-gestures

Version:

Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.

717 lines (663 loc) 24.1 kB
(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 <mask> 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 = ['<defs>', '<mask ' + commons + ' >', eraser.toSVG(options.reviver), '</mask>', '</defs>']; eraser._objects[0].fill = fill; return objectMarkup.join('\n'); } return ''; }, /** * use <mask> to achieve erasing for svg, override <clipPath> * @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 */ })();