Skip to main content

Command Palette

Search for a command to run...

How we implemented cropping on the canvas with Fabric.js

Updated
8 min read
How we implemented cropping on the canvas with Fabric.js

Introduction

SpaceRunners has a design tool where artists can create custom designs in a limited drawing area on any physical object. We already described the core concepts of the tool in previous articles on this blog. A core feature that every design tool must have by default is cropping. However, there's no native cropping feature in Fabric.js where one can just call a few functions and call it a day. We had to implement it ourselves using several steps. This entire process has sparse and incomplete documentation online. This blog post will try to give practical tips to anyone who encounters the same problems.

The basic idea of cropping in Fabric.js is to create a crop mask shape when you initiate the crop and then let the user move the mask around the drawable area. The user can position the mask to the exact area to be cropped and then confirm the action. Confirmation can be done by any combination of keys, clicks, or just a button that appears somewhere in the UI. Whatever is inside the mask will stay on the canvas and whatever is left out will be removed. The crop mask is applied by setting it as the clip path of the original image. The canvas is then exported to a data URL. The clip path is a Fabric.js concept that lets the user specify which part of the canvas is visible in the browser or when exported as an image output. This is the basic idea of how cropping works. The following sections will describe the process in detail together with some code examples.

Crop Mask

In our design tool, the crop is initiated by selecting an image and clicking on a specialized “Crop” icon in the toolbar, as displayed in the picture below:

After the crop is initiated, the design tool offers different crop shapes that can be applied to the image. Imagine this shape as a mold on an image. In the image below, which has a rectangle crop shape, you can see the rectangle crop mask over the image. After applying the crop mask, everything inside this rectangle marked by the white dots will stay on the canvas and everything else will get cropped out.

If we apply a heart shape you’ll notice how the crop mask will allow you to cut out heart shapes from the original image:

Our supported crop shapes are Rectangle, Circle, Rounded Rectangle, Heart, and Star. Fabric.js already has native shapes for everything except the Heart shape. For custom shapes such as Heart or whatever else you may want to use, you can use Fabric.js's Path object that takes an SVG path. When working with SVG icons, you must make sure that you include just the path and not the entire SVG, and that it's properly formatted and has just a single path and not embedded images. Here's the code that shows how we generate the crop mask based on the selected shape:

const heartSVGPath =
  'M 272.70141,238.71731 \
      C 206.46141,238.71731 152.70146,292.4773 152.70146,358.71731  \
      C 152.70146,493.47282 288.63461,528.80461 381.26391,662.02535 \
      C 468.83815,529.62199 609.82641,489.17075 609.82641,358.71731 \
      C 609.82641,292.47731 556.06651,238.7173 489.82641,238.71731  \
      C 441.77851,238.71731 400.42481,267.08774 381.26391,307.90481 \
      C 362.10311,267.08773 320.74941,238.7173 272.70141,238.71731  \
      Z ';

const CropMaskProps = {
  isCropMask: true,
  fill: 'rgba(0,0,0,0.3)',
  stroke: 'black',
  opacity: 1,
  originX: 'left',
  originY: 'top',
  hasRotatingPoint: false,
  transparentCorners: false,
  cornerColor: 'white',
  cornerStrokeColor: 'black',
  borderColor: 'black',
  cornerSize: 20 * 3,
  padding: 0,
  height: 150,
  width: 150,
  cornerStyle: 'circle',
  borderDashArray: [5, 5],
  excludeFromExport: true,
};

export const getCropMaskShape = (
  shapeName: CropShape,
  width: number,
  left: number,
  top: number
) => {
  let shape;

  if (shapeName === CropShape.RECTANGLE) {
    shape = new fabric.Rect({ ...CropMaskProps, left, centeredScaling: true, top });
  } else if (shapeName === CropShape.CIRCLE) {
    shape = new fabric.Circle({
      ...CropMaskProps,
      width: undefined,
      height: undefined,
      radius: width / 2,
    });
  } else if (shapeName === CropShape.ROUNDED_RECTANGLE) {
    shape = new fabric.Rect({
      ...CropMaskProps,
      rx: 20,
      ry: 20,
    });
  } else if (shapeName === CropShape.STAR) {
    shape = new fabric.Star({
      ...CropMaskProps,
      width: 200,
      height: 200,
    });
  } else if (shapeName === CropShape.HEART) {
    shape = new fabric.Path(heartSVGPath, {
      ...CropMaskProps,
      width: 200,
      height: 200,
    });
  }

  shape
    .scaleToWidth(width)
    .set({
      left,
      centeredScaling: true,
      top,
    })
    .setCoords();

  return shape;
};

