Cách tạo Drag and Drop directive để upload file trong Angular

Cách tạo Drag and Drop directive để upload file trong Angular

{tocify} $title={Table of Content}

Angular Drag and Drop File

Drag and Drop directive là một chỉ thị (directive) mà khi chúng ta thêm nó vào một phần tử, phần tử sẽ trở thành một vùng mà ta có thể dùng để kéo thả các file vào trong đó. Ví dụ, ta có một directive drag and drop file là appDropZone, ta sẽ sử dụng nó như sau:

HTML<div appDropZone (fileDropped)="onFileDropped($event)"></div>

Sau đó, bất kỳ file nào được đưa vào vùng này đều được trả về dưới dạng một mảng các file objecs trong Javascript. Trong TypeScriptArray<File>.

[Image]

Tạo appDropZone directive

Đầu tiên, ta dùng Angular CLI để tạo ra một file directive:

ng g directive drop-zone

Sau khi, lệnh trên được thực thi, ta sẽ có một file drop-zone.directive.ts như thế này:

drop-zone.directive.tsimport { Directive } from '@angular/core';

@Directive({
  selector: '[appDropZone]'
})
export class DropZoneDirective {

  constructor() { }

}

Trả về các file đã được thả vào appDropZone

Mục đích cuối cùng mà ta dùng appDropZone directive là để nhận được các file đã được thả vào. Do đó, ta cần tạo ra một event trả về danh sách các file đã được thả vào element dưới dạng một mảng Javascript các File object:

drop-zone.directive.ts@Output() fileDropped = new EventEmitter<Array<File>>();

Chặn hành vi mặc định của trình duyệt

Theo mặc định, khi chúng ta kéo và thả một file (hoặc một thư mục - folder) vào trình duyệt. Trình duyệt sẽ mở ra một tab mới và tải nội dung của file mà chúng vừa thả vào lên đó. Chúng ta dùng Event.preventDefault để ngăn chặn hành vì mặc định này. Do đó, chúng ta có thể tạo thêm một input đầu vào, để có thể chọn enable hoặc disable hành vi mặc định này của trình duyêt. Ở đây, chúng ta sẽ tạo ra một Input và mặc định là sẽ disable hành vi mặc định của browser cho phần body bao ngoài vùng kéo thả của appDropZone directive:

drop-zone.directive.ts@Input() preventBodyDrop = true;

Và chúng ta sẽ xử lý như sau:

drop-zone.directive.ts  @HostListener('body:dragover', ['$event'])
  onBodyDragOver(event: DragEvent) {
    if (this.preventBodyDrop) {
      event.preventDefault();
      event.stopPropagation();
    }
  }
  @HostListener('body:drop', ['$event'])
  onBodyDrop(event: DragEvent) {
    if (this.preventBodyDrop) {
      event.preventDefault();
    }
  }

Trong đó:

  • event.preventDefault(); - Huỷ bỏ event mặc định nếu nó có thể huỷ mà không dừng sự lan rộng (propagation) của event tới phần khác.
  • event.stopPropagation(); - Ngăn chặn sự lan rộng của sự kiện hiện tại tới các element bao ngoài nó. Ví dụ, cả parent và child cùng có sự kiện click, nếu hàm xử lý sự kiện click của child sử dụng event.stopPropagation() thì hàm xử lý sự kiện của parent sẽ không được thực thi.

Tạo tín hiệu để thông báo file đang được kéo và giữ trên vùng appDropZone (chưa thả vào) - dragover và dragleave

Chúng ta sẽ binding một class dropzone-active vào element được gắn kèm appDropZone directive khi một file được kéo vào vùng drop của nó (kéo và giữ trên đó, chưa thả file ra nhé).
drop-zone.directive.ts@HostBinding('class.dropzone-active') active = false;

Chúng ta sẽ thêm class dropzone-active khi dragover và xóa nó đi khi dragleave hoặc drop:

drop-zone.directive.ts  //Dragover listener
  @HostListener('dragover', ['$event'])
  onDragOver(event: DragEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.active = true;
  }

  //Dragleave listener
  @HostListener('dragleave', ['$event'])
  onDragLeave(event: DragEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.active = false;
  }

Và chúng ta sẽ style cho class dropzone-active như sau:

styles.scss.dropzone-active {
  background-color: #9ecbec;
  opacity: 0.8;
  animation: shake 1s;
  animation-iteration-count: infinite;
}

