/** * -------------------------------------------------------------------------- * Ace (v4.0.0): fileinput.js * Custom styling for default browser file input */ import Util from './util' import EventHandler from './event-handler' /** * ------------------------------------------------------------------------ * Constants * ------------------------------------------------------------------------ */ const NAME = 'aceFileInput' const VERSION = '4.0.0' const DATA_KEY = 'ace.file' const EVENT_KEY = `.${DATA_KEY}` const Event = { CHANGED: `changed${EVENT_KEY}`, INVALID: `invalid${EVENT_KEY}`, CLEAR: `clear${EVENT_KEY}`, PREVIEW_FAILED: `preview_failed${EVENT_KEY}` } const Default = { style: false, persistent: false, container: 'border-1 brc-grey-l2 brc-h-warning-m1', btnChooseClass: 'bgc-default text-white px-2 pt-2 text-90 my-1px mr-1px', btnChangeClass: 'bgc-blue text-white px-2 pt-2 text-90 my-1px mr-1px', btnChooseText: 'Choose', btnChangeText: 'Change', placeholderClass: 'text-grey-m2 px-1', placeholderText: 'No file chosen', placeholderIcon: '', iconClass: 'mx-2px', reset: '', resetText: '', resetIcon: '', droppable: false, thumbnail: false, // large, fit, small previewImage: true, allowExt: null, denyExt: null, allowMime: null, denyMime: null, maxSize: null, previewSize: false, previewWidth: false, previewHeight: false, // callbacks beforeChange: null, fileIcons: { file: '', image: '', video: '', audio: '', document: '', archive: '', code: '' } } const DefaultType = { persistent: 'boolean', style: '(boolean|string)', btn: '(string|undefined)', container: '(string|undefined)', icon: '(string|undefined)', placeholderText: '(string|undefined)', placeholderIcon: '(string|undefined)', btnChooseText: '(string|undefined)', btnChangeText: '(string|undefined)', reset: '(string|undefined)', resetText: '(string|undefined)', resetIcon: '(string|undefined)', droppable: 'boolean', thumbnail: '(boolean|string)', previewImage: 'boolean', allowExt: '(string|null)', denyExt: '(string|null)', allowMime: '(string|null)', denyMime: '(string|null)', maxSize: '(number|null)', previewSize: '(boolean|number)', previewWidth: '(boolean|number)', previewHeight: '(boolean|number)', fileIcons: '(object|null)', // callbacks beforeChange: '(function|null)' } const PreviewError = { FILE_LOAD_FAILED: 1, IMAGE_LOAD_FAILED: 2, THUMBNAIL_FAILED: 3 } class ImagePreviewError extends Error { constructor (message, code) { super(message) this.code = code } } /** * ------------------------------------------------------------------------ * Class Definition * ------------------------------------------------------------------------ */ class FileInput { constructor (element, config) { this.settings = this._getConfig(config) this.settings.fileIcons = { ...Default.fileIcons, ...this.settings.fileIcons || {} } this.fileList = [] this.selectMethod = '' this._hasMultiple = 'multiple' in document.createElement('INPUT') this._hasFileList = 'FileList' in window// file list enabled in modern browsers this._hasFileReader = 'FileReader' in window this._hasFile = 'File' in window this.element = element this.disabled = false this.canReset = true this._hasAcceptAttr = this.element.getAttribute('accept') !== null // if new files selected (via file dialog), handle them EventHandler.off(this.element, 'change.aceFileInput') EventHandler.on(this.element, 'change.aceFileInput', () => { if (this.disabled) return return this._handleOnChange() }) // if a parent form is 'reset', reset UI as well const parentForm = Util.closest(this.element, 'form') if (parentForm) { EventHandler.off(parentForm, 'reset.aceFileInput') EventHandler.on(parentForm, 'reset.aceFileInput', () => { this.resetInputData() this.resetInputUI() }) } const parentLabel = Util.closest(this.element, 'label') let tagName = 'label' if (parentLabel) { parentLabel.classList.add('d-block') tagName = 'span'// if not inside a "LABEL" tag, use "LABEL" tag, otherwise use "SPAN" } this._wrap = Util.wrap(this.element, `<${tagName} class="ace-file-input" />`) this._applySettings() } // Getters static get VERSION () { return VERSION } static get DefaultType () { return DefaultType } _getConfig (config) { config = { ...Default, ...config } if (typeof window.bootstrap !== 'undefined') { window.bootstrap.Util.typeCheckConfig( NAME, config, this.constructor.DefaultType ) } return config } _applySettings () { this._jQueryBS = typeof window.jQuery !== 'undefined' && typeof window.bootstrap !== 'undefined' this._isMulti = this.element.getAttribute('multiple') && this._hasMultiple this._isDropStyle = this.settings.style === 'drop' if (this._isDropStyle) { if (!this.settings.thumbnail) this.settings.thumbnail = 'small' this._wrap.classList.add('ace-file-multiple') } else { this._wrap.classList.remove('ace-file-multiple') } this._wrap.querySelectorAll('*:not([type=file])').forEach((el) => Util.remove(el))// remove all except our input, good for when changing settings const placeholder = `
${this.settings.placeholderIcon || ''} ${this.settings.placeholderText} ` + (!this._isDropStyle ? `${this.settings.btnChooseText}` : '') + '
' Util.after(this.element, `
${placeholder}
`) this.container = this.element.nextElementSibling if (this.settings.reset !== false) { const remove = this.settings.reset.length > 0 ? this.settings.reset : (!this._isDropStyle ? 'position-rc text-danger mr-n25 w-3 radius-2 border-1 brc-h-danger-m4 text-center' : 'position-tr bgc-white text-danger mt-n25 mr-n25 w-4 h-4 text-center pt-2px radius-round border-2 brc-grey-m4 brc-h-danger-m3') const btn = Util.append(this._wrap, `${this.settings.resetIcon}`) if (this.settings.resetText && this._jQueryBS && window.jQuery.fn.tooltip) window.jQuery(btn).tooltip({ container: 'body' }) btn.addEventListener('click', (ev) => { ev.preventDefault() if (!this.canReset) return const event = EventHandler.trigger(this.element, Event.CLEAR) if (event.defaultPrevented) return this.resetInput() }) } if (this.settings.droppable && this._hasFileList) { this._enableFileDrop() } // set 'accept' attribute if not set if (!this._hasAcceptAttr) { this._setAcceptAttr(this.settings.allowExt, this.settings.allowMime) } } showFileList ($files, innerCall) { const files = $files || this.fileList if (!files || !files.length) return /// ////////////////////////////////// if (!this.settings.persistent) { this.resetInputUI() } this.container.classList.add('selected') this.container.querySelectorAll('.ace-file-placeholder').forEach((el) => el.classList.add('d-none')) for (let i = 0; i < files.length; i++) { let filename = '' let format = false if (typeof files[i] === 'string') filename = files[i] else if (this._hasFile && files[i] instanceof window.File) filename = files[i].name.trim() else if (files[i] instanceof Object && Object.prototype.hasOwnProperty.call(files[i], 'name')) { // format & name specified by user (pre-displaying name, etc) filename = files[i].name if (Object.prototype.hasOwnProperty.call(files[i], 'type')) format = files[i].type if (!Object.prototype.hasOwnProperty.call(files[i], 'path')) files[i].path = files[i].name } else continue let index = filename.lastIndexOf('\\') + 1 if (index === 0) index = filename.lastIndexOf('/') + 1 filename = filename.substr(index) if (!format) { if ((/\.(jpe?g|png|gif|svg|bmp|tiff?|webp)$/i).test(filename)) { format = 'image' } else if ((/\.(mpe?g|flv|mov|avi|swf|mp4|mkv|webm|wmv|3gp)$/i).test(filename)) { format = 'video' } else if ((/\.(mp3|ogg|wav|wma|amr|aac)$/i).test(filename)) { format = 'audio' } else if ((/\.(pdf|docx?|rtf|txt)$/i).test(filename)) { format = 'document' } else if ((/\.(zip|rar|tar)$/i).test(filename)) { format = 'archive' } else if ((/\.(html?|js|s?css|less|php|py|aspx?|rb|c|cpp|java|cs)$/i).test(filename)) { format = 'code' } else format = 'file' } const fileIcon = this.settings.fileIcons[format] let className = 'ace-file-item d-flex h-100' if (this.settings.thumbnail) className += ` ${this.settings.thumbnail !== 'small' ? 'flex-column my-2px py-2' : 'mx-1 py-1'} align-items-center` const label = Util.append(this.container, `
${fileIcon} ${filename}` + (!this._isDropStyle ? `${this.settings.btnChangeText}` : '') + '
') const type = (innerCall === true && this._hasFile && files[i] instanceof window.File) ? files[i].type.trim() : '' const canPreview = this.settings.previewImage !== false && this._hasFileReader && this.settings.thumbnail && ((type.length > 0 && type.match('image')) || (type.length === 0 && format === 'image'))// the second one is for older Android's default browser which gives an empty text for file.type if (canPreview) { try { this._previewImage(files[i], label) .catch((result) => { EventHandler.trigger(this.element, Event.PREVIEW_FAILED, { $_previewError: { filename: filename, code: result.code } }) }) } catch (e) { // for IE that doesn't support Promises EventHandler.trigger(this.element, Event.PREVIEW_FAILED, { $_previewError: { filename: filename, code: PreviewError.FILE_LOAD_FAILED } }) } } } } resetInput () { this.resetInputUI() this.resetInputField() this.resetInputData() } resetInputUI () { this.container.querySelectorAll('div:not(.ace-file-placeholder)').forEach((el) => Util.remove(el)) this.container.querySelectorAll('.ace-file-placeholder').forEach((el) => el.classList.remove('d-none')) this.container.classList.remove('selected') // hides 'close' button this.stopLoading() } resetInputField () { this.element.value = '' this.element.type = '' this.element.type = 'file' } resetInputData () { this.fileList = [] this.selectMethod = '' if (this._jQueryBS && window.jQuery(this.element).data('ace_input_files')) { window.jQuery(this.element).removeData('ace_input_files') window.jQuery(this.element).removeData('ace_input_method') } } enableReset () { this.canReset = true } disableReset () { this.canReset = false } disable () { this.disabled = true this.element.setAttribute('disabled', 'disabled') this.element.classList.add('disabled') } enable () { this.disabled = false this.element.removeAttribute('disabled') this.element.classList.remove('disabled') } files () { return this.fileList.length > 0 ? this.fileList : null } method () { return this.selectMethod } updateSettings (newSettings) { this.settings = { ...this.settings, ...newSettings || {} } this.settings.fileIcons = { ...this.settings.fileIcons, ...newSettings.fileIcons || {} } this._applySettings() } startLoading (loadingHtml = '') { let loader = this._wrap.querySelector('.ace-file-overlay') if (loader === null) { loader = Util.append(this._wrap, '
') EventHandler.on(loader, 'click', (ev) => { ev.stopImmediatePropagation() ev.preventDefault() }) this.element.setAttribute('readonly', 'true')// for IE } loader.innerHTML = loadingHtml } stopLoading () { const loader = this._wrap.querySelector('.ace-file-overlay') if (loader === null) { this.element.removeAttribute('readonly') return } EventHandler.off(loader, 'click') Util.remove(loader) } _enableFileDrop () { const dropbox = this.element.parentNode EventHandler.off(dropbox, 'dragenter') EventHandler.on(dropbox, 'dragenter', (e) => { e.preventDefault() e.stopPropagation() }) EventHandler.off(dropbox, 'dragover') EventHandler.on(dropbox, 'dragover', (e) => { e.preventDefault() e.stopPropagation() }) EventHandler.off(dropbox, 'drop') EventHandler.on(dropbox, 'drop', (e) => { e.preventDefault() e.stopPropagation() if (this.disabled) return const dt = e.dataTransfer let tmpFileList = dt.files if (!this._isMulti && tmpFileList.length > 1) { // single file upload, but dragged multiple files const tmpfiles = [] tmpfiles.push(tmpFileList[0]) tmpFileList = tmpfiles// keep only first file } tmpFileList = this._processFiles(tmpFileList, true)// true means files have been dropped if (tmpFileList === false) return false this.selectMethod = 'drop' // const fileArray = [...tmpFileList] const fileArray = [] for (let f = 0; f < tmpFileList.length; f++) fileArray.push(tmpFileList[f]) if (this.settings.persistent) { this.fileList = this.fileList.concat(fileArray) } else { this.fileList = fileArray } if (this._jQueryBS) { window.jQuery(this.element).data('ace_input_files', this.fileList) window.jQuery(this.element).data('ace_input_method', this.selectMethod) } this.showFileList(fileArray, true) EventHandler.trigger(this.element, Event.CHANGED, { $_selectedFiles: { list: this.fileList, method: this.selectMethod } }) return true }) } /// /////////// _handleOnChange () { let tmpFileList = this.element.files || [this.element.value]// make it an array tmpFileList = this._processFiles(tmpFileList, false)// false means files have been selected, not dropped if (tmpFileList === false) return false // const fileArray = [...tmpFileList]; const fileArray = [] for (let f = 0; f < tmpFileList.length; f++) fileArray.push(tmpFileList[f]) this.selectMethod = 'select' if (this.settings.persistent) { this.fileList = this.fileList.concat(fileArray) } else { this.fileList = fileArray } if (this._jQueryBS) { window.jQuery(this.element).data('ace_input_files', this.fileList) window.jQuery(this.element).data('ace_input_method', this.selectMethod) } this.showFileList(fileArray, true) EventHandler.trigger(this.element, Event.CHANGED, { $_selectedFiles: { list: this.fileList, method: this.selectMethod } }) return true } _previewImage (file, label) { return new Promise((resolve, reject) => { const icon = label.querySelector('.ace-file-icon')// it should be out of onload, otherwise all onloads may target the same $icon because of delays if (icon) Util.empty(icon) const getImage = function (src, $file) { const img = Util.prepend(icon, "") // no error/load event is triggered in some browsers such as firefox mobile // so we wait a while and then reject the promise let waitTimer = setTimeout(() => { waitTimer = null removeEvents() imgFailed(img) }, 6000) const onLoad = () => { removeEvents() imgLoaded(img, $file) } const onError = () => { removeEvents() imgFailed(img) } const removeEvents = () => { if (waitTimer) { clearTimeout(waitTimer) waitTimer = null } img.removeEventListener('load', onLoad) // call only once img.removeEventListener('error', onError) // remove the other one too } img.addEventListener('load', onLoad) img.addEventListener('error', onError) img.src = src } const imgLoaded = (img, $file) => { // if image loaded successfully let size = this.settings.previewSize if (!size) { if (this.settings.previewWidth || this.settings.previewHeight) { size = { previewWidth: this.settings.previewWidth, previewHeight: this.settings.previewHeight } } else { size = 50 if (this.settings.thumbnail === 'large') size = 150 } } if (this.settings.thumbnail === 'fit') size = icon.parentNode.offsetWidth else if (typeof size === 'number') size = parseInt(Math.min(size, icon.parentNode.offsetWidth)) const svg = /svg/.test($file.type) const thumb = !svg ? this._getThumbnail(img, size, $file.type) : false//, file.type; if (thumb === null) { // if making thumbnail fails Util.remove(img) reject(new ImagePreviewError('Thumbnail Failed', PreviewError.THUMBNAIL_FAILED)) return } const showPreview = true if (showPreview) { if (svg) { if (this.settings.thumbnail === 'small') { img.style.width = size + 'px' } else { if (img.width > img.height) { img.style.width = size + 'px' } else { img.style.height = size + 'px' } } } else { let w = thumb.previewWidth let h = thumb.previewHeight if (this.settings.thumbnail === 'small') { w = h = parseInt(Math.max(w, h)) } else { icon.classList.add('thumbnail-large') } img.style.background = `url(${thumb.src}) center no-repeat` img.style.width = w + 'px' img.style.height = h + 'px' img.setAttribute('data-src', thumb.src) img.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==' } img.style.display = '' } /// //////////////// resolve() } const imgFailed = function (img) { // for example when a file has image extenstion, but format is something else Util.remove(img) reject(new ImagePreviewError('Image Load Failed', PreviewError.IMAGE_LOAD_FAILED)) } if (this._hasFile && file instanceof window.File) { const reader = new window.FileReader() reader.onload = function (e) { getImage(e.target.result, file) } reader.onerror = function (e) { reject(new ImagePreviewError('File Load Failed', PreviewError.FILE_LOAD_FAILED)) } reader.readAsDataURL(file) } else { if (file instanceof Object && Object.prototype.hasOwnProperty.call(file, 'path')) { getImage(file.path, file)// file is a file name (path) --- this is used to pre-show user-selected image } } }) } /// ////////// _getThumbnail (img, size, type) { let imgWidth = img.width let imgHeight = img.height //* *IE10** is not giving correct width using img.width imgWidth = imgWidth > 0 ? imgWidth : img.offsetWidth imgHeight = imgHeight > 0 ? imgHeight : img.offsetHeight let previewSize = false let previewHeight = false let previewWidth = false if (typeof size === 'number') previewSize = size else if (size instanceof Object) { if (size.previewWidth && !size.previewHeight) previewWidth = size.previewWidth else if (size.previewHeight && !size.previewWidth) previewHeight = size.previewHeight else if (size.previewWidth && size.previewHeight) { previewWidth = size.previewWidth previewHeight = size.previewHeight } } if (previewSize) { if (imgWidth > imgHeight) { previewWidth = previewSize previewHeight = parseInt(imgHeight / imgWidth * previewWidth) } else { previewHeight = previewSize previewWidth = parseInt(imgWidth / imgHeight * previewHeight) } } else { if (!previewHeight && previewWidth) { previewHeight = parseInt(imgHeight / imgWidth * previewWidth) } else if (previewHeight && !previewWidth) { previewWidth = parseInt(imgWidth / imgHeight * previewHeight) } } let dataURL try { const canvas = document.createElement('canvas') canvas.width = previewWidth canvas.height = previewHeight const context = canvas.getContext('2d') context.imageSmoothingQuality = 'medium' context.drawImage(img, 0, 0, imgWidth, imgHeight, 0, 0, previewWidth, previewHeight) dataURL = canvas.toDataURL(type, 0.8) // dataURL = canvas.toDataURL(); } catch (e) { dataURL = null } if (!dataURL) return null // there was only one image that failed in firefox completely randomly! so let's double check things if (!(/^data:image\/(png|jpe?g|gif|svg);base64,[0-9A-Za-z+/=]+$/.test(dataURL))) dataURL = null if (!dataURL) return null return { src: dataURL, previewWidth: previewWidth, previewHeight: previewHeight, width: imgWidth, height: imgHeight } } _processFiles (tmpFileList, dropped) { let ret = this._checkFileList(tmpFileList, dropped) if (ret === -1) { this.resetInput() return false } if (!ret || ret.length === 0) { if (this.fileList.length === 0) this.resetInput() // if nothing selected before, reset because of the newly unacceptable (ret=false||length=0) selection // otherwise leave the previous selection intact?!!! return false } if (ret instanceof Array || (this._hasFileList && ret instanceof window.FileList)) tmpFileList = ret ret = true if (this.settings.beforeChange) ret = this.settings.beforeChange.call(this.element, tmpFileList, dropped) if (ret === -1) { this.resetInput() return false } if (!ret || ret.length === 0) { if (this.fileList.length === 0) this.resetInput() return false } // inside beforeChange you can return a modified File Array as result if (ret instanceof Array || (this._hasFileList && ret instanceof window.FileList)) tmpFileList = ret return tmpFileList } /// /////// _checkFileList (files, dropped) { const allowExt = this._getExtRegex(this.settings.allowExt) const denyExt = this._getExtRegex(this.settings.denyExt) const allowMime = this._getMimeRegex(this.settings.allowMime) const denyMime = this._getMimeRegex(this.settings.denyMime) const maxSize = this.settings.maxSize || false if (!(allowExt || denyExt || allowMime || denyMime || maxSize)) return true// no checking required const safeFiles = [] const errorList = {} // for (const file of files) { for (let i = 0; i < files.length; i++) { const file = files[i] // file is either a string(file name) or a File object const filename = !this._hasFile ? file : file.name if (allowExt && !allowExt.test(filename)) { // extension not matching whitelist, so drop it if (!('ext' in errorList)) errorList.ext = [] errorList.ext.push(filename) continue } else if (denyExt && denyExt.test(filename)) { // extension is matching blacklist, so drop it if (!('ext' in errorList)) errorList.ext = [] errorList.ext.push(filename) continue } let type if (!this._hasFile) { // in browsers that don't support FileReader API safeFiles.push(file) continue } else if ((type = file.type.trim()).length > 0) { // there is a mimetype for file so let's check against are rules if (allowMime && !allowMime.test(type)) { // mimeType is not matching whitelist, so drop it if (!('mime' in errorList)) errorList.mime = [] errorList.mime.push(filename) continue } else if (denyMime && denyMime.test(type)) { // mimeType is matching blacklist, so drop it if (!('mime' in errorList)) errorList.mime = [] errorList.mime.push(filename) continue } } if (maxSize && file.size > maxSize) { // file size is not acceptable if (!('size' in errorList)) errorList.size = [] errorList.size.push(filename) continue } safeFiles.push(file) } if (safeFiles.length === files.length) return files// return original file list if all are valid /// ////// const errorCount = { ext: 0, mime: 0, size: 0 } if ('ext' in errorList) errorCount.ext = errorList.ext.length if ('mime' in errorList) errorCount.mime = errorList.mime.length if ('size' in errorList) errorCount.size = errorList.size.length const event = EventHandler.trigger(this.element, Event.INVALID, { $_fileErrors: { fileCount: files.length, invalidCount: files.length - safeFiles.length, errorList: errorList, errorCount: errorCount, dropped: dropped } } ) if (event.defaultPrevented) return -1// it will reset input /// /////// return safeFiles// return safeFiles } _setAcceptAttr (ext = '', mime = '') { if (ext) { if (Array.isArray(ext)) ext = ext.join(',.') else ext = ext.replace(/\|/g, ',.') ext = '.' + ext } if (mime) { if (Array.isArray(mime)) mime = mime.join(',') // replace `/\w+` with `/*` ... for example, `image/\w+` becomes `image/*` else mime = mime.replace(/\|/g, ',').replace(/\/\\w+/g, '/*') } let accept = (ext || '') + (ext && mime ? ',' : '') + (mime || '') accept = accept.replace(/\s/g, '') if (accept) this.element.setAttribute('accept', accept) } _getExtRegex (ext) { if (!ext) return null if (Array.isArray(ext)) ext = ext.join('|') if (ext.length === 0) return null return new RegExp('\\.(?:' + ext + ')$', 'i') } _getMimeRegex (mime) { if (!mime) return null if (Array.isArray(mime)) mime = mime.join('|') if (mime.length === 0) return null // replace `/*` with `/\w+` ... for example, `image/*` becomes `image/\w+` return new RegExp('^(?:' + mime.replace(/\/\*/g, '/\\w+').replace(/\//g, '\\/') + ')$', 'i') } // Static methods static getInstance (element, config = null) { if (!element) throw new Error('element for Fileinput is null') const name = `__${NAME}__` if (typeof element[name] !== 'undefined') return element[name] element[name] = new FileInput(element, config) return element[name] } static _jQueryInterface (config, value) { let retval const reteach = this.each(function () { if (!Util.matches(this, 'input[type=file]')) return const $this = window.jQuery(this) let data = $this.data(DATA_KEY) const _config = { ...Default, ...$this.data(), ...typeof config === 'object' && config ? config : {} } if (!data) { data = FileInput.getInstance(this, _config) $this.data(DATA_KEY, data) } if (typeof config === 'string') { if (typeof data[config] === 'undefined') { throw new TypeError(`No method named "${config}"`) } retval = data[config](value) } }) return (typeof retval !== 'undefined') ? retval : reteach } } /** * ------------------------------------------------------------------------ * jQuery * ------------------------------------------------------------------------ */ if (typeof jQuery !== 'undefined') { const $ = window.jQuery const JQUERY_NO_CONFLICT = $.fn[NAME] $.fn[NAME] = FileInput._jQueryInterface $.fn[NAME].Constructor = FileInput $.fn[NAME].noConflict = () => { $.fn[NAME] = JQUERY_NO_CONFLICT return FileInput._jQueryInterface } } export default FileInput