18 May, 2022

Angular file drag and drop with FileDropzoneDirective

Drag and drop to upload files
tweet
share

Please support me with,

Table Of Contents
# # # #

We often need to allow users to upload files to their browser. In this case, using the traditional HTML file input can be a way. But, if user convenience is considered, drag and drop is also possible. So, today I'll introduce the Angular FileDropzoneDirective.

How it Works

In order to be able to use it for various forms of dropzone, I made it as a Directive rather than a Component.

FileDropzoneDirective is associated with the file input. So it receives the file input with @Input() decorator and detects the change event of the file input. It also detects drop event that occur on it.

After detecting the change and drop event, the uploaded files are validated and success or error emitter is emitted according to validation result.

FileDropzoneDirective (Full source code)

/**
 * Mime types are consist of `${type}/${sub-type}`.
 * This object maps mime types of `accept` field.
 */
export interface MimeTypesMap {
  [type: string]: {
    [subtype: string]: boolean;
  }
}

/**
 * Neutralize the Event.
 * @param event Event.
 */
function neutralize(event: Event): void {
  event.stopPropagation();
  event.preventDefault();
}

@Directive({
  selector: '[appFileDropzone]'
})
export class FileDropzoneDirective implements OnDestroy {
  /**
   * Set acceptable file types.
   * Should be an array of mime types.
   */
  @Input() accept: string[] = [
    '*/*',
  ];

  /**
   * Emit uploaded files as an array.
   */
  @Output() success = new EventEmitter<File[]>();

  /**
   * Emit error message.
   */
  @Output() error = new EventEmitter<string>();

  /**
   * Dropzone activated state.
   */
  @HostBinding('class.active') active = false;

  /**
   * File input element.
   */
  private _input?: HTMLInputElement;

  constructor() {
  }

  ngOnDestroy(): void {
    this._removeInputChangeEvent();
  }

  /**
   * Get state of allowing multiple files.
   */
  get multi(): boolean {
    return this._input?.hasAttribute('multi') || false;
  }

  /**
   * Map the `accept` field with `type` and `subtype`.
   */
  get mimeTypesMap(): MimeTypesMap {
    const mimeTypesMap: MimeTypesMap = {};

    this.accept.forEach(mimeType => {
      const [type, subtype] = mimeType.split('/');

      if (!mimeTypesMap[type]) {
        mimeTypesMap[type] = {};
      }

      if (!mimeTypesMap[type][subtype]) {
        mimeTypesMap[type][subtype] = true;
      }
    });

    return mimeTypesMap;
  }

  /**
   * Set file input element.
   * If the `input` is an instance of `ElementRef`, extract `nativeElement` from it.
   * @param input Input element.
   */
  @Input() set input(input: HTMLInputElement | ElementRef<HTMLInputElement>) {
    this._removeInputChangeEvent();

    if (input instanceof ElementRef) {
      this._input = input.nativeElement;
    } else {
      this._input = input;
    }

    this._addInputChangeListener();
  }

  @HostListener('window:dragover', ['$event'])
  onWindowDragover(event: DragEvent): void {
    neutralize(event);
  }

  @HostListener('window:drop', ['$event'])
  onWindowDrop(event: DragEvent): void {
    neutralize(event);
  }

  @HostListener('dragover')
  onHostDragOver(): void {
    this.active = true;
  }

  @HostListener('dragleave')
  onHostDragLeave(): void {
    this.active = false;
  }

  @HostListener('dragend')
  onHostDragEnd(): void {
    this.active = false;
  }

  /**
   * Handle `drop` on this dropzone.
   * @param event DragEvent.
   */
  @HostListener('drop', ['$event'])
  onHostDrop(event: DragEvent): void {
    this.active = false;

    if (event.dataTransfer?.files) {
      this._handleFiles(event.dataTransfer.files);
    }
  }

  /**
   * Add `change` event listener to file input.
   */
  private _addInputChangeListener(): void {
    if (this._input) {
      this._input.addEventListener('change', this._onInputChange);
    }
  }

  /**
   * Remove `change` event listener from file input.
   */
  private _removeInputChangeEvent(): void {
    if (this._input) {
      this._input.removeEventListener('change', this._onInputChange);
    }
  }

  /**
   * The `change` event listener for file input.
   */
  private _onInputChange = (): void => {
    if (this._input?.files) {
      this._handleFiles(this._input.files);

      // Remove the value of file input.
      // This is to allow uploading of the same file(s).
      this._input.value = '';
    }
  }

  /**
   * Handle both `host.drop` and `input.change` event.
   * Validate file and emit `success` or `error` emitter based on the validation result.
   * @param files Uploaded FileList.
   */
  private _handleFiles(files: FileList): void {
    try {
      this.success.emit(this._validateFiles(files));
    } catch (e) {
      this.error.emit((e as Error).message);
    }
  }

  /**
   * Validate the file length and types.
   * @param files Uploaded FileList.
   */
  private _validateFiles(files: FileList): File[] {
    const mimeTypesMap = this.mimeTypesMap;
    const validatedFiles: File[] = [];

    // Check whether multiple files can be uploaded or not.
    if (!this.multi && files.length > 1) {
      throw new Error('Multi Error');
    }

    for (let i = 0; i < files.length; i++) {
      const file = files.item(i) as File;

      const [type, subtype] = file.type.split('/');

      // Check Mimetype.
      // Since the first object can be `undefined`, `{}` is set as the default value of the first object.
      if (
        // If there is `*/*` in the `accept` field, all mimetypes are allowed to upload.
        // Other mimetypes are redundant if `*/*` is set in the `accept` field.
        (mimeTypesMap['*'] || {})['*']

        // Check `*` wildcard for specific type.
        // If the `accept` field is `['image/*', 'text/*'],
        // all images and texts are allowed to upload.
        || (mimeTypesMap[type] || {})['*']

        // Check specific type.
        // If the `accept` field is `['image/png', 'text/javascript']`,
        // only png images and javascript files are allowed to upload.
        || (mimeTypesMap[type] || {})[subtype]
      ) {
        validatedFiles.push(file);
      } else {
        throw new Error('Mimetype Error');
      }
    }

    return validatedFiles;
  }
}