/* Shake animation */
@keyframes shake {
  0% {
    transform: translate(1px, 1px) rotate(0deg);
  }

  10% {
    transform: translate(-1px, -2px) rotate(-1deg);
  }

  20% {
    transform: translate(-3px, 0px) rotate(1deg);
  }

  30% {
    transform: translate(3px, 2px) rotate(0deg);
  }

  40% {
    transform: translate(1px, -1px) rotate(1deg);
  }

  50% {
    transform: translate(-1px, 2px) rotate(-1deg);
  }

  60% {
    transform: translate(-3px, 1px) rotate(0deg);
  }

  70% {
    transform: translate(3px, 1px) rotate(-1deg);
  }

  80% {
    transform: translate(-1px, -1px) rotate(1deg);
  }

  90% {
    transform: translate(1px, 2px) rotate(0deg);
  }

  100% {
    transform: translate(1px, -2px) rotate(-1deg);
  }
}

Xử lý sự kiện drop khi file được vào appDropZone

Ở phần này, chúng ta sẽ thêm một @Input allowDirectory. allowDirectory có ý nghĩa rằng, có lấy file trong thư mục khi thả thư mục vào hay không. Đồng thời, chúng ta cũng cần một sự kiện để bắt lỗi nếu người dùng thả folder nếu allowDirectory = false.

drop-zone.directory.ts  // Allow reading files from folder
  @Input() allowDirectory: boolean = true;

  // Catch error when dropping a folder if allowDirectory is false
  @Output() onError = new EventEmitter<string>();

  • Code xử lý lấy tất cả các file được thả vào

drop-zone.directory.ts  private getFiles(dataTransfer: DataTransfer) {
    if (dataTransfer?.items.length) {
      const files: Array<File> = [];
      for (let i = 0; i < dataTransfer.items.length; i++) {
        // If dropped items aren't files, reject them
        if (!dataTransfer.items[i].webkitGetAsEntry().isDirectory && dataTransfer.items[i].kind === 'file') {
          const file = dataTransfer.items[i].getAsFile();
          file && file.type !== "" && files.push(file);
        } else {
          this.onError.emit("Invalid file type. Folder is not accepted!");
        }
      }
      // Clear any remaining drag data
      dataTransfer.items.clear();
      this.fileDropped.emit(files);
    } else {
      const files = dataTransfer?.files ?? [];
      dataTransfer?.clearData();
      this.fileDropped.emit(Array.from(files));
    }
  }

  • Code xử lý lấy tất cả các file trong thư mục đã thả vào

drop-zone.directory.tsconst getFilesAndDirectory = (dataTransferItems: any) => {
  function traverseFileTreePromise(item: any, path = '') {
    return new Promise(resolve => {
      if (item.isFile) {
        item.file((file: any) => {
          file.filepath = path + file.name //save full path
          files.push(file)
          resolve(file)
        })
      } else if (item.isDirectory) {
        let directoryReader = item.createReader()
        directoryReader.readEntries((entries: any) => {
          let entriesPromises = []
          for (let entr of entries)
            entriesPromises.push(traverseFileTreePromise(entr, path + item.name + "/"))
          resolve(Promise.all(entriesPromises))
        })
      }
    })
  }

  let files: any = []
  return new Promise((resolve, reject) => {
    let entriesPromises = []
    for (let it of dataTransferItems)
      // webkitGetAsEntry() returns a FileSystemFileEntry or FileSystemDirectoryEntry representing it.
      entriesPromises.push(traverseFileTreePromise(it.webkitGetAsEntry()))
    Promise.all(entriesPromises)
      .then(entries => {
        console.log(entries)
        resolve(files)
      })
  })
}

Full Code

drop-zone.directory.tsimport { Directive, EventEmitter, HostBinding, HostListener, Input, Output } from '@angular/core';

// Get Files from webkit DataTransferItem
const getFilesAndDirectory = (dataTransferItems: any) => {
  function traverseFileTreePromise(item: any, path = '') {
    return new Promise(resolve => {
      if (item.isFile) {
        item.file((file: any) => {
          file.filepath = path + file.name //save full path
          files.push(file)
          resolve(file)
        })
      } else if (item.isDirectory) {
        let directoryReader = item.createReader()
        directoryReader.readEntries((entries: any) => {
          let entriesPromises = []
          for (let entr of entries)
            entriesPromises.push(traverseFileTreePromise(entr, path + item.name + "/"))
          resolve(Promise.all(entriesPromises))
        })
      }
    })
  }

  let files: any = []
  return new Promise((resolve, reject) => {
    let entriesPromises = []
    for (let it of dataTransferItems)
      // webkitGetAsEntry() returns a FileSystemFileEntry or FileSystemDirectoryEntry representing it.
      entriesPromises.push(traverseFileTreePromise(it.webkitGetAsEntry()))
    Promise.all(entriesPromises)
      .then(entries => {
        console.log(entries)
        resolve(files)
      })
  })
}

