Angular Image Resizing


Description

This is a simple image resizing service that can be used to resize images.

Installation

npm i pica
npm i -D @types/pica

Implementation

// image.service.ts
import { Injectable, NgZone, inject } from '@angular/core';
import * as PicaClient from 'pica';
import { SnackBarService } from './snack-bar.service';

const fitInside = ([x, y]: [number, number], [w, h]: [number, number]) => {
  if (x <= w && y <= h) return;
  x > w && ((y /= x / w), (x = w));
  y > h && ((x /= y / h), (y = h));
  return [Math.floor(x), Math.floor(y)];
};

export interface UploadThumbOption {
  maxSize?: number;
  type?: RegExp;
  exportTo?: string;
  resize?: [number, number];
}

const defaultThumbOption = {
  maxSize: 4 * 1024 * 1024,
  type: /^image\/(png|jpeg)$/,
  resize: [480, 480] as [number, number],
};

@Injectable({ providedIn: 'root' })
export class ImageService {
  readonly #ngZone = inject(NgZone);
  readonly #snackBarService = inject(SnackBarService);
  readonly #picaClient = PicaClient();

  public async resize(image: HTMLImageElement, to: [number, number] = [600, 600]) {
    const res = fitInside([image.naturalWidth, image.naturalHeight], to);
    if (!res) return;
    const picaClient = this.#picaClient;
    const canvas = document.createElement('canvas');
    canvas.width = res[0];
    canvas.height = res[1];
    const resized = await picaClient.resize(image, canvas, {
      unsharpAmount: 80,
      unsharpRadius: 0.6,
      unsharpThreshold: 2,
    });
    canvas.remove();
    return await picaClient.toBlob(resized, 'image/jpeg', 0.9);
  }

  #prepareImage(file: File): Promise<HTMLImageElement> {
    const image = document.createElement('img');
    image.width = 200;
    image.height = 200;
    const process: Promise<HTMLImageElement> = new Promise((s, f) => {
      image.onload = () => s(image);
      image.onerror = f;
    });
    const url = URL.createObjectURL(file);
    image.src = url;
    return process;
  }

  #removeImage(image: HTMLImageElement) {
    URL.revokeObjectURL(image.src);
    image.remove();
  }

  public async resizeFromFile(file: File, opt: { exportTo?: string; resize?: [number, number] } = {}): Promise<Blob> {
    return this.#ngZone.runOutsideAngular(() => this.#resizeFromFile(file, opt));
  }

  async #resizeFromFile(file: File, opt: { exportTo?: string; resize?: [number, number] } = {}): Promise<Blob> {
    opt.resize ||= defaultThumbOption.resize;
    const image = await this.#prepareImage(file);
    const res = fitInside([image.naturalWidth, image.naturalHeight], opt.resize);
    if (!res) {
      this.#removeImage(image);
      return file;
    }
    const picaClient = this.#picaClient;
    const canvas = document.createElement('canvas');
    canvas.width = res[0];
    canvas.height = res[1];
    const resized = await picaClient.resize(image, canvas, {
      unsharpAmount: 80,
      unsharpRadius: 0.6,
      unsharpThreshold: 2,
    });
    const blob = await picaClient.toBlob(resized, opt.exportTo || 'image/jpeg', 0.9);
    canvas.remove();
    this.#removeImage(image);
    return blob;
  }

  public async resizeForUpload(file: File, options: UploadThumbOption = {}) {
    if (file.size > (options.maxSize || defaultThumbOption.maxSize)) {
      this.#snackBarService.warn('Image should be less then 4MB');
      return;
    }
    if (!(options.type || defaultThumbOption.type).test(file.type)) {
      this.#snackBarService.warn('Image formats invalids should be png/jpeg');
      return;
    }
    options.resize ||= defaultThumbOption.resize;
    return this.#ngZone.runOutsideAngular(() => this.#resizeFromFile(file, options));
  }
}

Usage

import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, inject } from '@angular/core';
import { FilePickerService } from '../../services/file-picker.service';
import { ImageService } from '../../services/image.service';

@Component({
  selector: 'app-test',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="grow flex flex-col">
      <button (click)="previewImage()">Preview</button>
      <img *ngIf="preview" [src]="preview" />
    </div>
  `,
})
export class TestComponent implements OnDestroy {
  readonly #cdRef = inject(ChangeDetectorRef);
  readonly #imageService = inject(ImageService);
  readonly #filePickerService = inject(FilePickerService);
  public preview: string;

  async previewImage() {
    const [file] = await this.#filePickerService.pick({
      accept: 'image/jpeg,image/png',
    });
    if (!file) return; // user canceled
    const imageBlob = await this.#imageService.resizeForUpload(file);
    /*
    // or with custom resize
    const imageBlob = await this.#imageService.resizeForUpload(file, {resize: [200, 200]});
    // or with custom resize and export type
    const imageBlob = await this.#imageService.resizeForUpload(file, {resize: [200, 200], exportTo: 'image/png'});
    */
    if (this.preview?.startsWith('blob:')) URL.revokeObjectURL(this.preview);
    this.preview = URL.createObjectURL(imageBlob);
    this.#cdRef.detectChanges();
  }

  ngOnDestroy(): void {
    if (this.preview?.startsWith('blob:')) URL.revokeObjectURL(this.preview);
  }
}

Explanation

  • ImageService is a service that can be used to resize images.
  • FilePickerService is a service that can be used to pick files from the user.
  • previewImage is a method that is called when the user clicks on the preview button.
  • this.#filePickerService.pick is used to pick a file from the user.
  • this.#imageService.resizeForUpload is used to resize the image.
  • this.#cdRef.detectChanges() is used to detect changes in the component.