Reputation: 910
I'm working on a project where fabric.js (version 5.3) is used in several different places. I have recently built a requested feature with it and in the meantime, I had to extend some fabric classes like fabric.Object
, fabric.Shadow
, fabric.Line
etc. and their prototype methods to apply custom logic.
I recently figured that these changes were affecting every code where fabric is imported and used and due to this fact, it makes some of these codes to not work properly. I want to convert my extend code to subclasses so I can avoid this issue.
Below is the code I use to extends fabric's classes and their methods:
// Ensure the fillEnabled property is included when serializing or cloning Fabric.js objects.
fabric.Object.prototype.initialize = (function (originalInitialize) {
return function (options) {
originalInitialize.call(this, options);
this.fillEnabled = options?.fillEnabled ?? true; // Default to true if not provided
this.prevStrokeWidth = options?.prevStrokeWidth ?? null; // Default to null if not provided
this.backgroundEnabled = options?.backgroundEnabled ?? true; // Default to true if not provided
};
})(fabric.Object.prototype.initialize);
// Extend or patch the fabric.Object.prototype._render method to skip shadow rendering if enabled is false.
fabric.Object.prototype.render = (function (originalRender) {
return function (ctx) {
let prevBackgroundColor = null;
let prevShadow = null;
let prevFill = null;
if (this.backgroundEnabled === false) {
// Temporarily disable backgroundColor for this rendering pass
prevBackgroundColor = this.backgroundColor;
this.backgroundColor = null;
}
if (this.fillEnabled === false) {
// Temporarily disable fill for this rendering pass
prevFill = this.fill;
this.fill = null; // Disable fill
}
if (this.shadow && this.shadow.enabled === false) {
// Temporarily remove the shadow if 'enabled' is false
prevShadow = this.shadow;
this.shadow = null;
}
// Call the original render logic without the disabled properties such as shadow
originalRender.call(this, ctx);
if (prevBackgroundColor !== null) {
// Restore the backgroundColor property
this.backgroundColor = prevBackgroundColor;
}
if (prevShadow !== null) {
// Restore the shadow property
this.shadow = prevShadow;
}
if (prevFill) {
// Restore the original fill
this.fill = prevFill;
}
};
})(fabric.Object.prototype.render);
// Override or extend the fabric.Object methods to check the fillEnabled property before rendering the shadow.
fabric.Object.prototype.toObject = (function (originalToObject) {
return function () {
// Exclude objects marked with `excludeFromExport`
if (this.excludeFromExport === true) {
return null;
}
const propertiesToInclude = [
'points',
'hoverCursor',
'selectable',
'evented',
'objectCaching',
'_controlsVisibility',
];
const obj = originalToObject.call(this, propertiesToInclude);
obj.id = this.id;
obj.relatedTo = this.relatedTo;
obj.name = this.name;
obj.fillEnabled = this.fillEnabled ?? true;
obj.prevStrokeWidth = this.prevStrokeWidth ?? null;
obj.backgroundEnabled = this.backgroundEnabled ?? true;
if (this.type === 'arrow-customizable') {
obj.x1 = this.x1;
obj.y1 = this.y1;
obj.x2 = this.x2;
obj.y2 = this.y2;
obj.arrowFillEnabled = this.arrowFillEnabled || true;
obj.arrowFlapAngle = this.arrowFlapAngle || 30;
obj.arrowFlapLength = this.arrowFlapLength || 0.1;
obj.headCorner = this.headCorner || null;
obj.tailCorner = this.tailCorner || null;
obj.points = this.points || [this.x1, this.y1, this.x2, this.y2];
}
return obj;
};
})(fabric.Object.prototype.toObject);
fabric.ArrowCustomizable = fabric.util.createClass(fabric.Line, {
type: 'arrow-customizable',
initialize(element, options) {
options || (options = {});
this.arrowFillEnabled = options.arrowFillEnabled || true; // Default arrow fill flag value
this.arrowFlapAngle = options.arrowFlapAngle || 30; // Default flap angle in degrees
this.arrowFlapLength = options.arrowFlapLength || 0.1; // Default flap length as percentage
this.callSuper('initialize', element, options);
},
_render(ctx) {
const arrowFlapAngleRad = (this.arrowFlapAngle * Math.PI) / 180; // Convert degrees to radians
const arrowFlapLength = (this.arrowFlapLength / 2) * this.length(); // Length as a percentage of the arrow body
const arrowHalfWidth = Math.tan(arrowFlapAngleRad) * arrowFlapLength; // Half-width of arrowhead
// Calculate rotation angle of the arrow
const xDiff = this.x2 - this.x1;
const yDiff = this.y2 - this.y1;
const angle = Math.atan2(yDiff, xDiff);
ctx.beginPath();
const p = this.calcLinePoints();
ctx.moveTo(p.x1, p.y1);
if (this.arrowFillEnabled) {
const arrowFlapLengthDoubled = arrowFlapLength * 2;
ctx.lineTo(
p.x2 - arrowFlapLengthDoubled * Math.cos(angle),
p.y2 - arrowFlapLengthDoubled * Math.sin(angle)
);
} else {
const tipOffset = this.strokeWidth / 2;
ctx.lineTo(
p.x2 - tipOffset * Math.cos(angle),
p.y2 - tipOffset * Math.sin(angle)
);
}
ctx.lineWidth = this.strokeWidth;
ctx.strokeStyle = this.stroke;
this._renderStroke(ctx);
ctx.save();
// Adjust the line's endpoint to end at the base of the arrowhead
const lineEndX =
(this.x2 - this.x1) / 2 - arrowFlapLength * Math.cos(angle);
const lineEndY =
(this.y2 - this.y1) / 2 - arrowFlapLength * Math.sin(angle);
// Translate to arrow tip and rotate to match arrow direction
ctx.translate(lineEndX, lineEndY);
ctx.rotate(angle);
// Draw the arrowhead
if (this.arrowFillEnabled) {
ctx.beginPath();
ctx.moveTo(arrowFlapLength, 0); // Arrow tip
ctx.lineTo(-arrowFlapLength, arrowHalfWidth); // Bottom edge of arrowhead
ctx.lineTo(-arrowFlapLength, -arrowHalfWidth); // Top edge of arrowhead
ctx.closePath();
} else {
ctx.moveTo(-arrowFlapLength, arrowHalfWidth); // Bottom edge of arrowhead
ctx.lineTo(arrowFlapLength, 0); // Arrow tip
ctx.lineTo(-arrowFlapLength, -arrowHalfWidth); // Top edge of arrowhead
}
if (this.arrowFillEnabled) {
ctx.fillStyle = this.stroke;
ctx.fill();
} else {
ctx.lineWidth = this.strokeWidth;
ctx.strokeStyle = this.stroke;
ctx.stroke();
}
ctx.restore();
},
// Method to calculate the length of the arrow body
length() {
const xDiff = this.x2 - this.x1;
const yDiff = this.y2 - this.y1;
return Math.sqrt(xDiff * xDiff + yDiff * yDiff);
},
});
// Ensure the arrow properties are included during serialization/deserialization
fabric.ArrowCustomizable.fromObject = function (object, callback) {
return fabric.Object._fromObject(
'ArrowCustomizable',
{
...object,
points: [object.x1, object.y1, object.x2, object.y2],
arrowFlapAngle: object.arrowFlapAngle,
arrowFlapLength: object.arrowFlapLength,
},
callback,
'points'
);
};
const canvas = new fabric.Canvas('canvas');
let arrow = new fabric.ArrowCustomizable([25, 25, 300, 200], {
strokeWidth: 1,
stroke: '#000000',
});
canvas.add(arrow);
#canvas {
border: 1px solid black;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/fabric.min.js" type="text/javascript"></script>
</head>
<body>
<canvas width="500" height="300" id="canvas"></canvas>
</body>
</html>
As you can see, in my code I extend the fabric.Line
class to subclass it and create fabric.ArrowCustomizable
class. I want to be able to create a custom general subclass of fabric.Object
class which we can call fabric.CustomObject
to apply my custom render mechanism to all objects extending it. For the fabric.ArrowCustomizable
class, I will have to subclass my fabric.CustomObject
to subclass fabric.CustomLine
which I will use to subclass fabric.ArrowCustomizable
class. Below was my first attempt on coding this subclassing but as you can see I can not see my arrow being drawn/rendered properly.
fabric.CustomObject = fabric.util.createClass(fabric.Object, {
initialize: function (options) {
options || (options = {});
this.callSuper('initialize', options);
this.fillEnabled = options.fillEnabled ?? true;
this.prevStrokeWidth = options.prevStrokeWidth ?? null;
this.backgroundEnabled = options.backgroundEnabled ?? true;
},
render: function (ctx) {
let prevBackgroundColor = null;
let prevShadow = null;
let prevFill = null;
if (this.backgroundEnabled === false) {
// Temporarily disable backgroundColor for this rendering pass
prevBackgroundColor = this.backgroundColor;
this.backgroundColor = null;
}
if (this.fillEnabled === false) {
// Temporarily disable fill for this rendering pass
prevFill = this.fill;
this.fill = null; // Disable fill
}
if (this.shadow && this.shadow.enabled === false) {
// Temporarily remove the shadow if 'enabled' is false
prevShadow = this.shadow;
this.shadow = null;
}
// Call the original render logic without the disabled properties such as shadow
this.callSuper('render', ctx);
if (prevBackgroundColor !== null) {
// Restore the backgroundColor property
this.backgroundColor = prevBackgroundColor;
}
if (prevShadow !== null) {
// Restore the shadow property
this.shadow = prevShadow;
}
if (prevFill) {
// Restore the original fill
this.fill = prevFill;
}
},
toObject: function () {
// Exclude objects marked with `excludeFromExport`
if (this.excludeFromExport === true) {
return null;
}
const propertiesToInclude = [
'points',
'hoverCursor',
'selectable',
'evented',
'objectCaching',
'_controlsVisibility',
];
const obj = this.callSuper('toObject', propertiesToInclude);
obj.id = this.id;
obj.name = this.name;
obj.fillEnabled = this.fillEnabled ?? true;
obj.prevStrokeWidth = this.prevStrokeWidth ?? null;
obj.backgroundEnabled = this.backgroundEnabled ?? true;
if (this.type === 'arrow-customizable') {
obj.x1 = this.x1;
obj.y1 = this.y1;
obj.x2 = this.x2;
obj.y2 = this.y2;
obj.arrowFillEnabled = this.arrowFillEnabled || true;
obj.arrowFlapAngle = this.arrowFlapAngle || 30;
obj.arrowFlapLength = this.arrowFlapLength || 0.1;
obj.headCorner = this.headCorner || null;
obj.tailCorner = this.tailCorner || null;
obj.points = this.points || [this.x1, this.y1, this.x2, this.y2];
}
return obj;
},
});
fabric.CustomLine = fabric.util.createClass(fabric.CustomObject, {
type: 'custom-line',
initialize: function (points, options) {
options || (options = {});
options.points = points ?? [0, 0, 0, 0];
options.x1 = points[0];
options.y1 = points[1];
options.x2 = points[2];
options.y2 = points[3];
this.callSuper('initialize', options);
},
calcLinePoints: fabric.Line.prototype.calcLinePoints,
});
fabric.ArrowCustomizable = fabric.util.createClass(fabric.CustomLine, {
type: 'arrow-customizable',
initialize(points, options) {
options || (options = {});
this.arrowFillEnabled = options.arrowFillEnabled || true;
this.arrowFlapAngle = options.arrowFlapAngle || 30;
this.arrowFlapLength = options.arrowFlapLength || 0.1;
this.callSuper('initialize', points, options);
},
_render(ctx) {
const arrowFlapAngleRad = (this.arrowFlapAngle * Math.PI) / 180;
const arrowFlapLength = (this.arrowFlapLength / 2) * this.length();
const arrowHalfWidth = Math.tan(arrowFlapAngleRad) * arrowFlapLength;
// Calculate rotation angle of the arrow
const xDiff = this.x2 - this.x1;
const yDiff = this.y2 - this.y1;
const angle = Math.atan2(yDiff, xDiff);
ctx.beginPath();
const p = this.calcLinePoints();
ctx.moveTo(p.x1, p.y1);
if (this.arrowFillEnabled) {
const arrowFlapLengthDoubled = arrowFlapLength * 2;
ctx.lineTo(
p.x2 - arrowFlapLengthDoubled * Math.cos(angle),
p.y2 - arrowFlapLengthDoubled * Math.sin(angle)
);
} else {
const tipOffset = this.strokeWidth / 2;
ctx.lineTo(
p.x2 - tipOffset * Math.cos(angle),
p.y2 - tipOffset * Math.sin(angle)
);
}
ctx.lineWidth = this.strokeWidth;
ctx.strokeStyle = this.stroke;
this._renderStroke(ctx);
ctx.save();
// Adjust the line's endpoint to end at the base of the arrowhead
const lineEndX =
(this.x2 - this.x1) / 2 - arrowFlapLength * Math.cos(angle);
const lineEndY =
(this.y2 - this.y1) / 2 - arrowFlapLength * Math.sin(angle);
// Translate to arrow tip and rotate to match arrow direction
ctx.translate(lineEndX, lineEndY);
ctx.rotate(angle);
// Draw the arrowhead
if (this.arrowFillEnabled) {
ctx.beginPath();
ctx.moveTo(arrowFlapLength, 0); // Arrow tip
ctx.lineTo(-arrowFlapLength, arrowHalfWidth); // Bottom edge of arrowhead
ctx.lineTo(-arrowFlapLength, -arrowHalfWidth); // Top edge of arrowhead
ctx.closePath();
} else {
ctx.moveTo(-arrowFlapLength, arrowHalfWidth); // Bottom edge of arrowhead
ctx.lineTo(arrowFlapLength, 0); // Arrow tip
ctx.lineTo(-arrowFlapLength, -arrowHalfWidth); // Top edge of arrowhead
}
if (this.arrowFillEnabled) {
ctx.fillStyle = this.stroke;
ctx.fill();
} else {
ctx.lineWidth = this.strokeWidth;
ctx.strokeStyle = this.stroke;
ctx.stroke();
}
ctx.restore();
},
calcLinePoints: fabric.Line.prototype.calcLinePoints,
// Method to calculate the length of the arrow body
length() {
const xDiff = this.x2 - this.x1;
const yDiff = this.y2 - this.y1;
return Math.sqrt(xDiff * xDiff + yDiff * yDiff);
},
});
// Ensure the arrow properties are included during serialization/deserialization
fabric.ArrowCustomizable.fromObject = function (object, callback) {
return fabric.Object._fromObject(
'ArrowCustomizable',
{
...object,
points: [object.x1, object.y1, object.x2, object.y2],
arrowFlapAngle: object.arrowFlapAngle,
arrowFlapLength: object.arrowFlapLength,
},
callback,
'points'
);
};
const canvas = new fabric.Canvas('canvas');
let arrow = new fabric.ArrowCustomizable([25, 25, 300, 200], {
strokeWidth: 1,
stroke: '#000000'
});
canvas.add(arrow);
#canvas {
border: 1px solid black;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/fabric.min.js" type="text/javascript"></script>
</head>
<body>
<canvas width="500" height="300" id="canvas"></canvas>
</body>
</html>
Upvotes: 0
Views: 63