How to build an Image Cropper Control in Angular

10 min read


In this tutorial we'll be exploring how to build a robust Image Control Component in Angular that enables users to crop their photos to preferred dimensions right before they get uploaded to the server. If you've ever wondered how to do this, you're about to find out!

Video

Final Image Cropper

Consider this scenario, a user chooses a photo (probably too big or not having the right aspect ratio) which is then cropped further consequently creating an ideal frame. This resultant photo is then uploaded to the server. This ensures the photos maintain the correct aspect ratio and size, saving significant space on your server.

Excited? Let's dive in!

Setting Up An Angular Standalone Project

First, we need to create a new Angular standalone project. Here's how to do it:

ng new --standalone --inline-style --inline-template

We've also added the inline style and template parameters so we can develop a component completely in one file.

You can choose to opt out of this, if you prefer separate files for the template and styles

After that, we need to install a couple of packages

ng add @angular/material
npm install ngx-image-cropper

The Angular Material dialog and the image cropper from the ngx-image-cropper package will work together to build our reusable cropper component.

Creating the Image Control and Cropper Dialog Components

Now let's create the two components that we'll need: the Image Control and the Cropper Dialog.

ng g component components/image-control
ng g component components/cropper-dialog

These commands will create the two components respectively. The Cropper Dialog represents the Angular Material Dialog.

Basic UI setup

After creating these components, we move on to the AppComponent to setup our basic UI structure.

In our template, we create a mat-toolbar and give it a color of primary. Below the toolbar, we add a div with the class of container and some padding.

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, MatToolbarModule, ImageControlComponent],
  template: `
    <mat-toolbar color="primary"> Angular Image Cropper </mat-toolbar>

    <div class="container">

    </div>
  `,
  styles: [
    `
      .container {
        padding: 24px;
      }
    `,
  ],
})

Configuring the Image Control Component with Inputs

Inside this div, we're going to add our Image Control component, but not without adding two inputs to it. Making this component configurable means that you can crop the image to any desired width and heights.

We also want our input values to be reactive, so whenever they change Angular automatically updates the control's dimensions accordingly. Therefore, we'll store these inputs in signals by using setters for them.

export class ImageControlComponent {
  imageWidth = signal(0);
  @Input() set width(val: number) {
    this.imageWidth.set(val);
  }

  imageHeight = signal(0);
  @Input() set height(val: number) {
    this.imageHeight.set(val);
  }
}

Creating a placeholder for the image control

Since we don't have the image yet, we make use of a placeholder for now. We generate the placeholder using a computed signal that derives from our signals for image height and width.

The URL is from a pretty nifty utility service called placehold.co - where we can just pass in the dimensions and we get a placeholder image of the same.

placeholder = computed(
  () => `https://placehold.co/${this.imageWidth()}x${this.imageHeight()}`
);

Then we use this placeholder URL returned in the source of the image. Finally, we set up the image with the same dimensions as the values passed in and replace the placeholder with the required image.

<div [style.width]="imageWidth() + 'px'">
  <img [src]="placeholder()" [height]="imageHeight()" [width]="imageWidth()" />
</div>

Image Control Placeholder

As a result, our image control now is fully reactive - which means whenever the width and/or height changes, the placeholder will change automatically using the power of Signals!

If you need an introduction to the new Signals API in Angular, you might find my series of blog posts here useful

Finishing touches to the Image Control UI

Next, we add a button in the Image Control Component for photo selection and a bit of mat-elevation-z5 to the image so that things looks even better.

<div [style.width]="imageWidth() + 'px'">
  <img ... class="mat-elevation-z5" />
  <button mat-raised-button>Select Photo</button>
</div>

Image Control UI complete

Now that we have a basic structure of the Image Control Component, we are ready to advance to the second part of our task – creating the Cropper Dialog Component.

Creating the Cropper Dialog Component

We start by defining the two data types for the dialog’s input and result. Here, we create the CropperDialogData interface containing the image, width, and height objects. We define these right on top of the cropper dialog component.

export type CropperDialogData = {
  image: File;
  width: number;
  height: number;
};

export type CropperDialogResult = {
  blob: Blob;
  imageUrl: string;
};

The result would contain the Blob data type, which we can upload to our server, and a URL to display the Blob wherever we'd like.

Configuring Cropper Dialog Component

Next, we inject the MAT_DIALOG_DATA in the Cropper Dialog component so that we can access the parameters which we'll send while opening the mat-dialog.

Also, we add a new signal in which we'll store the data returned by the cropper.

data: CropperDialogData = inject(MAT_DIALOG_DATA);
result = signal<CropperDialogResult | undefined>(undefined);

The type of this signal is CropperDialogResult(defined above), which could be undefined initially when there’s no result yet.

Adding Cropper Dialog UI

After defining the data, we start by adding a title with the directive mat-dialog-title. Then we specify the contents of the dialog by creating a div with the mat-dialog-content directive and the Image Cropper component. Also, we add the action buttons at the bottom.

<h1 mat-dialog-title>Please crop your photo</h1>

<div mat-dialog-content>
  <!-- Image cropper will go here -->
</div>

<div mat-dialog-actions>
  <button mat-raised-button [mat-dialog-close]="false">Cancel</button>
  <button mat-raised-button color="primary" [mat-dialog-close]="result()">
    Done
  </button>
</div>