Another important consideration that comes into effect at the end of cropping is that you must use similar logic to get the applied crop mask that will be used as a clip path to cut out the cropped area and remove the remainder. The original crop mask, marked by white circles as shown in the previous images, is draggable and also resizable. After the user performs any of these operations and decides on the final look of the mask, we must call another function that will take the coordinates of the live mask together with its absolute position on the canvas and then generate a new shape that is used just for cropping. Here's the code that achieves just that:

export const getAppliedCropMask = (shapeName: CropShape, croppingMask: CanvasObject) => {
  const commonProps = {
    left: croppingMask.left,
    top: croppingMask.top,
    originX: 'left',
    originY: 'top',
    absolutePositioned: true,
  };

  let cropMask;

  if (shapeName === CropShape.RECTANGLE) {
    cropMask = new fabric.Rect({
      ...commonProps,
      width: croppingMask.getScaledWidth(),
      height: croppingMask.getScaledHeight(),
    });
  } else if (shapeName === CropShape.CIRCLE) {
    cropMask = new fabric.Circle({
      ...commonProps,
      radius: croppingMask.radius * croppingMask.scaleX,
    });
  } else if (shapeName === CropShape.ROUNDED_RECTANGLE) {
    cropMask = new fabric.Rect({
      ...commonProps,
      rx: croppingMask.rx,
      ry: croppingMask.ry,
      width: croppingMask.getScaledWidth(),
      height: croppingMask.getScaledHeight(),
    });
  } else if (shapeName === CropShape.STAR) {
    cropMask = new fabric.Star({
      ...commonProps,
      width: croppingMask.getScaledWidth(),
      height: croppingMask.getScaledHeight(),
    });
  } else if (shapeName === CropShape.HEART) {
    cropMask = new fabric.Path(heartSVGPath, {
      ...commonProps,
      originX: 'left',
      originY: 'top',
    });

    cropMask.scaleToWidth(croppingMask.getScaledWidth());
  }

  return cropMask;
};

Applying the mask

After the mask is positioned at the desired place and scaled to the desired dimensions, the user can confirm the crop. At that point, we must perform a series of operations that will generate a new cropped output. Here's the code, with explanations of each line below it:

const cropMask = getAppliedCropMask(shapeToUse, croppingMask);

imageToCrop.clipPath = cropMask;

canvas.remove(croppingMask);
removeDrawingArea(canvas);

canvas.renderAll();

const cropped = new Image();

const backgroundImage = canvas.backgroundImage;
const overlayImage = canvas.overlayImage;

canvas.backgroundImage = null;
canvas.overlayImage = null;

const originalViewportTransform = canvas.viewportTransform;

canvas.viewportTransform = [1, 0, 0, 1, 0, 0];

cropped.src = canvas.toDataURL({
  left: cropMask.left,
  top: cropMask.top,
  width: cropMask.width * cropMask.scaleX,
  height: cropMask.height * cropMask.scaleY,
  multiplier: 5,
  format: 'png',
  quality: 0.99,
});

canvas.viewportTransform = originalViewportTransform;

canvas.backgroundImage = backgroundImage;
canvas.overlayImage = overlayImage;

cropped.onload = function () {
  const image = new fabric.Image(cropped);

  image.left = cropMask.left + (cropMask.width * cropMask.scaleX) / 2;
  image.top = cropMask.top + (cropMask.height * cropMask.scaleX) / 2;
  image.aiImage = activeObject.aiImage;
  image.setCoords();
  image.scaleToWidth(cropMask.width * cropMask.scaleX);

  image.set('radius', (cropMask.width * cropMask.scaleX) / 2);
  image.set('originX', 'center');
  image.set('originY', 'center');

  canvas.add(image);
  canvas.remove(imageToCrop);
  canvas.discardActiveObject();

  onCrop(image);
};

