UNPKG

ngx-uploadx

Version:
1,136 lines (1,117 loc) 40.2 kB
import * as i0 from '@angular/core'; import { InjectionToken, Injectable, inject, NgZone, EventEmitter, ElementRef, Renderer2, HostListener, Output, Input, Directive, ContentChild, HostBinding, NgModule } from '@angular/core'; import { map, debounceTime, takeWhile } from 'rxjs/operators'; import { Subject, fromEvent } from 'rxjs'; function createXhr() { return new XMLHttpRequest(); } class UploadxAjax { buildXhr; constructor(buildXhr) { this.buildXhr = buildXhr; } request = ({ method = 'GET', data = null, headers = {}, url, responseType, signal, onUploadProgress, timeout = 0, withCredentials = false, validateStatus = status => status < 400 && status >= 200 }) => { return new Promise((resolve, reject) => { const xhr = this.buildXhr(); const abortListener = () => xhr && xhr.readyState !== xhr.DONE && xhr.abort(); signal?.addEventListener('abort', abortListener, { once: true }); xhr.open(method, url, true); xhr.timeout = timeout; withCredentials && (xhr.withCredentials = true); if (responseType && responseType !== 'json') { xhr.responseType = responseType; } Object.keys(headers).forEach(key => xhr.setRequestHeader(key, String(headers[key]))); xhr.upload.onprogress = onUploadProgress || null; xhr.onerror = xhr.ontimeout = xhr.onabort = evt => { signal?.removeEventListener('abort', abortListener); return reject({ error: evt.type, url, method }); }; xhr.onload = () => { const response = { data: this.getResponseBody(xhr, responseType), status: xhr.status, headers: this.getResponseHeaders(xhr) }; signal?.removeEventListener('abort', abortListener); return validateStatus(response.status) ? resolve(response) : reject(response); }; xhr.send(data); }); }; getResponseHeaders(xhr) { const rows = xhr.getAllResponseHeaders().split(/[\r\n]+/); return rows.reduce((headers, current) => { const [name, value] = current.split(': '); name && (headers[name.toLowerCase()] = value); return headers; }, {}); } getResponseBody(xhr, responseType) { let body = typeof xhr.response === 'undefined' ? xhr.responseText : xhr.response; if (responseType === 'json' && body && typeof body === 'string') { try { body = JSON.parse(body); } catch { } } return body; } } /** * Injection token for the AJAX client used by Uploadx. * * @example * // Using a custom AJAX client (e.g., axios) * import axios from 'axios'; * const axiosInstance = axios.create({ }); * { provide: UPLOADX_AJAX, useFactory: () => ({ request: axiosInstance.request }) } */ const UPLOADX_AJAX = new InjectionToken('uploadx.ajax', { factory: () => new UploadxAjax(createXhr), providedIn: 'root' }); /** * Allows canceling some operation by calling cancel(). * onCancel callback can be used to execute cleanup logic when cancel is called. * @deprecated Use AbortController instead. Will be removed in next major version. */ class Canceler { /** * Callback function to execute cleanup logic when cancel() is called */ onCancel = () => { }; /** * Cancels the operation. */ cancel() { this.onCancel(); this.onCancel = () => { }; } } const KiB = 1024; /** * Adaptive chunk size */ class DynamicChunk { /** Maximum chunk size in bytes */ static maxSize = Number.MAX_SAFE_INTEGER; /** Minimum chunk size in bytes */ static minSize = 256 * KiB; /** Initial chunk size in bytes */ static size = 4 * (256 * KiB); static minChunkTime = 8; static maxChunkTime = 24; /** * Scales the chunk size based on the throughput. * If the elapsed time to upload a chunk is less than the min time, increase the chunk size. * If the elapsed time is more than the max time, decrease the chunk size. * Keeps the chunk size within the min and max limits. * @param throughput - represents the upload rate in bytes/sec. */ static scale(throughput) { const elapsedTime = DynamicChunk.size / throughput; if (elapsedTime < DynamicChunk.minChunkTime) { DynamicChunk.size = Math.min(DynamicChunk.maxSize, DynamicChunk.size * 2); } if (elapsedTime > DynamicChunk.maxChunkTime) { DynamicChunk.size = Math.max(DynamicChunk.minSize, DynamicChunk.size / 2); } return DynamicChunk.size; } } function safeMatch(base, re) { return (base.match(re) || [''])[0]; } function resolveUrl(url, base) { if (url.indexOf('https://') * url.indexOf('http://') === 0) return url; if (url.indexOf('//') === 0) return safeMatch(base, /^(https?:)/) + url; if (url.indexOf('/') === 0) return safeMatch(base, /^(?:https?:)?(?:\/\/)?([^/?]+)/) + url; return safeMatch(base, /^(?:https?:)?(?:\/\/)?([^/?]+)?(.*\/)/) + url; } /** * Unwraps a value if it is a function, otherwise returns the value directly. * Useful for allowing values to optionally be specified as functions. */ function unfunc(value, ref) { return value instanceof Function ? value(ref) : value; } const pick = (obj, props) => { const result = {}; props.forEach(key => (result[key] = obj[key])); return result; }; function isNumber(x) { return x === Number(x); } /** * 32-bit FNV-1a hash function */ function createHash(str) { let hash = 2166136261; const len = str.length; for (let i = 0; i < len; i++) { hash ^= str.charCodeAt(i); hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); } return hash >>> 0; } /** * Utility functions for base64 encoding and decoding strings and objects. */ const b64 = { encode: (str) => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode(Number.parseInt(p1, 16)))), decode: (str) => decodeURIComponent(atob(str) .split('') .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) .join('')), serialize: (obj) => Object.keys(obj) .map(key => [key, b64.encode(String(obj[key]))].filter(Boolean).join(' ')) .toString(), parse: (encoded) => { const kvPairs = encoded.split(',').map(kv => kv.split(' ')); const decoded = {}; for (const [key, value] of kvPairs) { if (key) decoded[key] = value ? b64.decode(value) : ''; } return decoded; } }; function isBrowser() { return ![typeof window, typeof navigator].includes('undefined'); } function onLine() { return isBrowser() ? navigator.onLine : true; } class IdService { generateId(uploader) { const print = JSON.stringify({ ...uploader.metadata, type: uploader.constructor.name, endpoint: uploader.endpoint }); return createHash(uploader.name + uploader.size).toString(16) + createHash(print).toString(16); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: IdService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: IdService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: IdService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }] }); var ErrorType; (function (ErrorType) { ErrorType[ErrorType["NotFound"] = 0] = "NotFound"; ErrorType[ErrorType["Auth"] = 1] = "Auth"; ErrorType[ErrorType["Retryable"] = 2] = "Retryable"; ErrorType[ErrorType["Fatal"] = 3] = "Fatal"; })(ErrorType || (ErrorType = {})); const defaultRetryConfig = { maxAttempts: 8, shouldRestartCodes: [404, 410], authErrorCodes: [401], shouldRetryCodes: [408, 423, 429, 460], shouldRetry(code) { return code < 400 || code >= 500 || this.shouldRetryCodes.includes(code); }, minDelay: 500, maxDelay: 50000, onBusyDelay: 1000, timeout: 0, keepPartial(statusCode) { return statusCode >= 400; } }; /** * Retryable ErrorHandler */ class RetryHandler { static STALL_THRESHOLD = 3; attempts = 0; config; observedValue; repeatCount = 0; cancel = () => { }; constructor(configOptions = {}) { this.config = Object.assign({}, defaultRetryConfig, configOptions); } /** * Determine error type based on response code * @param code - HTTP response status code */ kind(code) { this.attempts++; if (this.attempts > this.config.maxAttempts) { return ErrorType.Fatal; } if (this.config.authErrorCodes.includes(code)) { return ErrorType.Auth; } if (this.config.shouldRestartCodes.includes(code)) { return ErrorType.NotFound; } if (this.config.shouldRetry(code, this.attempts)) { return ErrorType.Retryable; } return ErrorType.Fatal; } /** * Wait before next retry attempt * @param time - Delay in ms */ wait(time) { const ms = time || Math.min(2 ** (this.attempts - 1) * this.config.minDelay, this.config.maxDelay); const jitter = Math.floor(Math.random() * this.config.minDelay * this.attempts); let id; return new Promise(resolve => { this.cancel = () => { clearTimeout(id); resolve(); }; id = setTimeout(this.cancel, ms + jitter); }); } /** * @deprecated Use checkForStall instead */ observe(value) { this.checkForStall(value); } /** * Check if upload is stalled */ checkForStall(value) { if (value === undefined) { this.repeatCount = 0; this.observedValue = undefined; return false; } if (this.observedValue === value) { this.repeatCount++; } else { this.repeatCount = 0; this.observedValue = value; this.attempts = 0; } return this.repeatCount >= RetryHandler.STALL_THRESHOLD; } } const HOUR = 1000 * 60 * 60; class Store { prefix; ttl = 24 * HOUR; constructor(prefix = 'UPLOADX-v4.0-') { this.prefix = prefix; } set(key, value) { this.ttl && localStorage.setItem(this.prefix + key, JSON.stringify([value, Date.now() + this.ttl])); } get(key) { const item = localStorage.getItem(this.prefix + key); if (item) { try { const [value, expires] = JSON.parse(item); if (Date.now() < expires) { return value; } } catch { } this.delete(key); } return null; } delete(key) { localStorage.removeItem(this.prefix + key); } clear(maxAgeHours = 0) { this.ttl = maxAgeHours * HOUR; const now = Date.now(); this.keys().forEach(key => { const item = localStorage.getItem(key); if (item && maxAgeHours) { try { const [, expires] = JSON.parse(item); now > expires && localStorage.removeItem(key); return; } catch { } } localStorage.removeItem(key); }); } keys() { return Object.keys(localStorage).filter(key => key.indexOf(this.prefix) === 0); } } const store = isLocalStorageAvailable() ? new Store() : new Map(); function isLocalStorageAvailable() { try { const key = 'LocalStorageTest'; const value = 'value'; localStorage.setItem(key, value); const getValue = localStorage.getItem(key); localStorage.removeItem(key); return getValue === value; } catch { return false; } } const actionToStatusMap = { pause: 'paused', upload: 'queue', cancel: 'cancelled', update: 'updated' }; /** * Uploader Base Class */ class Uploader { file; options; stateChange; ajax; name; size; uploadId; response = null; responseStatus = 0; responseHeaders = {}; progress = 0; remaining; speed = 0; /** Custom headers */ headers = {}; /** Metadata Object */ metadata; /** Upload endpoint */ endpoint = '/upload'; /** Chunk size in bytes */ chunkSize = 0; /** Auth token/tokenGetter */ token; /** Byte offset within the whole file */ offset; /** Retries handler */ retry; abortController = new AbortController(); /** Set HttpRequest responseType */ responseType; _authorize; _prerequest; _token; _eventsCount = 0; constructor(file, options, stateChange, ajax) { this.file = file; this.options = options; this.stateChange = stateChange; this.ajax = ajax; this.retry = new RetryHandler(options.retryConfig); this.name = file.name; this.size = file.size; this.metadata = { name: file.name, mimeType: file.type || 'application/octet-stream', size: file.size, lastModified: file.lastModified }; options.maxChunkSize && (DynamicChunk.maxSize = options.maxChunkSize); /** A function that is executed before the request is sent */ this._prerequest = options.prerequest || (req => req); /** A function used to authorize the request */ this._authorize = options.authorize || (req => req); this.configure(options); } _url = ''; get url() { return this._url || store.get(this.uploadId) || ''; } set url(value) { this._url !== value && store.set(this.uploadId, value); this._url = value; } _status; get status() { return this._status; } /** * Transition uploader to a new status. * @sideEffects * - Calls this.abort() when transitioning to 'paused' * - Calls this.cleanup() for terminal states * - Emits state change via this.stateChange() */ moveTo(next) { const current = this._status; // Guard: cannot change from cancelled if (current === 'cancelled') return; // Guard: cannot change from complete (except updated/cancelled) if (current === 'complete' && next !== 'updated' && next !== 'cancelled') return; // Guard: cannot go back to queue from uploading if (current === 'uploading' && next === 'queue') return; // Guard: ignore duplicate status (updated is always allowed) if (current === next && next !== 'updated') return; // Cancel pending retry if (current === 'retry') { this.retry.cancel(); } this._status = next; // Side effects if (next === 'paused') { this.abort(); } if (next === 'cancelled' || next === 'complete' || next === 'error') { this.cleanup(); } // Dispatch event switch (next) { case 'cancelled': this.cancelAndSendState(); break; case 'updated': this.updateAndSendState(); break; default: this.stateChange(this); } } set status(next) { this.moveTo(next); } /** * Configure uploader */ configure({ metadata, headers, token, endpoint, action }) { endpoint && (this.endpoint = endpoint); token && (this.token = token); metadata && Object.assign(this.metadata, unfunc(metadata, this.file)); headers && Object.assign(this.headers, unfunc(headers, this.file)); action && (this.status = actionToStatusMap[action]); } /** * Starts uploading */ async upload() { do { this.status = 'uploading'; try { this._token ||= await this.updateToken(); this.url ||= await this.getFileUrl(); if (this.offset !== this.size) { this.offset = isNumber(this.offset) ? await this.sendFileContent() : await this.getOffset(); } if (this.retry.checkForStall(this.offset)) { this.response = { error: 'stalled', message: `No offset update received after ${RetryHandler.STALL_THRESHOLD} attempts — upload is stalled (offset: ${this.offset})` }; this.status = 'error'; return; } if (this.offset === this.size) { this.remaining = 0; this.progress = 100; this.status = 'complete'; } else if (!isNumber(this.offset)) { this.stateChange(this); await this.retry.wait(this.getRetryAfterFromBackend() || this.retry.config.onBusyDelay); } } catch (e) { e instanceof Error && console.error(e); if (this.status !== 'uploading') { return; } switch (this.retry.kind(this.responseStatus)) { case ErrorType.Fatal: this.status = 'error'; return; case ErrorType.NotFound: this.url = ''; break; case ErrorType.Auth: this._token = ''; break; default: if (unfunc(this.retry.config.keepPartial, this.responseStatus)) { this.offset = undefined; } this.status = 'retry'; await this.retry.wait(this.getRetryAfterFromBackend()); } } } while (['uploading', 'retry', 'updated'].includes(this._status)); } /** * Performs http requests */ async request(requestOptions) { this.responseStatus = 0; this.response = null; this.responseHeaders = {}; if (this.abortController.signal.aborted) { this.abortController = new AbortController(); } const signal = requestOptions.signal || this.abortController.signal; let req = { body: requestOptions.body || null, signal, headers: { ...this.headers, ...requestOptions.headers }, method: requestOptions.method || 'GET', url: requestOptions.url || this.url }; if (!requestOptions.skipAuthorization) { req = await this._authorize(req, this._token); } const { body = null, headers, method, url = req.url } = (await this._prerequest(req)) || req; const ajaxRequestConfig = { method, headers: { ...req.headers, ...headers }, url, data: body, responseType: this.options.responseType ?? this.responseType, withCredentials: !!this.options.withCredentials, signal, validateStatus: () => true, timeout: this.retry.config.timeout }; if (isNumber(this.offset) && body && typeof body === 'object') { ajaxRequestConfig.onUploadProgress = this.onProgress(); } const response = await this.ajax.request(ajaxRequestConfig); this.response = response.data; this.responseHeaders = response.headers; this.responseStatus = response.status; if (response.status >= 400) { return Promise.reject(); } } /** * Set auth token string */ updateToken = () => { return unfunc(this.token || '', this.responseStatus); }; /** * Updating the metadata of the upload */ update(_data) { return Promise.reject('Not implemented'); } abort() { this.offset = undefined; this.abortController.abort(); } async cancel() { this.abort(); if (this.url) { await this.request({ method: 'DELETE' }).catch(() => { }); } } /** * Gets the value from the response */ getValueFromResponse(key) { return this.responseHeaders[key.toLowerCase()] || null; } /** * Get file chunk * @param offset - number of bytes of the file to skip * @param size - chunk size */ getChunk(offset, size) { if (this.responseStatus === 413) { DynamicChunk.maxSize = DynamicChunk.size = Math.floor(DynamicChunk.size / 2); } this.chunkSize = this.options.chunkSize === 0 ? this.size : this.options.chunkSize || DynamicChunk.size; const start = offset ?? this.offset ?? 0; const end = Math.min(start + (size || this.chunkSize), this.size); const body = this.file.slice(start, end); return { start, end, body }; } getRetryAfterFromBackend() { return Number(this.getValueFromResponse('retry-after')) * 1000; } cancelAndSendState() { this.cancel().then(() => this.stateChange(this), console.error); } updateAndSendState() { this.update({ metadata: this.metadata }).then(() => this.stateChange(this), console.error); } cleanup = () => { store.delete(this._url); store.delete(this.uploadId); }; onProgress() { let throttle; const startTime = Date.now(); return ({ loaded }) => { const current = loaded / ((Date.now() - startTime) / 1000); this.speed = ~~((this.speed * this._eventsCount + current) / ++this._eventsCount); DynamicChunk.scale(this.speed); if (!throttle) { throttle = setTimeout(() => (throttle = undefined), 500); const uploaded = (this.offset || 0) + loaded; this.progress = +((uploaded / this.size) * 100).toFixed(2); this.remaining = ~~((this.size - uploaded) / this.speed); this.stateChange(this); } }; } } /** * Implements XHR/CORS Resumable Upload * {@link https://github.com/kukhariev/node-uploadx/blob/master/proto.md Github} * @see {@link https://developers.google.com/drive/api/v3/manage-uploads#resumable Google Drive API documentation} */ class UploaderX extends Uploader { responseType = 'json'; async getFileUrl() { const body = JSON.stringify(this.metadata); const headers = { 'Content-Type': 'application/json; charset=utf-8', 'X-Upload-Content-Length': this.size, 'X-Upload-Content-Type': this.file.type || 'application/octet-stream' }; await this.request({ method: 'POST', body, url: this.endpoint, headers }); this.offset = this.getOffsetFromResponse() || (this.responseStatus === 201 ? 0 : undefined); const location = this.getValueFromResponse('location'); if (!location) { throw new Error('Invalid or missing Location header'); } return resolveUrl(location, this.endpoint); } async sendFileContent() { const { body, start, end } = this.getChunk(); const headers = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes ${start}-${end - 1}/${this.size}` }; await this.request({ method: 'PUT', body, headers }); return this.responseStatus > 201 ? this.getOffsetFromResponse() : end; } async getOffset() { const headers = { 'Content-Type': 'application/octet-stream', 'Content-Range': `bytes */${this.size}` }; await this.request({ method: 'PUT', headers }); return this.responseStatus > 201 ? this.getOffsetFromResponse() : this.size; } async update(data) { const body = JSON.stringify(data); const headers = { 'Content-Type': 'application/json; charset=utf-8' }; await this.request({ method: 'PATCH', body, headers }); const location = this.getValueFromResponse('location') || this.url; return resolveUrl(location, this.endpoint); } getOffsetFromResponse() { const range = this.getValueFromResponse('Range'); if (range) { return getRangeEnd(range) + 1; } return this.responseStatus === 308 ? 0 : undefined; } } function getRangeEnd(range = '') { const end = parseInt(range.split(/-/)[1], 10); return end >= 0 ? end : -1; } const defaultOptions = { endpoint: '/upload', autoUpload: true, concurrency: 2, uploaderClass: UploaderX, authorize: (req, token) => { token && (req.headers['Authorization'] = `Bearer ${token}`); return req; }, storeIncompleteHours: 24 }; const UPLOADX_FACTORY_OPTIONS = new InjectionToken('uploadx.factory.options', { providedIn: 'root', factory: () => defaultOptions }); const UPLOADX_OPTIONS = new InjectionToken('uploadx.options'); /** * Implements tus resumable upload protocol * {@link https://github.com/tus/tus-resumable-upload-protocol/blob/master/protocol.md Github} */ class Tus extends Uploader { headers = { 'Tus-Resumable': '1.0.0' }; async getFileUrl() { const encodedMetaData = b64.serialize(this.metadata); const headers = { 'Upload-Length': this.size, 'Upload-Metadata': encodedMetaData }; await this.request({ method: 'POST', url: this.endpoint, headers }); this.offset = this.getOffsetFromResponse() || (this.responseStatus === 201 ? 0 : undefined); const location = this.getValueFromResponse('location'); if (!location) { throw new Error('Invalid or missing Location header'); } return resolveUrl(location, this.endpoint); } async sendFileContent() { const { body, start, end } = this.getChunk(); const headers = { 'Content-Type': 'application/offset+octet-stream', 'Upload-Offset': start }; await this.request({ method: 'PATCH', body, headers }); return this.getOffsetFromResponse() || end; } async getOffset() { await this.request({ method: 'HEAD' }); return this.getOffsetFromResponse(); } getOffsetFromResponse() { const offset = this.getValueFromResponse('Upload-Offset'); return offset ? parseInt(offset, 10) : undefined; } } const UPLOAD_STATE_KEYS = [ 'file', 'name', 'progress', 'remaining', 'response', 'responseHeaders', 'responseStatus', 'size', 'speed', 'status', 'uploadId', 'url' ]; const DUE_TIME = 5; class UploadxService { /** Upload Queue */ queue = []; options; eventsStream = new Subject(); subs = []; ngZone = inject(NgZone); ajax = inject(UPLOADX_AJAX); idService = inject(IdService); constructor() { const options = inject(UPLOADX_OPTIONS, { optional: true }); const defaults = inject(UPLOADX_FACTORY_OPTIONS); this.options = Object.assign({}, defaults, options); if (isBrowser()) { this.subs.push(fromEvent(window, 'online').subscribe(() => this.control({ action: 'upload' })), fromEvent(window, 'offline').subscribe(() => this.control({ action: 'pause' }))); } } /** Upload status events */ get events() { return this.eventsStream.asObservable(); } /** * Initializes service * @param options global module options * @returns Observable that emits a new value on progress or status changes */ init(options = {}) { Object.assign(this.options, options); return this.events; } /** * Initializes service * @param options global module options * @returns Observable that emits the current array of uploaders */ connect(options) { return this.init(options).pipe(map(() => this.queue), debounceTime(DUE_TIME)); } /** * Terminates all uploads and clears the queue */ disconnect() { this.queue.forEach(uploader => (uploader.status = 'paused')); this.queue = []; } /** * Returns current uploads state * @example * // restore background uploads * this.uploads = this.uploadService.state(); */ state() { return this.queue.map(uploader => pick(uploader, UPLOAD_STATE_KEYS)); } ngOnDestroy() { this.disconnect(); this.subs.forEach(sub => sub.unsubscribe()); } /** * Creates uploaders for files and adds them to the upload queue */ handleFiles(files, options = {}) { const instanceOptions = { ...this.options, ...options }; store.clear(instanceOptions.storeIncompleteHours); this.options.concurrency = instanceOptions.concurrency; ('name' in files ? [files] : Array.from(files)).forEach(file => this.addUploaderInstance(file, instanceOptions)); } /** * Upload control * @example * // pause all * this.uploadService.control({ action: 'pause' }); * // pause upload with uploadId * this.uploadService.control({ action: 'pause', uploadId}); * // set token * this.uploadService.control({ token: `TOKEN` }); */ control(evt) { const target = evt.uploadId ? this.queue.filter(({ uploadId }) => uploadId === evt.uploadId) : this.queue; target.forEach(uploader => uploader.configure(evt)); } /** * Number of active uploads */ get activeUploadsCount() { return this.queue.filter(({ status }) => status === 'uploading' || status === 'retry').length; } /** * Performs http requests */ async request(config) { config.data ||= config.body; return this.ajax.request(config); } stateChange = (uploader) => { this.ngZone.run(() => this.eventsStream.next(pick(uploader, UPLOAD_STATE_KEYS))); if (uploader.status !== 'uploading' && uploader.status !== 'added') { this.ngZone.runOutsideAngular(() => setTimeout(() => this.processQueue())); } }; async addUploaderInstance(file, options) { const uploader = new options.uploaderClass(file, options, this.stateChange, this.ajax); uploader.uploadId = await this.idService.generateId(uploader); this.queue.push(uploader); uploader.status = 'added'; if (options.autoUpload && onLine()) { uploader.status = 'queue'; } } processQueue() { this.queue = this.queue.filter(({ status }) => status !== 'cancelled'); this.queue .filter(({ status }) => status === 'queue') .slice(0, Math.max(this.options.concurrency - this.activeUploadsCount, 0)) .forEach(uploader => uploader.upload()); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxService, providedIn: 'root' }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [] }); class UploadxDirective { set uploadx(value) { if (value) { this.options = value; } } options = {}; set control(value) { if (value) { this.uploadService.control(value); } } state = new EventEmitter(); elementRef = inject(ElementRef); renderer = inject(Renderer2); uploadService = inject(UploadxService); ngOnInit() { const { multiple, allowedTypes } = { ...this.uploadService.options, ...this.options }; multiple !== false && this.renderer.setAttribute(this.elementRef.nativeElement, 'multiple', ''); allowedTypes && this.renderer.setAttribute(this.elementRef.nativeElement, 'accept', allowedTypes); this.uploadService.events .pipe(takeWhile(() => this.state.observers.length > 0)) .subscribe(this.state); } fileChange(event) { if (event.target) { this.fileListener(event.target.files || []); } } fileListener(files) { if (files?.length) { this.uploadService.handleFiles(files, this.options); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.0", type: UploadxDirective, isStandalone: true, selector: "[uploadx]", inputs: { uploadx: "uploadx", options: "options", control: "control" }, outputs: { state: "state" }, host: { listeners: { "change": "fileChange($event)" } }, ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxDirective, decorators: [{ type: Directive, args: [{ // eslint-disable-next-line @angular-eslint/directive-selector selector: '[uploadx]', standalone: true }] }], propDecorators: { uploadx: [{ type: Input }], options: [{ type: Input }], control: [{ type: Input }], state: [{ type: Output }], fileChange: [{ type: HostListener, args: ['change', ['$event']] }] } }); class UploadxDropDirective { active = false; fileInput; uploadService = inject(UploadxService); dropHandler(event) { this._stopEvents(event); this.active = false; const files = this.getFiles(event); if (files.length) { this.fileInput ? this.fileInput.fileListener(files) : this.uploadService.handleFiles(files); } } onDragOver(event) { this._stopEvents(event); if (event.dataTransfer?.items[0]?.kind === 'file') { if (this.fileInput?.options.multiple === false && event.dataTransfer.items.length > 1) { event.dataTransfer.dropEffect = 'none'; } else { event.dataTransfer.dropEffect = 'copy'; this.active = true; } } } onDragLeave(event) { this._stopEvents(event); this.active = false; } /** * Extracts the files from a `DragEvent` object */ getFiles(event) { const dataTransfer = new DataTransfer(); const items = event.dataTransfer?.items; if (items?.length) { for (let i = 0; i < items.length; i++) { const item = items[i]; if (isFile(item)) { const file = item.getAsFile(); file && dataTransfer.items.add(file); } } } return dataTransfer.files; } _stopEvents(event) { event.stopPropagation(); event.preventDefault(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxDropDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.0", type: UploadxDropDirective, isStandalone: true, selector: "[uploadxDrop]", host: { listeners: { "drop": "dropHandler($event)", "dragover": "onDragOver($event)", "dragleave": "onDragLeave($event)" }, properties: { "class.uploadx-drop-active": "this.active" } }, queries: [{ propertyName: "fileInput", first: true, predicate: UploadxDirective, descendants: true }], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxDropDirective, decorators: [{ type: Directive, args: [{ selector: '[uploadxDrop]', standalone: true }] }], propDecorators: { active: [{ type: HostBinding, args: ['class.uploadx-drop-active'] }], fileInput: [{ type: ContentChild, args: [UploadxDirective, { static: false }] }], dropHandler: [{ type: HostListener, args: ['drop', ['$event']] }], onDragOver: [{ type: HostListener, args: ['dragover', ['$event']] }], onDragLeave: [{ type: HostListener, args: ['dragleave', ['$event']] }] } }); function isFile(item) { if (item.kind !== 'file') return false; const entry = item.webkitGetAsEntry?.(); if (entry) { return !!entry.isFile; } return true; } class UploadxModule { static withConfig(options) { return { ngModule: UploadxModule, providers: [{ provide: UPLOADX_OPTIONS, useValue: options }] }; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "22.0.0", ngImport: i0, type: UploadxModule, imports: [UploadxDirective, UploadxDropDirective], exports: [UploadxDirective, UploadxDropDirective] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxModule }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: UploadxModule, decorators: [{ type: NgModule, args: [{ imports: [UploadxDirective, UploadxDropDirective], exports: [UploadxDirective, UploadxDropDirective] }] }] }); /** * Provides configuration options for standalone app. * * @example * ```ts * bootstrapApplication(AppComponent, { * providers: [ * provideUploadx({ * endpoint: uploadUrl, * allowedTypes: 'video/*,audio/*', * maxChunkSize: 96 * 1024 * 1024 * }) * ] * }); * ``` */ function provideUploadx(options = {}) { const providers = [ { provide: UPLOADX_OPTIONS, useValue: options } ]; return providers; } /** * Generated bundle index. Do not edit. */ export { Canceler, DynamicChunk, ErrorType, IdService, RetryHandler, Store, Tus, UPLOADX_AJAX, UPLOADX_FACTORY_OPTIONS, UPLOADX_OPTIONS, UPLOAD_STATE_KEYS, Uploader, UploaderX, UploadxAjax, UploadxDirective, UploadxDropDirective, UploadxModule, UploadxService, b64, createHash, getRangeEnd, isBrowser, isLocalStorageAvailable, isNumber, onLine, pick, provideUploadx, resolveUrl, store, unfunc }; //# sourceMappingURL=ngx-uploadx.mjs.map