The cancel button will simply close the dialog with a false value. The Done button will send the result signal we defined that contains the last selected crop of the image. Notice how conveniently we use the mat-dialog-close directive which is shorthand way of closing the dialog.

Adding the Ngx Image Cropper

Now let's add the ngx-image-cropper to the dialog content and give in the following inputs to it.

<div mat-dialog-content>
  <image-cropper
    [imageFile]="data.image"
    [maintainAspectRatio]="true"
    [aspectRatio]="data.width / data.height"
    [resizeToHeight]="data.height"
    [resizeToWidth]="data.width"
    (imageCropped)="imageCropped($event)"
  >
  </image-cropper>
</div>

We're sending in the image file and specifying the aspect ratio that we want the cropper to maintain by using the dimensions we get from the dialog's data.

Note also the use of resizeToHeight and resizeToWidth inputs: these ensure that the output will be resized to the specific dimensions even if the user selects a very large sized photo e.g. from their phone camera.

For more inputs and options available for the ngx-image-cropper, you may refer to the official docs for the package here.

When crops are made on the image, the imageCropped event lets us save the new result each time a modification occurs. This event handler is called multiple times when the crop box is adjusted.

All we do in the handler is check for valid blob and then set the value to the result signal.

imageCropped(event: ImageCroppedEvent) {
  const { blob, objectUrl } = event;
  if (blob && objectUrl) {
    this.result.set({ blob, imageUrl: objectUrl });
  }
}

Great, we're all done with the Cropper Dialog now. But we need to add file selection functionality to the image control. So let's go back and do that now.

Adding File Selection to Image Control

The first thing we do here is add a hidden file input field, from where our image would be selected. This input field is hidden because we want our users to interact with the ‘Select Photo’ button instead, creating a more pleasant user experience.

We then trigger the click of the input field on button click referring to the input field with a template variable inputField.

<input
  #inputField
  type="file"
  hidden
  (change)="fileSelected($event)"
  (click)="inputField.value = ''"
/>
<button mat-raised-button (click)="inputField.click()">Select Photo</button>

We add two event handlers to the input field. The first is the change event called fileSelected(), which is triggered when a file is selected for upload. The second is an added click event that clears the value every time the input field is clicked.

This is meant to prevent a bug which I discovered during testing - when selecting the same file multiple times.

Opening the cropper dialog

After a file is selected, we need to show the Cropper Dialog component after checking that the selected file is valid. To do this, we first inject MatDialog in the component and subsequently open the Cropper Dialog.

Note: Please also ensure the MatDialogModule is included in the imports of the component for the dialog to work

dialog = inject(MatDialog);

fileSelected(event: any) {
  const file = event.target.files[0];
  if (file) {
    const dialogRef = this.dialog.open(CropperDialogComponent, {
      data: {
        image: file,
        width: this.imageWidth(),
        height: this.imageHeight(),
      },
      width: '500px',
    });

    ...
  }
}

As you can see, we send in the file and the dimensions in the data for the dialog and keep a reference to the dialogRef so we can handle it when its closed.

Let's see how we do that next!

croppedImage = signal<CropperDialogResult | undefined>(undefined);

dialogRef
  .afterClosed()
  .pipe(filter((result) => !!result))
  .subscribe((result) => {
    this.croppedImage.set(result);
  });

So where do we keep the result? We create a new signal for the purpose. Then we listen to the dialogRef afterClosed function and filter on the result being truthy. This means when we subscribe to the RxJS stream, it'll only run when a valid image result is returned.

Then, we simply set the value of the result in croppedImage signal.

Updating the image source of the control

We have just one last thing that we need to do. And that is update the image source in the image control component - because currently it only shows the placeholder all the time.

Let's create a new computed signal called imageSource and add the following logic to it.

placeholder = computed(
  () => `https://placehold.co/${this.imageWidth()}x${this.imageHeight()}`
);

croppedImage = signal<CropperDialogResult | undefined>(undefined);

imageSource = computed(() => {
  if (this.croppedImage()) {
    return this.croppedImage()?.imageUrl;
  }

  return this.placeholder();
});

So basically we just want to show the placeholder when we don't have any croppedImage available. But if we do, we want to show that instead using its imageUrl.

You can see now the power of signals and the reactivity it brings to Angular out of the box!

Ok, let's test this out now finally.

Image Cropper Dialog works

Cropped Image shows fine

Finishing touches

Once the user is happy with the crop, the blob for that image should be sent back to the parent component and can then be uploaded to the server. To do that, we need to add an output called imageReady to the Image Control component for the function of sending the cropped image back to the parent component.

We can do that easily in an effect like this.

@Output() imageReady = new EventEmitter<Blob>();

constructor() {
  effect(() => {
    if (this.croppedImage()) {
      this.imageReady.emit(this.croppedImage()?.blob);
    }
  });
}

So whenever the croppedImage signal's value changes, the output is emitted with it's blob and can be handled in the parent component as a normal component output can be.

Wrapping Up

And that's it! You've now created a reusable image cropper component in Angular. This component can automatically generate placeholders based on the input dimensions, allow users to select photos, crop them to retain the correct aspect ratio, and send the correctly sized photos back to the parent so you can upload to the server.

I hope you enjoyed this tutorial. Feel free to play around with and improve the code available here on github. In the next part of this tutorial, we'll explore how you can automatically upload this image to a server (like Firebase Storage) and get a URL back in return!

Stay tuned for more such tutorials. Happy Coding!

Support

You may also like...