fabric
Version:
Object model for HTML5 canvas, and SVG-to-canvas parser. Backed by jsdom and node-canvas.
876 lines (826 loc) • 29.8 kB
text/typescript
//@ts-nocheck
import { Point } from '../Point';
import { FabricObject } from '../shapes/Object/FabricObject';
import { uid } from '../util/internals/uid';
(function (global) {
/** ERASER_START */
var fabric = global.fabric,
__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_' + 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_ */
});
fabric.util.object.extend(fabric.Group.prototype, {
/**
* @private
* @param {fabric.Path} path
* @returns {Promise<fabric.Path[]>}
*/
_addEraserPathToObjects: function (path) {
return Promise.all(
this._objects.map(function (object) {
return 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}
* @returns {Promise<fabric.Path[]|fabric.Path[][]|void>}
*/
applyEraserToObjects: function () {
var _this = this,
eraser = this.eraser;
return Promise.resolve().then(function () {
if (eraser) {
delete _this.eraser;
var transform = _this.calcTransformMatrix();
return eraser.clone().then(function (eraser) {
var clipPath = _this.clipPath;
return Promise.all(
eraser.getObjects('path').map(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);
return clipPath
? clipPath.clone().then(
function (_clipPath) {
var eraserPath =
fabric.EraserBrush.prototype.applyClipPathToPath.call(
fabric.EraserBrush.prototype,
path,
_clipPath,
transform,
);
return _this._addEraserPathToObjects(eraserPath);
},
['absolutePositioned', 'inverted'],
)
: _this._addEraserPathToObjects(path);
}),
);
});
}
});
},
});
/**
* 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',
/**
* eraser should retain size
* dimensions should not change when paths are added or removed
* handled by {@link fabric.Object#_drawClipPath}
* @override
* @private
*/
layout: 'fixed',
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);
},
/* _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 instance from an object representation
* @static
* @memberOf fabric.Eraser
* @param {Object} object Object to create an Eraser from
* @returns {Promise<fabric.Eraser>}
*/
fabric.Eraser.fromObject = function (object) {
var objects = object.objects || [],
options = fabric.util.object.clone(object, true);
delete options.objects;
return Promise.all([
fabric.util.enlivenObjects<FabricObject>(objects),
fabric.util.enlivenObjectEnlivables(options),
]).then(function (enlivedProps) {
return new fabric.Eraser(
enlivedProps[0],
Object.assign(options, enlivedProps[1]),
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);
this.isErasing() && 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
* @type boolean
*/
inverted: false,
/**
* Used to fix https://github.com/fabricjs/fabric.js/issues/7984
* Reduces the path width while clipping the main context, resulting in a better visual overlap of both contexts
* @type number
*/
erasingWidthAliasing: 4,
/**
* @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 while maintaining object stacking.\
* Iterates over collections to allow nested selective erasing.\
* Prepares objects before rendering the pattern brush.\
* If brush is **NOT** inverted render all non-erasable objects.\
* If brush is inverted render all objects, erasable objects without their eraser.
* This will render the erased parts as if they were not erased in the first place, achieving an undo effect.
*
* @param {fabric.Collection} collection
* @param {fabric.Object[]} objects
* @param {CanvasRenderingContext2D} ctx
* @param {{ visibility: fabric.Object[], eraser: fabric.Object[], collection: fabric.Object[] }} restorationContext
*/
_prepareCollectionTraversal: function (
collection,
objects,
ctx,
restorationContext,
) {
objects.forEach(function (obj) {
var dirty = false;
if (obj.forEachObject && obj.erasable === 'deep') {
// traverse
this._prepareCollectionTraversal(
obj,
obj._objects,
ctx,
restorationContext,
);
} else if (!this.inverted && obj.erasable && obj.visible) {
// render only non-erasable objects
obj.visible = false;
restorationContext.visibility.push(obj);
dirty = true;
} else if (
this.inverted &&
obj.erasable &&
obj.eraser &&
obj.visible
) {
// render all objects without eraser
var eraser = obj.eraser;
obj.eraser = undefined;
obj.dirty = true;
restorationContext.eraser.push([obj, eraser]);
dirty = true;
}
if (dirty && collection instanceof fabric.Object) {
collection.dirty = true;
restorationContext.collection.push(collection);
}
}, this);
},
/**
* Prepare the pattern for the erasing brush
* This pattern will be drawn on the top context after clipping the main context,
* achieving a visual effect of erasing only erasable objects
* @private
* @param {fabric.Object[]} [objects] override default behavior by passing objects to render on pattern
*/
preparePattern: function (objects) {
if (!this._patternCanvas) {
this._patternCanvas = fabric.util.createCanvasElement();
}
var canvas = this._patternCanvas;
objects =
objects || this.canvas._objectsToRender || this.canvas._objects;
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) {
var eraser = backgroundImage && backgroundImage.eraser;
if (eraser) {
backgroundImage.eraser = undefined;
backgroundImage.dirty = true;
}
this.canvas._renderBackground(patternCtx);
if (eraser) {
backgroundImage.eraser = eraser;
backgroundImage.dirty = true;
}
}
patternCtx.save();
patternCtx.transform.apply(patternCtx, this.canvas.viewportTransform);
var restorationContext = { visibility: [], eraser: [], collection: [] };
this._prepareCollectionTraversal(
this.canvas,
objects,
patternCtx,
restorationContext,
);
this.canvas._renderObjects(patternCtx, objects);
restorationContext.visibility.forEach(function (obj) {
obj.visible = true;
});
restorationContext.eraser.forEach(function (entry) {
var obj = entry[0],
eraser = entry[1];
obj.eraser = eraser;
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) {
var eraser = overlayImage && overlayImage.eraser;
if (eraser) {
overlayImage.eraser = undefined;
overlayImage.dirty = true;
}
__renderOverlay.call(this.canvas, patternCtx);
if (eraser) {
overlayImage.eraser = eraser;
overlayImage.dirty = true;
}
}
},
/**
* 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'
: 'destination-in';
},
/**
* We indicate {@link fabric.PencilBrush} to repaint itself if necessary
* @returns
*/
needsFullRender: function () {
return true;
},
/**
*
* @param {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
*
* @todo provide a better solution to https://github.com/fabricjs/fabric.js/issues/7984
*/
_render: function () {
var ctx,
lineWidth = this.width;
var t = this.canvas.getRetinaScaling(),
s = 1 / t;
// clip canvas
ctx = this.canvas.getContext();
// a hack that fixes https://github.com/fabricjs/fabric.js/issues/7984 by reducing path width
// the issue's cause is unknown at time of writing (@ShaMan123 06/2022)
if (lineWidth - this.erasingWidthAliasing > 0) {
this.width = lineWidth - this.erasingWidthAliasing;
this.callSuper('_render', ctx);
this.width = lineWidth;
}
// render brush and mask it with pattern
ctx = this.canvas.contextTop;
this.canvas.clearContext(ctx);
ctx.save();
ctx.scale(s, s);
ctx.drawImage(this._patternCanvas, 0, 0);
ctx.restore();
this.callSuper('_render', ctx);
},
/**
* 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
* @returns {Promise<fabric.Path>}
*/
clonePathWithClipPath: function (path, object) {
var objTransform = object.calcTransformMatrix();
var clipPath = object.clipPath;
var _this = this;
return Promise.all([
path.clone(),
clipPath.clone(['absolutePositioned', 'inverted']),
]).then(function (clones) {
return _this.applyClipPathToPath(clones[0], clones[1], objTransform);
});
},
/**
* Adds path to object's eraser, walks down object's descendants if necessary
*
* @public
* @fires erasing:end on object
* @param {fabric.Object} obj
* @param {fabric.Path} path
* @param {Object} [context] context to assign erased objects to
* @returns {Promise<fabric.Path | fabric.Path[]>}
*/
_addPathToObjectEraser: function (obj, path, context) {
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) {
return this.clonePathWithClipPath(path, obj).then(function (_path) {
return Promise.all(
targets.map(function (_obj) {
return _this._addPathToObjectEraser(_obj, _path, context);
}),
);
});
} else if (targets.length > 0) {
return Promise.all(
targets.map(function (_obj) {
return _this._addPathToObjectEraser(_obj, path, context);
}),
);
}
return;
}
// prepare eraser
var eraser = obj.eraser;
if (!eraser) {
eraser = new fabric.Eraser();
obj.eraser = eraser;
}
// clone and add path
return path.clone().then(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.add(path);
obj.set('dirty', true);
obj.fire('erasing:end', {
path: path,
});
if (context) {
(obj.group ? context.subTargets : context.targets).push(obj);
//context.paths.set(obj, path);
}
return path;
});
},
/**
* Add the eraser path to canvas drawables' clip paths
*
* @param {fabric.Canvas} source
* @param {fabric.Canvas} path
* @param {Object} [context] context to assign erased objects to
* @returns {Promise<fabric.Path[]|void>} eraser paths
*/
applyEraserToCanvas: function (path, context) {
var canvas = this.canvas;
return Promise.all(
['backgroundImage', 'overlayImage'].map(function (prop) {
var drawable = canvas[prop];
return (
drawable &&
drawable.erasable &&
this._addPathToObjectEraser(drawable, path).then(function (path) {
if (context) {
context.drawables[prop] = drawable;
//context.paths.set(drawable, path);
}
return path;
})
);
}, this),
);
},
/**
* 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 _this = this;
var context = {
targets: [],
subTargets: [],
//paths: new Map(),
drawables: {},
};
var tasks = canvas._objects.map(function (obj) {
return (
obj.erasable &&
obj.intersectsWithObject(path, true, true) &&
_this._addPathToObjectEraser(obj, path, context)
);
});
tasks.push(_this.applyEraserToCanvas(path, context));
return Promise.all(tasks).then(function () {
// fire erasing:end
canvas.fire(
'erasing:end',
Object.assign(context, {
path: path,
}),
);
canvas.requestRenderAll();
_this._resetShadow();
// fire event 'path' created
canvas.fire('path:created', { path: path });
});
},
},
);
/** ERASER_END */
})(typeof exports !== 'undefined' ? exports : window);