/** * File upload * @augments EicComponent * @category EICUI/Components * @author Nike * @version 1.0 */ class FileUpload extends EicComponent { /** * Triggered just before upload of file(s) is launched * Return False to prevent theupload to happen * * @event FileUpload#onBeforeUploadAllStart * @type {event} */ onBeforeUploadAllStart() { return(true) } /** * Triggered when the upload of file(s) is launched * * @event FileUpload#onUploadAllStart * @type {event} */ onUploadAllStart() { } /** * Triggered when the upload of file(s) is launched * * @event FileUpload#onUploadAllEnd * @type {event} */ onUploadAllEnd() { } /** * Triggered just before upload of file(s) is launched * Return False to prevent theupload to happen * * @event FileUpload#onBeforeUploadOneStart * @type {event} * @param {file} fileInfo The offening file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onBeforeUploadOneStart(file) { return(true) } /** * Triggered when the upload of file(s) is launched * * @event FileUpload#onUploadOneStart * @type {event} * @param {file} fileInfo The offening file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onUploadOneStart(file) { } /** * Triggered when the upload of file(s) is launched * * @event FileUpload#onUploadOneEnd * @type {event} * @param {file} fileInfo The offening file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onUploadOneEnd(file) { } /** * Triggered on attempting to add a file with wrong extension/mime to the list * * @event FileUpload#onWrongFileType * @type {event} * @param {file} fileInfo The offening file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onWrongFileType(file) { console.warn('Wrong file type !', file) } /** * Triggered when a file is added in the list * * @event FileUpload#onFileAdded * @type {event} * @param {integer} fileIdx The file index in this.filesList * @param {file} fileInfo The added file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onFileAdded(fileIdx, file) { } /** * Triggered when a file is removed from the list * * @event FileUpload#onFileRemoved * @type {event} * @param {integer} fileIdx The file index in this.filesList * @param {file} fileInfo The removed file object (https://developer.mozilla.org/en-US/docs/Web/API/File) */ onFileRemoved(fileIdx, file) { } /** * @param {element|string} el A DOM element or the selector string for the element which will contain the component * @param {object} [options] The grid options * @param {string} [options.uploadUrl] Where to POST the files * @param {string} [options.browseLabel] Text of the browser button * @param {string} [options.submitLabel] Text of the upload button * @param {string} [options.dndLabel] Text of the drop area * @param {array} [options.allowedMimes] list of mim types accepted * @param {array} [options.allowedExtensions] list of file extenstions accepted (dot excluded) * @param {boolean} [options.allowDrop] Enables drag & drop * @param {Integer} [options.maxFiles] Set to 1 to disable file list * @param {Integer} [options.minFiles] Upload button will show only if files list greater or equal * @param {object} [options.mimeTypes] Recognized mime types (unknown types show extension as name, and an attachment icon) * @param {string} [options.mimetypes[mime].label] type name used in list * @param {string} [options.mimetypes[mime].icon] icon used in list * @param {string} [options.noUpload] Use when processiong files in browser : no upload to server, no upload/reset buttons */ constructor(el, options) { super(el, options); this.options = { browseLabel: 'Choose file...', submitLabel : 'upload', resetLabel: 'clear list', noUpload : false, dndLabel: 'Drag and drop file here', allowedMimes: ['application/pdf'], allowedExtensions: ['pdf'], allowDrop: true, maxSize: 1024*1024*1024, maxFiles: 1, minFiles: 1, uploadMethod: 'POST', mimeTypes: { 'video/x-msvideo': {label: 'Video' , icon: 'youtube'}, 'video/mp4': {label: 'Video' , icon: 'youtube'}, 'video/mpeg': {label: 'Video' , icon: 'youtube'}, 'video/ogg': {label: 'Video' , icon: 'youtube'}, 'application/x-bzip': {label: 'Compressed file' , icon: 'zip'}, 'application/x-bzip2': {label: 'Compressed file' , icon: 'zip'}, 'application/gzip': {label: 'Compressed file' , icon: 'zip'}, 'application/zip': {label: 'Compressed file' , icon: 'zip'}, 'application/x-7z-compressed': {label: 'Compressed file' , icon: 'zip'}, 'application/msword': {label: 'Text document' , icon: 'writing'}, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': {label: 'Text document' , icon: 'writing'}, 'application/vnd.oasis.opendocument.text': {label: 'Text document' , icon: 'writing'}, 'application/vnd.ms-excel': {label: 'Spreadsheet' , icon: 'xls'}, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': {label: 'Spreadsheet' , icon: 'xls'}, 'application/vnd.oasis.opendocument.spreadsheet': {label: 'Spreadsheet' , icon: 'xls'}, 'text/csv': {label: 'Spreadsheet' , icon: 'xls'}, 'image/gif': {label: 'Image' , icon: 'image'}, 'image/jpeg': {label: 'Image' , icon: 'image'}, 'image/png': {label: 'Image' , icon: 'image'}, 'image/svg+xml': {label: 'Image' , icon: 'image'}, 'application/pdf': {label: 'PDF document' , icon: 'pdf'}, } }; this.setOptions(options); this.el.setAttribute('eicfileupload', ''); this.fileItemTpl = `
%{icon}
%{name}
%{size}
%{type}
%{actions}
` this.browseBtn = new Button(null, { icon: null, severity: 'primary', disabled: false, badge: false, rounded: false, size: 'small', hint: null, label: this.options.browseLabel }) this.browseBtn.click = (e) => { e.stopPropagation(); e.preventDefault(); this.fileBtn.click() } if(!this.options.noUpload){ this.uploadBtn = new Button(null, { icon: null, severity: 'primary', disabled: false, badge: false, rounded: false, size: null, hint: null, label: this.options.submitLabel, onclick: this.uploadAllFiles.bind(this) }) this.uploadBtn.el.classList = 'uploadBtn custom-class' this.resetBtn = new Button(null, { icon: null, severity: '', disabled: false, badge: false, rounded: false, size: null, hint: null, label: this.options.resetLabel }) this.resetBtn.click = this.clearAllFiles.bind(this) } this.el.appendChild(ui.create(`
${options.allowDrop ? this.options.dndLabel : ''}
`)) this.fileBtn = this.el.querySelector('.file-input') this.fileBtn.setAttribute('accept', [...this.options.allowedMimes, ...this.options.allowedExtensions.map(item => '.'+item)].join(', ')) if(this.options.maxFiles>1) this.fileBtn.setAttribute('multiple', '') this.dropArea = this.el.querySelector('.file-drop-area') this.dropAreaText = this.el.querySelector('.file-drop-area .file-msg') this.dropArea.prepend(this.browseBtn.el) this.previewArea = this.el.querySelector('.file-preview-area') this.buttonsArea = this.el.querySelector('.buttons-area') if(!this.options.noUpload){ this.buttonsArea.append(this.uploadBtn.el) this.buttonsArea.append(this.resetBtn.el) } this.filesList = [] this.fileBtn.addEventListener("change", (e) => { if((!e.target.files) || (e.target.files.length==0)) return if((this.filesList.length + e.target.files.length) > this.options.maxFiles) return this.addFiles(e.target.files) }) if(options.allowDrop) { this.initDnd() this.enableDnd() } else this.disableDnd() this.refreshFilesList() //List still empty but that hides buttons depending on options } initDnd() { window.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); }) window.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); }) this.dropArea.addEventListener('dragenter', ((e) => { e.stopPropagation(); e.preventDefault(); if(this.dndDisabled) return this.dropArea.classList.add('dragover') }).bind(this)); this.dropArea.addEventListener('dragleave', ((e) =>{ e.stopPropagation(); e.preventDefault(); let elBelow = document.elementFromPoint(e.clientX, e.clientY) if( (elBelow.classList.contains('file-drop-area')) || (elBelow.classList.contains('file-msg')) || (elBelow.classList.contains('file-input')) ) return //leaving towards the child, so still over it if(this.dndDisabled) return this.dropArea.classList.remove('dragover') }).bind(this)) this.dropArea.addEventListener('drop', ((e) => { e.stopPropagation(); e.preventDefault(); if(this.dndDisabled) return this.dropArea.classList.remove('dragover') if((!e.dataTransfer) || (!e.dataTransfer.files) || (e.dataTransfer.files.length==0)) return if((this.filesList.length + e.dataTransfer.files.length) > this.options.maxFiles) return this.addFiles(e.dataTransfer.files) }).bind(this)); } disableDnd() { this.dndDisabled = true this.dropAreaText.classList.add('disabled') } enableDnd() { this.dndDisabled = false this.dropAreaText.classList.remove('disabled') } addFiles(files) { for(let file of files) { if((file.type!='') && this.checkFileType(file) && (file.size <= this.options.maxSize)) { this.filesList.push(file) this.onFileAdded(this.filesList.length-1, file) this.refreshFilesList() } else { this.onWrongFileType(file) } } } removeFile(e) { e.stopPropagation(); e.preventDefault(); let el = (e.target.tagName.toLocaleLowerCase()=='button') ? e.target : e.target.parentElement let fileIdx = el.dataset['id'] let [file, ] = this.filesList.splice(fileIdx,1) this.refreshFilesList() this.onFileRemoved(fileIdx, file) } checkFileType(file) { let ext = file.name.substring(file.name.lastIndexOf('.')+1) return((this.options.allowedMimes.indexOf(file.type)>-1) && (this.options.allowedExtensions.indexOf(ext)>-1)) } refreshFilesList() { let html = ''; let itemLine, file, ext, icon, type; for(let fileIdx in this.filesList) { file = this.filesList[fileIdx] ext = file.name.substring(file.name.lastIndexOf('.')+1) if(this.options.mimeTypes.hasOwnProperty(file.type)) { icon = this.options.mimeTypes[file.type].icon type = this.options.mimeTypes[file.type].label } else { icon = 'attachment' type = '".'+ext+'"' } icon = (this.options.mimeTypes.hasOwnProperty(file.type)) ? this.options.mimeTypes[file.type].icon : 'attachment' itemLine = this.fileItemTpl itemLine = itemLine.replace('%{icon}', ``) itemLine = itemLine.replace('%{size}', this.formatSize(file.size)) itemLine = itemLine.replace('%{type}', type) itemLine = itemLine.replace('%{actions}', ` `) itemLine = itemLine.replace('%{name}', file.name) // Do this one last in case some %{xxx} in the name html += itemLine } this.previewArea.innerHTML = html // Caution to myself: Re-creating all avoids multi-click-events this.previewArea.querySelectorAll('.file-remove').forEach((el) => { el.addEventListener('click', this.removeFile.bind(this)) }) if(!this.options.noUpload){ if(this.filesList.length >= this.options.minFiles) this.uploadBtn.disabled = false else this.uploadBtn.disabled = true } if(this.filesList.length < this.options.maxFiles) { this.browseBtn.disabled = false this.enableDnd() } else { this.browseBtn.disabled = true this.disableDnd() } if(!this.options.noUpload){ if(this.filesList.length > 0) this.resetBtn.disabled = false else this.resetBtn.disabled = true } } formatSize(size) { if (!+size) return '0 Bytes' const k = 1024 const units = ['Bytes', 'KB', 'MB', 'GB'] // Over GB, really ? const i = Math.floor(Math.log(size) / Math.log(k)) return `${parseFloat((size / Math.pow(k, i)).toFixed(2))} ${units[i]}` } clearAllFiles(e) { e.stopPropagation(); e.preventDefault(); this.filesList = [] this.refreshFilesList() if(this.options.allowDrop) this.enableDnd() } uploadAllFiles(e) { e.stopPropagation(); e.preventDefault(); if(!this.onBeforeUploadAllStart()) return this.browseBtn.disabled = true this.disableDnd() this.uploadBtn.loading = true this.resetBtn.disabled = true for(let fileIdx in this.filesList) { this.filesList[fileIdx].completed = false this.uploadFile(fileIdx) } this.onUploadAllStart() } async uploadFile(fileIdx) { let file = this.filesList[fileIdx] let actionEl = this.previewArea.querySelector('.file-remove[data-id="'+fileIdx+'"]').parentElement // Check file type --> if img add extension let ext = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase(); if (['jpeg', 'png', 'gif', 'jpg', 'svg'].includes(ext)) { this.options.uploadUrl += '.' + ext; // Add extension } let ok = await this.onBeforeUploadOneStart(file) if((!ok) || (!this.options.uploadUrl)) { this.filesList[fileIdx].completed = true actionEl.innerHTML = 'Failed !' this.uploadOneEnd(fileIdx) return } actionEl.innerHTML = '(0 %)' let xhrRequest = new XMLHttpRequest(); xhrRequest.open(this.options.uploadMethod, this.options.uploadUrl); // Caution: This fucking AWS shit uses Content-Type as mime-type for the file, contrary to the rest of the web. // Yeah, I know multi-files...multi-part etc...but AWS signed URL system is one file at a time anyway. xhrRequest.setRequestHeader('Content-Type', this.filesList[fileIdx].type); xhrRequest.upload.addEventListener("progress", this.uploadProgress.bind(this, fileIdx, actionEl)); xhrRequest.upload.addEventListener("error", ((err) => { this.filesList[fileIdx].completed = true actionEl.innerHTML = 'Failed !' this.uploadOneEnd(fileIdx) }).bind(this)) xhrRequest.upload.addEventListener("timeout", ((err) => { this.filesList[fileIdx].completed = true actionEl.innerHTML = 'Timed-out !' this.uploadOneEnd(fileIdx) }).bind(this)) xhrRequest.send(file); this.onUploadOneStart() } uploadProgress(fileIdx, actionEl, e) { if(!e.lengthComputable) return let percentDone = Math.floor((e.loaded / e.total) * 100); actionEl.innerHTML = `(${percentDone} %)` if(e.loaded == e.total) { this.filesList[fileIdx].completed = true this.uploadOneEnd(fileIdx) } } uploadOneEnd(fileIdx) { this.onUploadOneEnd(this.filesList[fileIdx]) let allFinished = this.filesList.reduce((acc, x)=> (x.completed && acc), true) if(allFinished) { this.uploadBtn.loading = false this.uploadBtn.disabled = true this.resetBtn.disabled = false this.onUploadAllEnd() } } }