angularasynchronousauto-updateimage-compressionngzone

Angular 8 - Resizing image on the front-end change-detection issue


I am trying to build an image compressor service from a couple of tutorials I found online . The service itself works as expected , it receives an image as File , then it compresses it and returns an Observable . All works great , except that I want to use the compressed image in my component , before uploading it to the server .

The component doesn't detect when a new compressed image has arrived through the async pipe. If I subscribe manually to the Observable , I get the image data as expected , but if I try to update a component property with it , it doesn't immediately change the view , but rather changes it with the old 'image data' if I try to compress a new Image .

I found that this problem might occur if part of the code resolves outside the ngZone , so I found a workaround ( see below in the code ) with injecting ApplicationRef and using .tick() which actually works great , but makes my service hardly reusable .

My question is : Which part of the service code runs outside of ngZone and what are the possible fixes or workarounds so the service is reusable in other components , without having to inject ApplicationRef and .tick() everytime the service emits data .

Here is my service code :

 import { Observable ,  Subscriber } from 'rxjs';
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';

@Injectable({
  providedIn: 'root'
})

export class ImageCompressorService {

// globals
private _currentFile : File ;
private _currentImage : ICompressedImage = {} ;

// Constructor
constructor( private sanitizer : DomSanitizer) {}

// FileReader Onload callback
readerOnload(observer : Subscriber<ICompressedImage>)  {
 return (progressEvent : ProgressEvent) => {
  const img = new Image();
  img.src = (progressEvent.target as any).result;
  img.onload = this.imageOnload(img , observer);
}
}

// Image Onload callback
 imageOnload(image : HTMLImageElement , observer : Subscriber<ICompressedImage>) {
  return () => {
  const canvas = document.createElement('canvas');
  canvas.width = 100;
  canvas.height = 100;
  const context = <CanvasRenderingContext2D>canvas.getContext('2d');
  context.drawImage(image , 0 , 0 , 100 , 100);
  this.toICompressedImage(context , observer);
}}

// Emit CompressedImage
toICompressedImage(context : CanvasRenderingContext2D , observer : Subscriber<ICompressedImage> ) {
  context.canvas.toBlob(
    (blob) => {
      this._currentImage.blob = blob ;
      this._currentImage.image = new File([blob] , this._currentFile.name , {type : 'image/jpeg', lastModified : Date.now()} );
      this._currentImage.imgUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
      this._currentImage.name = this._currentFile.name ;
      observer.next(this._currentImage);
      observer.complete();
    } ,
    'image/jpeg' ,
    1
  );
}

//  Compress function
 compress(file : File) : Observable<ICompressedImage> {
   this._currentFile = file ;
   return new Observable<ICompressedImage>(
     observer => {
       const currentFile = file;
       const reader = new FileReader();
       reader.readAsDataURL(currentFile);
       reader.onload = this.readerOnload(observer);
     }
   );
 }
}

// Image Data Interface
export interface ICompressedImage {
  name? : string;
  image? : File ;
  blob? : Blob ;
  imgUrl? : SafeUrl ;
}

And this is my component.ts :

import { Component, OnInit, ApplicationRef } from '@angular/core';
import { ImageCompressorService, ICompressedImage } from 'src/app/shared/services/image-compressor.service';


@Component({
  selector: 'app-new-project',
  templateUrl: './new-project.component.html',
  styleUrls: ['./new-project.component.css']
})
export class NewProjectComponent implements OnInit  {
// globals
private selectedImage ;
compressedImage :  ICompressedImage = {name : 'No file selected'};


// Constructor
  constructor( private compressor : ImageCompressorService,
               private ar : ApplicationRef
             ) {}
// OnInit implementation
     ngOnInit(): void {}

// Compress method
  compress(fl : FileList) {
if (fl.length>0) {
    this.selectedImage = fl.item(0);
    this.compressor
    .compress(this.selectedImage)
    .subscribe(data => {
     this.compressedImage = data ;
     this.ar.tick();
    });
  } else {
    console.error('No file/s selected');

  }
  }


}

Here is my HTML template for the component :

<div style='border : 1px solid green;'>
    <input type='file' #SelectedFile (change)="compress($event.target.files)" accept='image/*' >
</div>


<div
style = 'border : 1px solid blue ; height : 200px;'
*ngIf="compressedImage " >
 <strong>File Name : </strong>{{ compressedImage?.name }}

<img *ngIf="compressedImage?.imgUrl as src"
[src]= 'src' >
</div>

