Please support me with,
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);
}
}