@ngx-dropzone/cdk
Version:
CDK with basic dropzone functionality.
625 lines (613 loc) • 25.7 kB
JavaScript
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