UNPKG

@ngx-dropzone/cdk

Version:
625 lines (613 loc) 25.7 kB
import * as i0 from '@angular/core'; import { Injectable, EventEmitter, HostListener, Output, Input, HostBinding, Optional, Self, Directive, inject, ChangeDetectorRef, ContentChild, ChangeDetectionStrategy, ViewEncapsulation, Component } from '@angular/core'; import { Subject, BehaviorSubject, tap, takeUntil } from 'rxjs'; import * as i2 from '@angular/forms'; /** Inspired by the Angular Material library, we check our input properties. */ function coerceBoolean(value) { return ['', '1', 'true'].includes(`${value}`); } /** Allows filtering `null` and `undefined` elements from arrays. */ function nonNullable(value) { return value !== null && value !== undefined; } class AcceptService { /** * Returns `true` if all files match the provided `accept` parameter. * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept) * for more information. */ accepts(fileValue, accept) { if (!fileValue || accept === '*') { return true; } const acceptedMimeTypes = this.parseAttribute(accept, (t) => this.isValidMimeType(t)); const acceptedExtensions = this.parseAttribute(accept, (t) => this.isValidExtension(t)); const fileList = Array.isArray(fileValue) ? fileValue : [fileValue]; return fileList.every((file) => this.isAcceptedByExtension(file, acceptedExtensions) || this.isAcceptedByMimeType(file, acceptedMimeTypes)); } isAcceptedByExtension(file, acceptedExtensions) { return acceptedExtensions.some((ext) => file.name.toLowerCase().endsWith(ext)); } isAcceptedByMimeType(file, acceptedMimeTypes) { return acceptedMimeTypes.some((type) => { if (type === file.type) { return true; } const [media, sub] = type.split('/', 2); const fileMedia = file.type.split('/')[0]; return sub === '*' && media === fileMedia; }); } parseAttribute(accept, predicate) { if (!accept?.length) { return []; } return accept.split(',').reduce((types, type) => { const trimmedType = type.trim(); if (trimmedType.length && predicate(trimmedType)) { types.push(trimmedType.toLowerCase()); } return types; }, []); } isValidMimeType(type) { const safeType = type || ''; const slashPos = safeType.indexOf('/'); if (slashPos <= 0 || slashPos === safeType.length - 1) { return false; } return Array.from(safeType).every((char, i) => this.isValidToken(char) || i === slashPos); } isValidExtension(type) { const safeType = type || ''; return safeType.length >= 2 && safeType[0] === '.'; } isValidToken(char) { const invalidChars = Array.from('" (),/:@[]{}'); return this.isAscii(char) && !invalidChars.includes(char); } isAscii(char) { return (char || '').charCodeAt(0) < 127; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: AcceptService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: AcceptService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: AcceptService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class FileInputValidators { /** * Checks if the file size is equal or greater than the minimum size. * Validates every file within array or single file. Returns no error when null. */ static minSize(min) { return (control) => { const valid = this.validate(control.value, (file) => file.size >= min); return valid ? null : { minSize: { value: control.value } }; }; } /** * Checks if the file size is equal or smaller than the allowed size. * Validates every file within array or single file. Returns no error when null. */ static maxSize(max) { return (control) => { const valid = this.validate(control.value, (file) => file.size <= max); return valid ? null : { maxSize: { value: control.value } }; }; } /** Checks if all provided files match the specified `accept` value. */ static accept(accept) { return (control) => { const allAccepted = new AcceptService().accepts(control.value, accept); return allAccepted ? null : { accept: { value: control.value } }; }; } static validate(value, predicate) { if (!value) { return true; } if (Array.isArray(value)) { return value.every((f) => f && predicate(f)); } return predicate(value); } } /** * Returns an exception to be thrown when attempting to add the `FileInputDirective` * to an HTML `<input>` element with a type other than "file". */ function getInputTypeError() { return Error('The [fileInput] directive may only be applied to `<input type="file" />` elements.'); } /** * Returns an exception to be thrown when attempting to assign an array value * to a file input element without the `multiple` attribute. */ function getArrayValueError() { return Error('Value must not be an array when the multiple attribute is not present.'); } /** * Returns an exception to be thrown when attempting to assign a non-array value * to a file input element in `multiple` mode. Note that `undefined` and `null` are * valid values to allow for resetting the value. */ function getNonArrayValueError() { return Error('Value must be an array when the multiple attribute is present.'); } class FileInputDirective { _acceptService; elementRef; ngControl; _value = null; _parent = null; _focused = false; _touched = false; _errorState = false; _onChange = null; _onTouched = null; /** Emits whenever the parent dropzone should re-render. */ stateChanges = new Subject(); /** The value of the file input control. */ get _fileValue() { return this.value; } set _fileValue(newValue) { /** * We may not use the property name `value` for the setter * because it already exists on the native input element * and would break our tests. */ if (newValue !== this._value || Array.isArray(newValue)) { this._assertMultipleValue(newValue); this._value = newValue; this._updateErrorState(); this._onTouched?.(); this._touched = true; this.stateChanges.next(); } } /** Returns the selected value of the file input control (alias as syntactic sugar). */ get value() { return this._value; } /** Returns true if the file input has no selected item. */ get empty() { return this.value === null || (Array.isArray(this.value) && !this.value.length); } /** Returns the error state. */ get errorState() { return this._errorState; } /** Returns true if the file input element is focused. */ get focused() { return this._focused; } /** Returns true if the `multiple` attribute is present on the input element. */ get multiple() { return this.elementRef.nativeElement.multiple; } /** Controls the accepted file types. */ get accept() { return this._accept; } set accept(value) { this._accept = value; this._updateErrorState(); this.stateChanges.next(); } _accept = '*'; /** Controls the value setting strategy. */ get mode() { return this._mode; } set mode(value) { this._mode = value; this.stateChanges.next(); } _mode = 'replace'; /** The disabled state of the file input control. */ get disabled() { return this.ngControl?.disabled || this._parent?.disabled || this.elementRef.nativeElement.disabled; } set disabled(value) { this.elementRef.nativeElement.disabled = coerceBoolean(value); if (this.focused) { this._focused = false; } this.stateChanges.next(); } /** Event emitted when the selected files have been changed by the user. */ selectionChange = new EventEmitter(); constructor(_acceptService, elementRef, _parentForm, _parentFormGroup, ngControl) { this._acceptService = _acceptService; this.elementRef = elementRef; this.ngControl = ngControl; this._parent = _parentForm || _parentFormGroup; if (ngControl != null) { // Setting the value accessor directly (instead of using // the providers) to allow access to error state. ngControl.valueAccessor = this; } } ngOnInit() { if (this.elementRef.nativeElement.type !== 'file') { throw getInputTypeError(); } } ngOnChanges() { this.stateChanges.next(); } ngDoCheck() { this._updateErrorState(); } ngOnDestroy() { this.stateChanges.complete(); } /** Opens the native OS file picker. */ openFilePicker() { this.elementRef.nativeElement.click(); } /** Handles the native (change) event. */ _handleChange(fileList) { if (this.disabled) return; const files = this.multiple ? Array.from(fileList) : fileList.item(0); const filesWithPaths = this._copyRelativePaths(files); this._fileValue = this._appendOrReplace(filesWithPaths); this.selectionChange.emit(this._fileValue); this._onChange?.(this._fileValue); // Reset the native element for another selection. this.elementRef.nativeElement.value = ''; } /** Handles the drop of a file array. */ handleFileDrop(files) { if (this.disabled) return; this._fileValue = this._appendOrReplace(this.multiple ? files : files[0]); this.selectionChange.emit(this._fileValue); this._onChange?.(this._fileValue); } /** Sets the selected files value as required by the `ControlValueAccessor` interface. */ writeValue(value) { this._fileValue = value; this.selectionChange.emit(this._fileValue); } /** Registers the change handler as required by `ControlValueAccessor`. */ registerOnChange(fn) { this._onChange = fn; } /** Registers the touched handler as required by `ControlValueAccessor`. */ registerOnTouched(fn) { this._onTouched = fn; } /** Implements the disabled state setter from `ControlValueAccessor`. */ setDisabledState(disabled) { this.disabled = disabled; } /** Called when the input element is focused or blurred. */ _focusChanged(focused) { if (this._focused !== focused) { this._focused = focused; this.stateChanges.next(); } } /** * On directory drops, the readonly `webkitRelativePath` property is not available. * We manually set the `relativePath` property for dropped file trees instead. * To achieve a consistent behavior when using the file picker, we copy the value. */ _copyRelativePaths(value) { if (!value) return value; if (Array.isArray(value)) { return value.map((file) => { file.relativePath = file.webkitRelativePath; return file; }); } value.relativePath = value.webkitRelativePath; return value; } /** Asserts that the provided value type matches the input element's multiple attribute. */ _assertMultipleValue(value) { if (this.multiple && !Array.isArray(value || [])) { throw getNonArrayValueError(); } if (!this.multiple && Array.isArray(value)) { throw getArrayValueError(); } } _appendOrReplace(value) { if (this._canAppend(this._value)) { const valueArray = Array.isArray(value) ? value : [value]; return [...this._value, ...valueArray.filter(nonNullable)]; } return value; } _canAppend(value) { return this._mode === 'append' && this.multiple && Array.isArray(value); } _updateErrorState() { // Check for any errors of the FormControl or NgModel. const { invalid, touched } = this.ngControl?.control ?? {}; const reactiveError = !!(invalid && (touched || this._parent?.submitted)); // Check for any errors directly on the native input element. const nativeError = this._touched && !this._acceptService.accepts(this.value, this._accept); const errorState = reactiveError || nativeError; if (this._errorState !== errorState) { this._errorState = errorState; this.stateChanges.next(); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: FileInputDirective, deps: [{ token: AcceptService }, { token: i0.ElementRef }, { token: i2.NgForm, optional: true }, { token: i2.FormGroupDirective, optional: true }, { token: i2.NgControl, optional: true, self: true }], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "19.2.1", type: FileInputDirective, isStandalone: true, selector: "input[fileInput]", inputs: { _fileValue: ["value", "_fileValue"], accept: "accept", mode: "mode", disabled: "disabled" }, outputs: { selectionChange: "selectionChange" }, host: { listeners: { "focus": "_focusChanged(true)", "blur": "_focusChanged(false)", "change": "_handleChange($event.target.files)" }, properties: { "accept": "this.accept", "disabled": "this.disabled" }, styleAttribute: "display: none" }, exportAs: ["fileInput"], usesOnChanges: true, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: FileInputDirective, decorators: [{ type: Directive, args: [{ selector: 'input[fileInput]', exportAs: 'fileInput', host: { style: 'display: none', '(focus)': '_focusChanged(true)', '(blur)': '_focusChanged(false)', }, }] }], ctorParameters: () => [{ type: AcceptService }, { type: i0.ElementRef }, { type: i2.NgForm, decorators: [{ type: Optional }] }, { type: i2.FormGroupDirective, decorators: [{ type: Optional }] }, { type: i2.NgControl, decorators: [{ type: Optional }, { type: Self }] }], propDecorators: { _fileValue: [{ type: Input, args: ['value'] }], accept: [{ type: Input }, { type: HostBinding, args: ['accept'] }], mode: [{ type: Input }], disabled: [{ type: Input }, { type: HostBinding, args: ['disabled'] }], selectionChange: [{ type: Output }], _handleChange: [{ type: HostListener, args: ['change', ['$event.target.files']] }] } }); /** * Returns an exception to be thrown when creating a dropzone * without a FileInputDirective child. */ function getMissingControlError() { return Error('The `ngx-dropzone` component requires a child of `<input type="file" fileInput />`.'); } class DropzoneService { /** * Returns a `File[]` from a `DragEvent`. Accepts a list of files or folders. */ async getFiles(event) { if (!event.dataTransfer?.items) { // Fallback for older specifications return Array.from(event.dataTransfer?.files ?? []); } const fsEntries = Array.from(event.dataTransfer?.items ?? []) .map((item) => this._toFileSystemEntry(item)) .map((entry) => this._getFilesFromEntry(entry)) .filter(nonNullable); const files = await Promise.all(fsEntries); return this._flattenFiles(files); } _toFileSystemEntry(item) { // In the future, we can use the `getAsEntry` method when it becomes available. if ('getAsEntry' in item && typeof item.getAsEntry === 'function') { return item.getAsEntry(); } // If supported, use the `webkitGetAsEntry` method to allow folder drops. // As a fallback, use the well-supported `getAsFile` method. return item.webkitGetAsEntry() || item.getAsFile(); } async _getFilesFromEntry(entry) { if (!entry) return []; if (entry instanceof File) { return [entry]; } if (this._isFile(entry)) { const file = await this._readFilePromise(entry); // Manually set the `relativePath` property for dropped files. file.relativePath = entry.fullPath?.slice(1) ?? ''; return [file]; } if (this._isDirectory(entry)) { const entries = await this._readDirectoryWithoutLimit(entry); const children = entries.map((e) => this._getFilesFromEntry(e)); const files = await Promise.all(children); return this._flattenFiles(files); } return []; } /** * In Chrome >= 77, the `readEntries` method returns only 100 files. * To achieve a consistent behavior across browsers and not restrict user interaction, * we break the limit by recursively calling `readEntries`. */ async _readDirectoryWithoutLimit(entry) { const reader = entry.createReader(); let entries = []; const readEntries = async () => { const children = await this._readDirectoryPromise(reader); if (children.length) { entries = entries.concat(children); await readEntries(); } }; await readEntries(); return entries; } _isFile = (item) => item.isFile; _isDirectory = (item) => item.isDirectory; _flattenFiles = (files) => [].concat(...files); _readFilePromise(entry) { return new Promise((resolve) => { entry.file((file) => resolve(file)); }); } _readDirectoryPromise(reader) { return new Promise((resolve) => { reader.readEntries((entries) => resolve(entries)); }); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: DropzoneService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: DropzoneService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: DropzoneService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }] }); class DropzoneComponent { _destroy$ = new Subject(); _changeDetectorRef = inject(ChangeDetectorRef); _dropzoneService = inject(DropzoneService); fileInputDirective = null; dragover$ = new BehaviorSubject(false); get isDragover() { return this.dragover$.value; } get disabled() { return this.fileInputDirective?.disabled || false; } get focused() { return this.fileInputDirective?.focused || this.isDragover; } get errorState() { return this.fileInputDirective?.errorState || false; } get value() { return this.fileInputDirective?.value || null; } set value(newValue) { if (this.fileInputDirective) { this.fileInputDirective._fileValue = newValue; } } ngAfterContentInit() { if (!this.fileInputDirective) { throw getMissingControlError(); } // Forward state changes from the child input element. this.fileInputDirective.stateChanges .pipe(tap(() => this._changeDetectorRef.markForCheck()), takeUntil(this._destroy$)) .subscribe(); } ngOnDestroy() { this._destroy$.next(); this._destroy$.complete(); } /** Opens the native OS file picker. */ openFilePicker() { if (!this.disabled && this.fileInputDirective) { this.fileInputDirective.openFilePicker(); } } /** Forwards styling property from control to host element. */ _forwardProp(prop) { return !!this.fileInputDirective?.ngControl?.[prop]; } _onDragOver = (event) => { event?.preventDefault(); }; _onDragEnter = (event) => { event?.preventDefault(); this.dragover$.next(true); // Indicate to the Browser that files will be copied. if (event?.dataTransfer) { event.dataTransfer.dropEffect = 'copy'; } }; _onDragLeave = (event) => { event?.preventDefault(); this.dragover$.next(false); }; _onDrop = async (event) => { this._onDragLeave(event); const files = await this._dropzoneService.getFiles(event); this.fileInputDirective?.handleFileDrop(files); }; static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: DropzoneComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.2.1", type: DropzoneComponent, isStandalone: true, selector: "ngx-dropzone", inputs: { value: "value" }, host: { attributes: { "tabindex": "0" }, listeners: { "keydown.code.enter": "openFilePicker()", "dragover": "_onDragOver($event)", "dragenter": "_onDragEnter($event)", "dragleave": "_onDragLeave($event)", "drop": "_onDrop($event)" }, properties: { "class.ng-untouched": "_forwardProp(\"untouched\")", "class.ng-touched": "_forwardProp(\"touched\")", "class.ng-pristine": "_forwardProp(\"pristine\")", "class.ng-dirty": "_forwardProp(\"dirty\")", "class.ng-valid": "_forwardProp(\"valid\")", "class.ng-invalid": "_forwardProp(\"invalid\")", "class.dragover": "this.isDragover", "class.disabled": "this.disabled", "class.focused": "this.focused", "attr.aria-invalid": "this.errorState" } }, providers: [DropzoneService], queries: [{ propertyName: "fileInputDirective", first: true, predicate: FileInputDirective, descendants: true, static: true }], exportAs: ["dropzone"], ngImport: i0, template: `<ng-content></ng-content>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.1", ngImport: i0, type: DropzoneComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-dropzone', exportAs: 'dropzone', imports: [FileInputDirective], providers: [DropzoneService], template: `<ng-content></ng-content>`, host: { tabindex: '0', '[class.ng-untouched]': '_forwardProp("untouched")', '[class.ng-touched]': '_forwardProp("touched")', '[class.ng-pristine]': '_forwardProp("pristine")', '[class.ng-dirty]': '_forwardProp("dirty")', '[class.ng-valid]': '_forwardProp("valid")', '[class.ng-invalid]': '_forwardProp("invalid")', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }] }], propDecorators: { fileInputDirective: [{ type: ContentChild, args: [FileInputDirective, { static: true }] }], isDragover: [{ type: HostBinding, args: ['class.dragover'] }], disabled: [{ type: HostBinding, args: ['class.disabled'] }], focused: [{ type: HostBinding, args: ['class.focused'] }], errorState: [{ type: HostBinding, args: ['attr.aria-invalid'] }], value: [{ type: Input }], openFilePicker: [{ type: HostListener, args: ['keydown.code.enter'] }], _onDragOver: [{ type: HostListener, args: ['dragover', ['$event']] }], _onDragEnter: [{ type: HostListener, args: ['dragenter', ['$event']] }], _onDragLeave: [{ type: HostListener, args: ['dragleave', ['$event']] }], _onDrop: [{ type: HostListener, args: ['drop', ['$event']] }] } }); /* * Public API Surface of cdk */ /** * Generated bundle index. Do not edit. */ export { AcceptService, DropzoneComponent, DropzoneService, FileInputDirective, FileInputValidators, coerceBoolean, nonNullable }; //# sourceMappingURL=ngx-dropzone-cdk.mjs.map