Code Review

@Directive({
  selector: '[appFileDropzone]'
})
export class FileDropzoneDirective implements OnDestroy {
  // ...
  
  /**
   * Set file input element.
   * If the `input` is an instance of `ElementRef`, extract `nativeElement` from it.
   * @param input Input element.
   */
  @Input() set input(input: HTMLInputElement | ElementRef<HTMLInputElement>) {
    this._removeInputChangeEvent();

    if (input instanceof ElementRef) {
      this._input = input.nativeElement;
    } else {
      this._input = input;
    }

    this._addInputChangeListener();
  }
  
  // ...
}

The first flow is to set the HTMLInputElement using the input setter. Before HTMLInputElement is updated, the _removeInputChangeEvent() method is called to remove the change listener bound to the existing HTMLInputElement if it exists.

After updating the HTMLInputElement, the _addInputChangeListener() method is called to add a change listener to the new HTMLInputElement.

If files are uploaded by HTMLInputElement, the _onInputChange() method is called. This method calls the _handleFiles() method which validates the uploaded files and emits success or error emitter depending on the result.

@Directive({
  selector: '[appFileDropzone]'
})
export class FileDropzoneDirective implements OnDestroy {
  // ...
  
  /**
   * Validate the file length and types.
   * @param files Uploaded FileList.
   */
  private _validateFiles(files: FileList): File[] {
    const mimeTypesMap = this.mimeTypesMap;
    const validatedFiles: File[] = [];

    // Check whether multiple files can be uploaded or not.
    if (!this.multi && files.length > 1) {
      throw new Error('Multi Error');
    }

    for (let i = 0; i < files.length; i++) {
      const file = files.item(i) as File;

      const [type, subtype] = file.type.split('/');

      // Check Mimetype.
      // Since the first object can be `undefined`, `{}` is set as the default value of the first object.
      if (
        // If there is `*/*` in the `accept` field, all mimetypes are allowed to upload.
        // Other mimetypes are redundant if `*/*` is set in the `accept` field.
        (mimeTypesMap['*'] || {})['*']

        // Check `*` wildcard for specific type.
        // If the `accept` field is `['image/*', 'text/*'],
        // all images and texts are allowed to upload.
        || (mimeTypesMap[type] || {})['*']

        // Check specific type.
        // If the `accept` field is `['image/png', 'text/javascript']`,
        // only png images and javascript files are allowed to upload.
        || (mimeTypesMap[type] || {})[subtype]
      ) {
        validatedFiles.push(file);
      } else {
        throw new Error('Mimetype Error');
      }
    }

    return validatedFiles;
  }
}

File validating is done by the _validateFiles() method. It first validates the number of uploaded files according to whether the HTMLInputElement allows multiple files.

If the file count validation passes, the next step is to validate the mimetype of each file. The accept field is used here.

The accept field is an array of allowed mimetypes. The default value is ['*/*'], which means that all file types are allowed. If the value of the accept field is ['image/*', 'text/javascript'], then all image files will be accepted and only text files in JavaScript type will be accepted.

Files that have passed mimetype validation are returned as the result of the method.

@Directive({
  selector: '[appFileDropzone]'
})
export class FileDropzoneDirective implements OnDestroy {
  // ...
  
  /**
   * Handle `drop` on this dropzone.
   * @param event DragEvent.
   */
  @HostListener('drop', ['$event'])
  onHostDrop(event: DragEvent): void {
    this.active = false;

    if (event.dataTransfer?.files) {
      this._handleFiles(event.dataTransfer.files);
    }
  }
  
  // ...

  /**
   * The `change` event listener for file input.
   */
  private _onInputChange = (): void => {
    if (this._input?.files) {
      this._handleFiles(this._input.files);

      // Remove the value of file input.
      // This is to allow uploading of the same file(s).
      this._input.value = '';
    }
  }
  
  // ...
}

The key methods to detect the change event of HTMLInputElement and the drop event of dropzone are _onInputChange() and onHostDrop().

Both methods eventually go through the process of validation and emitting by calling the _handleFiles() method.

@Directive({
  selector: '[appFileDropzone]'
})
export class FileDropzoneDirective implements OnDestroy {
  // ...

  @HostListener('window:dragover', ['$event'])
  onWindowDragover(event: DragEvent): void {
    neutralize(event);
  }

  @HostListener('window:drop', ['$event'])
  onWindowDrop(event: DragEvent): void {
    neutralize(event);
  }
  
  // ...
}

This is the part you must remember to create a dropzone. I have neutralized the dragover and drop events of window. Without this part, dropzone will not work properly.

Usage

<div
  (error)="onError($event)"
  (success)="onSuccess($event)"
  [accept]="['image/*']"
  [input]="input"
  appFileDropzone
  class="dropzone"></div>

<input
  #input
  type="file"/>
.dropzone {
  width: 300px;
  height: 300px;
  border: 1px dashed #ccc;

  &.active {
    border: 1px solid #6F86C5;
    outline: 1px solid #6F86C5;
  }
}
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  onSuccess(files: File[]): void {
    console.log('Uploaded:', files);
  }

  onError(error: string): void {
    console.log('Error:', error);
  }
}
Tags
Copyright 2022, tk2rush90, All rights reserved