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
JavaScript
(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 */
})();