@Directive({
  selector: '[appDropZone]'
})
export class DropZoneDirective {
  // The directive emits a `fileDrop` event
  // with the list of files dropped on the element
  // as an JS array of `File` objects.
  @Output() fileDropped = new EventEmitter<Array<File>>();

  // Disable dropping on the body of the document. 
  // This prevents the browser from loading the dropped files
  // using it's default behaviour if the user misses the drop zone.
  // Set this input to false if you want the browser default behaviour.
  @Input() preventBodyDrop = true;

  // The `drop-zone-active` class is applied to the host
  // element when a drag is currently over the target.
  @HostBinding('class.dropzone-active') active = false;

  // Allow reading files from folder
  @Input() allowDirectory: boolean = true;

  // Catch error when dropping a folder if allowDirectory is false
  @Output() onError = new EventEmitter<string>();

  constructor() { }

  private getFiles(dataTransfer: DataTransfer) {
    if (dataTransfer?.items.length) {
      const files: Array<File> = [];
      for (let i = 0; i < dataTransfer.items.length; i++) {
        // If dropped items aren't files, reject them
        if (!dataTransfer.items[i].webkitGetAsEntry().isDirectory && dataTransfer.items[i].kind === 'file') {
          const file = dataTransfer.items[i].getAsFile();
          file && file.type !== "" && files.push(file);
        } else {
          this.onError.emit("Invalid file type. Folder is not accepted!");
        }
      }
      // Clear any remaining drag data
      dataTransfer.items.clear();
      this.fileDropped.emit(files);
    } else {
      const files = dataTransfer?.files ?? [];
      dataTransfer?.clearData();
      this.fileDropped.emit(Array.from(files));
    }
  }

  //Dragover listener
  @HostListener('dragover', ['$event'])
  onDragOver(event: DragEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.active = true;
  }

  //Dragleave listener
  @HostListener('dragleave', ['$event'])
  onDragLeave(event: DragEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.active = false;
  }

  // Drop listener
  @HostListener('drop', ['$event'])
  onDrop(event: DragEvent) {
    event.preventDefault();
    event.stopPropagation();
    this.active = false;

    const dataTransfer = event.dataTransfer;

    if (this.allowDirectory) {
      getFilesAndDirectory(dataTransfer?.items)
        .then((files: any) => {
          this.fileDropped.emit(files);
        });
    } else {
      dataTransfer && this.getFiles(dataTransfer);
    }
  }

  // Prevent the browser default behaviour when dropping on the body of the document
  @HostListener('body:dragover', ['$event'])
  onBodyDragOver(event: DragEvent) {
    if (this.preventBodyDrop) {
      event.preventDefault();
      event.stopPropagation();
    }
  }
  @HostListener('body:drop', ['$event'])
  onBodyDrop(event: DragEvent) {
    if (this.preventBodyDrop) {
      event.preventDefault();
    }
  }
}
styles.scss.dropzone-active {
  background-color: #9ecbec;
  opacity: 0.8;
  animation: shake 1s;
  animation-iteration-count: infinite;
}

/* Shake animation */
@keyframes shake {
  0% {
    transform: translate(1px, 1px) rotate(0deg);
  }

  10% {
    transform: translate(-1px, -2px) rotate(-1deg);
  }

  20% {
    transform: translate(-3px, 0px) rotate(1deg);
  }

  30% {
    transform: translate(3px, 2px) rotate(0deg);
  }

  40% {
    transform: translate(1px, -1px) rotate(1deg);
  }

  50% {
    transform: translate(-1px, 2px) rotate(-1deg);
  }

  60% {
    transform: translate(-3px, 1px) rotate(0deg);
  }

  70% {
    transform: translate(3px, 1px) rotate(-1deg);
  }

  80% {
    transform: translate(-1px, -1px) rotate(1deg);
  }

  90% {
    transform: translate(1px, 2px) rotate(0deg);
  }

  100% {
    transform: translate(1px, -2px) rotate(-1deg);
  }
}

Live Demo

Reference

Đăng nhận xét

Mới hơn Cũ hơn