M. Çağlar TUFAN
M. Çağlar TUFAN

Reputation: 910

Fabric.js how to subclass the `fabric.Object`

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

Answers (0)

Related Questions