setCroppingMask(null);
setImageToCrop(null);

Here are the steps with explanations:

  1. const cropMask = getAppliedCropMask(shapeToUse, croppingMask);
    imageToCrop.clipPath = cropMask;

    - This gets the crop mask absolutely positioned on the canvas that will be used to tell the image output function which part of the original canvas to keep.

  2.  canvas.remove(croppingMask);
     removeDrawingArea(canvas);
    
     canvas.renderAll();
    
     const cropped = new Image();
    
     const backgroundImage = canvas.backgroundImage;
     const overlayImage = canvas.overlayImage;
    
     canvas.backgroundImage = null;
     canvas.overlayImage = null;
    

This code removes from the original canvas all elements that won't be in the output. For example, it removes the drawing area lines and background/overlay images (these are the template images). This is all temporary until the clipped canvas is exported. This also removes the draggable cropping mask and saves the original background and overlay images so they can be restored after clipping.

  1.  const originalViewportTransform = canvas.viewportTransform;
    
     canvas.viewportTransform = [1, 0, 0, 1, 0, 0];
    

    One tricky thing is that we must also adjust the canvas viewport at this step while preserving the original viewport. That's why we use the viewportTransform property on the canvas that is defined like this in the source docs:

    /**

    • The transformation (a Canvas 2D API transform matrix) which focuses the viewport

    • @type Array

    • @example Default transform

    • canvas.viewportTransform = [1, 0, 0, 1, 0, 0];

    • @example Scale by 70% and translate toward bottom-right by 50, without skewing

    • canvas.viewportTransform = [0.7, 0, 0, 0.7, 50, 50]; */ viewportTransform: TMat2D; **/

  1.  cropped.src = canvas.toDataURL({
       left: cropMask.left,
       top: cropMask.top,
       width: cropMask.width * cropMask.scaleX,
       height: cropMask.height * cropMask.scaleY,
       multiplier: 5,
       format: 'png',
       quality: 0.99,
     });
    
     canvas.viewportTransform = originalViewportTransform;
    
     canvas.backgroundImage = backgroundImage;
     canvas.overlayImage = overlayImage;
    

    The canvas.toDataURL function generates an image from the area on the canvas that was defined by the crop mask. Then we set it as the src for a new Image element. This new Image element will be used to replace the original one. After calling the toDataURL() function, we can restore the viewport and the background and overlay images so that the user can continue using the canvas normally. One important thing here is that this operation is so fast that the user doesn't see any visual effects.

  2.  cropped.onload = function () {
       const image = new fabric.Image(cropped);
    
       image.left = cropMask.left + (cropMask.width * cropMask.scaleX) / 2;
       image.top = cropMask.top + (cropMask.height * cropMask.scaleX) / 2;
       image.aiImage = activeObject.aiImage;
       image.setCoords();
       image.scaleToWidth(cropMask.width * cropMask.scaleX);
    
       image.set('radius', (cropMask.width * cropMask.scaleX) / 2);
       image.set('originX', 'center');
       image.set('originY', 'center');
    
       canvas.add(image);
       canvas.remove(imageToCrop);
       canvas.discardActiveObject();
    
       onCrop(image);
     };
    

    The last step is to position the new Image element at the exact origin where the crop mask was so that the cropped element stays in the same place and doesn't jump around. You'll notice that all the dimensions and coordinates of the image are set according to the crop mask counterparts. Then we add the cropped image to the canvas and remove the original one, while calling any handlers that will persist this new state on the backend, but that's unrelated to the canvas.

Conclusion

In this blog post we demonstrated how to implement the cropping operation on a Fabric.js canvas and also how to work with custom shapes. It’s very important to get all the details right because even one small mistake can make the elements go out of bounds or have wrong proportions. You can see the full source code for our entire design tool in our open sourced repository:

https://github.com/Space-Runners/ablo.ai/pulls

If you follow all of these steps and cross reference your implementation with our publicly available code you’ll get it right. The best approach would be to work backwards by first implementing our working implementation and then adjusting it piece by piece to fit your needs.