import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
ViewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { IonItem, IonLabel, IonList, IonButton, IonText } from '@ionic/angular/standalone';
import { TranslatePipe } from '@ngx-translate/core';
import { ComponentEventNames, ElementSizes, HTML5InputTypes } from '@decaf-ts/ui-decorators';
import { Constructor } from '@decaf-ts/decoration';
import { Primitives } from '@decaf-ts/decorator-validation';
import { Dynamic } from '../../engine/decorators';
import { NgxFormFieldDirective } from '../../engine/NgxFormFieldDirective';
import { ElementSize, FlexPosition, KeyValue, PossibleInputTypes } from '../../engine/types';
import { IBaseCustomEvent, IFileUploadError } from '../../engine/interfaces';
import { presentNgxInlineModal, presentNgxLightBoxModal } from '../modal/modal.component';
import { CardComponent } from '../card/card.component';
import { IconComponent } from '../icon/icon.component';
import { NgxEventHandler } from '../../engine/NgxEventHandler';
const FileErrors = {
notAllowed: 'not_allowed',
maxSize: 'max_size',
} as const;
/**
* @description File upload component for Angular applications.
* @summary This component provides a user interface for uploading files with support for drag-and-drop,
* file validation, and preview functionality. It integrates seamlessly with Angular reactive forms
* and supports multiple file uploads, directory mode, and custom file size limits.
*
* @class FileUploadComponent
* @example
* ```typescript
* <ngx-decaf-file-upload [formGroup]="formGroup" [name]="'fileInput'" [multiple]="true"></ngx-decaf-file-upload>
* ```
* @mermaid
* sequenceDiagram
* participant User
* participant FileUploadComponent
* User->>FileUploadComponent: Select or drag files
* FileUploadComponent->>FileUploadComponent: Validate files
* FileUploadComponent->>FileUploadComponent: Emit change event
* User->>FileUploadComponent: Remove file
* FileUploadComponent->>FileUploadComponent: Update file list
*/
@Dynamic()
@Component({
selector: 'ngx-decaf-file-upload',
templateUrl: './file-upload.component.html',
styleUrls: ['./file-upload.component.scss'],
standalone: true,
imports: [
CommonModule,
ReactiveFormsModule,
CardComponent,
IonText,
IconComponent,
IonList,
IonLabel,
IonItem,
TranslatePipe,
IonButton,
],
})
export class FileUploadComponent extends NgxFormFieldDirective implements OnInit, OnDestroy {
@ViewChild('component', { static: true })
override component!: ElementRef<HTMLInputElement>;
/**
* @description Parent form group.
* @summary References the parent form group to which this field belongs.
* This is necessary for integrating the field with Angular's reactive forms.
*
* @type {FormGroup}
*/
@Input()
override formGroup: FormGroup | undefined;
/**
* @summary The flat field name used as the form control identifier in immediate parent FormGroup.
* @description Specifies the name of the field, which is used as the FormControl identifier in immediate parent FormGroup.
* This value must be unique within the immediate parent FormGroup context and should not contain dots or nesting.
*
* @type {string}
*/
@Input()
override name!: string;
/**
* @description Angular FormControl instance for this field.
* @summary The specific FormControl instance that manages this field's state, validation,
* and value. This provides direct access to Angular's reactive forms functionality
* for this individual field within the broader form structure.
*
* @type {FormControl}
*/
@Input()
override formControl!: FormControl;
/**
* @description Whether the field is required.
* @summary When true, the field must have a value for the form to be valid.
* Required fields are typically marked with an indicator in the UI.
*
* @type {boolean}
*/
@Input()
override required?: boolean;
/**
* @description Allows multiple file selection.
* @summary When true, the user can select multiple files for upload.
*
* @type {boolean}
* @default false
*/
@Input()
override multiple: boolean = false;
/**
* @description Specifies the input type for the file upload field.
* @summary Defines the type of input element used for file uploads, such as file or directory.
*
* @type {PossibleInputTypes}
* @default HTML5InputTypes.FILE
*/
@Input()
override type: PossibleInputTypes = HTML5InputTypes.FILE;
/**
* @description Label for the upload button.
* @summary Specifies the text displayed on the file upload button.
*
* @type {string | undefined}
*/
@Input()
buttonLabel?: string;
/**
* @description Size of the file upload component.
* @summary Determines the visual size of the file upload component, such as large, small, or default.
*
* @type {Extract<ElementSize, 'large' | 'small' | 'default'>}
* @default ElementSizes.large
*/
@Input()
size: Extract<ElementSize, 'large' | 'small' | 'default'> = ElementSizes.large;
/**
* @description Flex positioning of the container's content.
* @summary Controls how child elements are positioned within the container when flex layout
* is enabled. Options include 'center', 'top', 'bottom', 'left', 'right', and combinations
* like 'top-left'. This property is only applied when the flex property is true.
*
* @type {FlexPosition}
* @default 'center'
*/
@Input()
position: FlexPosition = 'center';
/**
* @description Accepted file types for upload.
* @summary Specifies the file types that are allowed for upload, such as images or documents.
*
* @type {string | string[]}
* @default ['image/*']
*/
@Input()
accept: string | string[] = ['image/*'];
/**
* @description Whether to show an icon in the file upload field.
* @summary When true, an icon is displayed alongside the file upload input.
*
* @type {boolean}
* @default true
*/
@Input()
showIcon: boolean = true;
/**
* @description Enables directory mode for file uploads.
* @summary When true, the user can upload entire directories instead of individual files.
*
* @type {boolean}
* @default false
*/
@Input()
enableDirectoryMode: boolean = false;
@Input()
previewHandler?: unknown;
/**
* @description Maximum file size allowed for upload.
* @summary Specifies the maximum size (in MB) for files that can be uploaded.
*
* @type {number}
* @default 1
*/
@Input()
maxFileSize: number = 1;
/**
* @description Event emitted when the file upload field changes.
* @summary Emits an event containing details about the change in the file upload field.
*
* @type {EventEmitter<IBaseCustomEvent>}
*/
@Output()
changeEvent: EventEmitter<IBaseCustomEvent> = new EventEmitter<IBaseCustomEvent>();
/**
* @description Preview of the first file in the upload list.
* @summary Stores the data URL of the first file in the upload list for preview purposes.
* This is typically used to display a thumbnail or preview image.
*
* @type {string | undefined}
*/
previewFile: string | undefined = undefined;
/**
* @description List of files selected for upload.
* @summary Contains the files selected by the user for upload. This array is updated
* whenever files are added or removed from the upload list.
*
* @type {File[]}
*/
files: File[] | KeyValue[] = [];
/**
* @description List of errors encountered during file validation.
* @summary Stores validation errors for files that do not meet the specified criteria,
* such as file type or size restrictions. Each error includes the file name, size, and error message.
*
* @type {IFileUploadError[]}
*/
errors: IFileUploadError[] = [];
/**
* @description Indicates whether a drag operation is in progress.
* @summary This flag is set to true when a file is being dragged over the upload area.
* It is used to provide visual feedback to the user during drag-and-drop operations.
*
* @type {boolean}
* @default false
*/
dragging: boolean = false;
/**
* @description Counter for drag events.
* @summary Tracks the number of drag events to ensure proper handling of drag-and-drop
* operations. The counter is incremented on drag enter and decremented on drag leave.
*
* @type {number}
* @default 0
*/
private dragCounter: number = 0;
constructor() {
super('FileUploadComponent');
this.handleClear();
}
/**
* @description Lifecycle hook that is called after Angular has initialized all data-bound properties of a directive.
* @summary Sets up the component by enabling directory mode if specified, formatting the accepted file types,
* and converting the maximum file size from megabytes to bytes.
*
* @returns {Promise<void>}
*/
async ngOnInit(): Promise<void> {
if (this.enableDirectoryMode) {
this.multiple = true;
}
if (Array.isArray(this.accept)) {
this.accept = this.accept.join(',');
}
// Convert maxFileSize from MB to bytes
this.maxFileSize = this.maxFileSize * 1024 * 1024;
await this.initialize();
}
override async initialize(): Promise<void> {
await super.initialize();
if (this.value && typeof this.value === Primitives.STRING) {
try {
const files = JSON.parse(this.value as string) as string[];
this.files = files.map((file) => {
const mime = this.getFileMime(file)?.split('/') || [];
const type = mime?.[0] === 'text' ? mime?.[1] : `${mime?.[0]}/${mime?.[1]}`;
return {
name: mime?.[0] || 'file',
type: `${type}` || 'image/*',
source: file as string,
} as KeyValue;
});
this.getPreview();
} catch (error: unknown) {
this.log
.for(this.initialize)
.error(`Error parsing file list: ${(error as Error).message || error}`);
}
}
}
/**
* @description Lifecycle hook that is called when a directive, pipe, or service is destroyed.
* @summary Cleans up the component by calling the parent ngOnDestroy method and clearing the file upload state.
*
* @returns {Promise<void>}
*/
override async ngOnDestroy(): Promise<void> {
await super.ngOnDestroy();
this.handleClear();
}
/**
* @description Handles the click event to trigger file selection.
* @summary Simulates a click on the hidden file input element to open the file selection dialog.
* This method is used to allow users to select files programmatically.
*
* @returns {void}
*/
handleClickToSelect(): void {
const element = this.component.nativeElement;
if (element) (element.querySelector('#dcf-file-input') as HTMLButtonElement)?.click();
}
/**
* @description Handles the file selection event.
* @summary Processes the files selected by the user, validates them, and updates the file list.
* This method is triggered when the user selects files using the file input element.
*
* @param {Event} event - The file selection event.
* @returns {Promise<void> }
*/
async handleSelection(event: Event): Promise<void> {
this.clearErrors();
const input = event.target as HTMLInputElement;
if (input.files) {
const fileList = Array.from(input.files);
this.handleSelectionConfirm(fileList);
input.value = '';
}
}
/**
* @description Handles the drop event for drag-and-drop file uploads.
* @summary Processes the files dropped by the user, validates them, and updates the file list.
* This method is triggered when the user drops files onto the upload area.
*
* @param {DragEvent} event - The drag-and-drop event.
* @returns {void}
*/
handleDrop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragCounter = 0;
this.dragging = false;
this.clearErrors();
if (!event.dataTransfer) return;
const fileList = Array.from(event.dataTransfer.files);
this.handleSelectionConfirm(fileList);
}
/**
* @description Handles the drag over event for drag-and-drop file uploads.
* @summary Sets the dragging flag to true to provide visual feedback during drag-and-drop operations.
* This method is triggered when the user drags files over the upload area.
*
* @param {DragEvent} event - The drag over event.
* @returns {void}
*/
handleDragOver(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragging = true;
}
/**
* @description Handles the drag leave event for drag-and-drop file uploads.
* @summary Decrements the drag counter and clears the dragging flag when the counter reaches zero.
* This method is triggered when the user drags files out of the upload area.
*
* @param {DragEvent} event - The drag leave event.
* @returns {void}
*/
handleDragLeave(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragCounter = Math.max(0, this.dragCounter - 1);
if (this.dragCounter === 0) {
this.dragging = false;
}
}
/**
* @description Clears the file list and validation errors.
* @summary Resets the file upload component by clearing the selected files, preview, and errors.
* This method is used to reset the component state.
*
* @returns {void}
*/
handleClear(): void {
this.clearErrors();
this.previewFile = undefined;
this.files = [];
}
/**
* @description Confirms the file selection and updates the component state.
* @summary Validates each file in the selection, updates the file list, and emits
* the change event. If multiple or directory mode is enabled, adds files to the existing list.
* Otherwise, replaces the existing files with the new selection.
*
* @param {File[]} files - The array of files selected by the user.
* @returns {Promise<void>}
*/
private async handleSelectionConfirm(files: File[]): Promise<void> {
const validFiles: File[] = [];
for (const file of files) {
const isValid = this.validateFile(file);
if (isValid === true) {
validFiles.push(file);
} else {
this.errors.push({
name: file.name,
error: isValid,
size: file.size,
});
}
}
if (this.multiple || this.enableDirectoryMode) {
this.files = this.files.concat(validFiles);
} else {
this.files = [validFiles[0]];
}
if (this.files.length) {
const dataValues = await this.getDataURLs(this.files as File[]);
this.setValue(JSON.stringify(dataValues));
}
await this.getPreview();
this.changeEventEmit();
this.changeDetectorRef.detectChanges();
}
/**
* @description Validates a single file against the component's constraints.
* @summary Checks the file type and size against the accepted values and limits.
* Returns true if the file is valid, or an error code if it is not.
*
* @param {File} file - The file to be validated.
* @returns {true | string} - Returns true if valid, error code otherwise.
*/
private validateFile(file: File): true | string {
if (this.accept && this.accept !== '*') {
const acceptedExtensions = Array.isArray(this.accept)
? this.accept
: this.accept.split(',').map((ext) => ext.trim());
const accept = acceptedExtensions.some((ext) => {
if (ext === '*') return true;
if (ext.endsWith('/*')) return file.type.startsWith(ext.replace(/\/\*$/, ''));
const fileExtension = file.type.split('/').pop() || '';
return (
file.type === ext ||
fileExtension === ext ||
file.name.toLowerCase().endsWith(ext.replace('.', ''))
);
});
if (!accept) return FileErrors.notAllowed;
}
if (this.maxFileSize && file.size > this.maxFileSize) return FileErrors.maxSize;
return true;
}
/**
* @description Displays a preview of the selected file in a lightbox.
* @summary If the file is an image, its data URL is retrieved and displayed in a modal lightbox.
* The lightbox shows the image at its natural size, constrained to the viewport dimensions.
*
* @param {File | string} [file] - The file to be previewed. If not provided, the current preview file is used.
* @returns {Promise<void>}
*/
override async preview(file: File | string, fileExtension: string = 'image/'): Promise<void> {
this.log.for(this).info(`Previewing file of type: ${fileExtension}`);
let content: string | undefined;
if (file instanceof File) {
const dataUrl = (await this.getDataURLs(file)) as string[];
if (dataUrl && dataUrl.length) file = dataUrl[0];
}
if (fileExtension.includes('image'))
content = '<img src="' + file + '" style="max-width: 100%; height: auto;" />';
if (fileExtension.includes('xml')) {
const parseXml = (xmlString: string): string | undefined => {
try {
xmlString = (xmlString as string).replace(/^data:[^;]+;base64,/, '').replace(/\s+/g, '');
const decodedString = atob(xmlString);
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(decodedString, 'text/xml');
// const encoder = new TextEncoder(); // gera bytes UTF-8
// const utf8Bytes = encoder.encode(xmlDoc.documentElement.outerHTML);
// return new TextDecoder("utf-8").decode(utf8Bytes);
return xmlDoc.documentElement.outerHTML;
} catch (error: unknown) {
this.log.error((error as Error)?.message);
return undefined;
}
};
if (this.previewHandler && typeof this.previewHandler === 'function') {
const clazz = new (this.previewHandler as Constructor<NgxEventHandler>)();
const previewFn = clazz.handle.bind(this);
return previewFn({ data: file });
} else {
content = parseXml(file as string);
}
return await presentNgxInlineModal(content as string);
}
await presentNgxLightBoxModal(content || '');
}
/**
* @description Checks if a file is an image based on its MIME type.
* @summary Determines if the file can be accepted as an image by checking
* if its type starts with 'image/'.
*
* @param {File} file - The file to be checked.
* @returns {boolean} - True if the file is an image, false otherwise.
*/
isImageFile(file: File): boolean {
return file && file.type.startsWith('image/');
}
getFileMime(base64: string): string {
const match = base64.match(/^data:(.*?);base64,/);
return match ? match[1] : '';
}
/**
* @description Removes a file from the selection.
* @summary Updates the file list to exclude the file at the specified index.
* Emits the change event and updates the preview if necessary.
*
* @param {number} index - The index of the file to be removed.
* @returns {Promise<void>}
*/
async removeFile(index: number): Promise<void> {
if (index <= this.files.length) this.files = [...this.files.filter((_, i) => i !== index)];
await this.getPreview();
this.changeEventEmit();
}
/**
* @description Retrieves the preview image for the selected files.
* @summary If the first selected file is an image, its data URL is retrieved and set as the preview.
* If the file is not an image, the preview is cleared.
*
* @returns {Promise<void>}
*/
private async getPreview(): Promise<void> {
this.previewFile = undefined;
const file = this.files && this.files.length ? this.files[0] : null;
if (file instanceof File) {
const dataUrl = (await this.getDataURLs(file as File)) as string[];
if (dataUrl && dataUrl.length) this.previewFile = dataUrl[0];
} else {
this.previewFile = (file as KeyValue)?.['source'] as string;
}
}
/**
* @description Emits the change event for the file upload field.
* @summary Triggers the change event, notifying any listeners that the value has changed.
* The event contains the updated value, component name, and event type.
*
* @returns {void}
*/
private changeEventEmit(): void {
this.changeEvent.emit({
data: this.value,
component: this.componentName,
name: ComponentEventNames.Change,
});
}
/**
* @description Retrieves the data URLs for the selected files.
* @summary Converts the selected image files to data URLs using FileReader.
* The resulting data URLs can be used for previewing images in the browser.
*
* @param {File[] | File} [files] - The files for which to generate data URLs.
* If not provided, the currently selected files are used.
*
* @returns {Promise<string[] | undefined>} - A promise that resolves to an array of data URLs, or undefined if an error occurs.
*/
async getDataURLs(files?: File[] | File): Promise<string[] | undefined> {
if (!files) files = this.files as File[];
if (!Array.isArray(files)) files = [files];
// files = files.filter(f => f.type && f.type.startsWith('image/'));
return this.readFile(files)
.then((urls) => {
// validate generated DataURLs
const invalid = urls.some((u) => !this.isValidDataURL(u));
if (invalid) return undefined;
if (this.multiple || this.enableDirectoryMode) return urls;
return urls.length ? [urls[0]] : undefined;
})
.catch(() => {
return undefined;
});
}
/**
* @description Validates the format of a data URL.
* @summary Checks if the data URL is a non-empty string and matches the expected pattern
* for base64-encoded image data URLs. Uses a regular expression to validate the format.
*
* @param {string | undefined} dataURL - The data URL to be validated.
* @returns {boolean} - True if the data URL is valid, false otherwise.
*/
private isValidDataURL(dataURL: string | undefined): boolean {
if (!dataURL || typeof dataURL !== 'string') {
return false;
}
// Regex para qualquer MIME type seguido de ;base64
const match = dataURL.match(/^data:([a-zA-Z0-9.+-\\/]+);base64,([A-Za-z0-9+/=\s]+)$/);
if (!match) return false;
const payload = match[2];
try {
if (typeof atob === 'function') {
// remove espaços e tenta decodificar
atob(payload.replace(/\s+/g, ''));
}
return true;
} catch {
return false;
}
}
/**
* @description Clears all error messages from the component.
* @summary Resets the error state, removing all error messages from the display.
*
* @returns {void}
*/
private clearErrors(): void {
this.errors = [];
}
/**
* @description Reads the selected files as data URLs.
* @summary Uses the FileReader API to read each file as a data URL.
* Returns a promise that resolves to an array of data URLs.
*
* @param {File[]} files - The files to be read.
* @returns {Promise<string[]>} - A promise that resolves to an array of data URLs.
*/
private readFile(files: File[]): Promise<string[]> {
return Promise.all(
files.map(
(file) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
}),
),
);
}
}
Source