The way I have showed my code , it works perfect . Try commenting out this.ar.tick(); in Compress Method of the component.ts file and see the change .


Solution

  • Adter a few hours of digging arround , I have found a working solution . I have injected the NgZone wrapper in my service . After that in my compress method I am running all the file handling code with zone.runOutsideAngular() , thus preventing ChangeDetection on purpose , and once the resizing operation is done and the new compressed image is available , I am running the next method of the observer (subscriber) with zone.Run() , which actually runs the code inside the Angular's zone , forcing ChangeDetection . I have tested manually subscribing to the resulting observable in my component , as well as subscribing via the async pipe. Both work like a charm . Posting the code using the async pipe .

    service.ts :

    import { Observable ,  Subscriber } from 'rxjs';
    import { Injectable, NgZone } from '@angular/core';
    import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
    
    @Injectable({
      providedIn: 'root'
    })
    
    export class ImageCompressorService {
    
    // globals
    private _currentFile : File ;
    private _currentImage : ICompressedImage = {} ;
    
    // Constructor
    constructor( private sanitizer : DomSanitizer , private _zone : NgZone) {
    
    }
    
    // FileReader Onload callback
    readerOnload(observer : Subscriber<ICompressedImage>)  {
     return (progressEvent : ProgressEvent) => {
      const img = new Image();
      img.src = (progressEvent.target as any).result;
      img.onload = this.imageOnload(img , observer);
    }
    }
    
    // Image Onload callback
     imageOnload(image : HTMLImageElement , observer : Subscriber<ICompressedImage>) {
      return () => {
      const canvas = document.createElement('canvas');
      canvas.width = 100;
      canvas.height = 100;
      const context = <CanvasRenderingContext2D>canvas.getContext('2d');
      context.drawImage(image , 0 , 0 , 100 , 100);
      this.toICompressedImage(context , observer);
    }}
    
    // Emit CompressedImage
    toICompressedImage(context : CanvasRenderingContext2D , observer : Subscriber<ICompressedImage> ) {
      context.canvas.toBlob(
        (blob) => {
          this._currentImage.blob = blob ;
          this._currentImage.image = new File([blob] , this._currentFile.name , {type : 'image/jpeg', lastModified : Date.now()} );
          this._currentImage.imgUrl = this.sanitizer.bypassSecurityTrustUrl(URL.createObjectURL(blob));
          this._currentImage.name = this._currentFile.name ;
          this._zone.run(() => {
            observer.next(this._currentImage);
            observer.complete();
          })
    
        } ,
        'image/jpeg' ,
        1
      );
    }
    
    //  Compress function
     compress(file : File) : Observable<ICompressedImage> {
       this._currentFile = file ;
       return new Observable<ICompressedImage>(
         observer => {
           this._zone.runOutsideAngular(() => {
            const currentFile = file;
    
            const reader = new FileReader();
            reader.readAsDataURL(currentFile);
            reader.onload = this.readerOnload(observer);
           })
    
         }
       );
     }
    }
    
    // Image Data Interface
    export interface ICompressedImage {
      name? : string;
      image? : File ;
      blob? : Blob ;
      imgUrl? : SafeUrl ;
    }
    

    component.ts :

    import { Component, OnInit } from '@angular/core';
    import { ImageCompressorService, ICompressedImage } from 'src/app/shared/services/image-compressor.service';
    import { Observable } from 'rxjs';
    
    
    @Component({
      selector: 'app-new-project',
      templateUrl: './new-project.component.html',
      styleUrls: ['./new-project.component.css']
    })
    export class NewProjectComponent implements OnInit  {
    // globals
    private selectedImage ;
    compressedImage : Observable<ICompressedImage>;
    
    // Constructor
      constructor( private compressor : ImageCompressorService) {}
    
    // OnInit implementation
         ngOnInit(): void {}
    
    // Compress method
      compress(fl : FileList) {
    if (fl.length>0) {
        this.selectedImage = fl.item(0);
      this.compressedImage =  this.compressor.compress(this.selectedImage)
      } else {
    
        console.error('No file/s selected');
    
      }
      }
    }
    

    component.html :

    <div style='border : 1px solid green;'>
        <input type='file' #SelectedFile (change)="compress($event.target.files)" accept='image/*' >
    </div>
    
    
    <div
    style = 'border : 1px solid blue ; height : 200px;'
    *ngIf="compressedImage | async as image" >
     <strong>File Name : </strong>{{ image.name }}
    
    <img *ngIf="image.imgUrl as src"
    [src]= 'src' >
    </div>