quasar-framework
Version:
Build responsive SPA, SSR, PWA, Hybrid Mobile Apps and Electron apps, all simultaneously using the same codebase
627 lines (582 loc) • 16.6 kB
JavaScript
import QInputFrame from '../input-frame/QInputFrame.js'
import FrameMixin from '../../mixins/input-frame.js'
import { humanStorageSize } from '../../utils/format.js'
import QSpinner from '../spinner/QSpinner.js'
import QIcon from '../icon/QIcon.js'
import QProgress from '../progress/QProgress.js'
import QList from '../list/QList.js'
import QItem from '../list/QItem.js'
import QItemSide from '../list/QItemSide.js'
import QItemMain from '../list/QItemMain.js'
import QItemTile from '../list/QItemTile.js'
import QSlideTransition from '../slide-transition/QSlideTransition.js'
import { stopAndPrevent } from '../../utils/event.js'
function initFile (file) {
file.__doneUploading = false
file.__failed = false
file.__uploaded = 0
file.__progress = 0
}
export default {
name: 'QUploader',
mixins: [FrameMixin],
props: {
name: {
type: String,
default: 'file'
},
headers: Object,
url: {
type: String,
required: true
},
urlFactory: {
type: Function,
required: false
},
uploadFactory: Function,
additionalFields: {
type: Array,
default: () => []
},
noContentType: Boolean,
method: {
type: String,
default: 'POST'
},
filter: Function,
extensions: String,
multiple: Boolean,
hideUploadButton: Boolean,
hideUploadProgress: Boolean,
noThumbnails: Boolean,
autoExpand: Boolean,
expandStyle: [Array, String, Object],
expandClass: [Array, String, Object],
withCredentials: Boolean,
sendRaw: {
type: Boolean,
default: false
}
},
data () {
return {
queue: [],
files: [],
uploading: false,
uploadedSize: 0,
totalSize: 0,
xhrs: [],
focused: false,
dnd: false,
expanded: false
}
},
computed: {
queueLength () {
return this.queue.length
},
hasExpandedContent () {
return this.files.length > 0
},
label () {
const total = humanStorageSize(this.totalSize)
return this.uploading
? `${(this.progress).toFixed(2)}% (${humanStorageSize(this.uploadedSize)} / ${total})`
: `${this.queueLength} (${total})`
},
progress () {
return this.totalSize ? Math.min(99.99, this.uploadedSize / this.totalSize * 100) : 0
},
addDisabled () {
return this.disable || (!this.multiple && this.queueLength >= 1)
},
filesStyle () {
if (this.maxHeight) {
return { maxHeight: this.maxHeight }
}
},
dndClass () {
const cls = [`text-${this.color}`]
if (this.isInverted) {
cls.push('inverted')
}
return cls
},
classes () {
return {
'q-uploader-expanded': this.expanded,
'q-uploader-dark': this.dark,
'q-uploader-files-no-border': this.isInverted || !this.hideUnderline
}
},
progressColor () {
return this.dark ? 'white' : 'grey'
},
computedExtensions () {
if (this.extensions) {
return this.extensions.split(',').map(ext => {
ext = ext.trim()
// support "image/*"
if (ext.endsWith('/*')) {
ext = ext.slice(0, ext.length - 1)
}
return ext
})
}
}
},
watch: {
hasExpandedContent (v) {
if (v === false) {
this.expanded = false
}
else if (this.autoExpand) {
this.expanded = true
}
}
},
methods: {
add (files) {
if (files) {
this.__add(null, files)
}
},
__onDragOver (e) {
stopAndPrevent(e)
this.dnd = true
},
__onDragLeave (e) {
stopAndPrevent(e)
this.dnd = false
},
__onDrop (e) {
stopAndPrevent(e)
this.dnd = false
let files = e.dataTransfer.files
if (files.length === 0) {
return
}
files = this.multiple ? files : [ files[0] ]
this.__add(null, files)
},
__filter (files) {
return Array.prototype.filter.call(files, file => {
return this.computedExtensions.some(ext => {
return file.type.toUpperCase().startsWith(ext.toUpperCase()) ||
file.name.toUpperCase().endsWith(ext.toUpperCase())
})
})
},
__add (e, files) {
if (this.addDisabled) {
return
}
files = Array.prototype.slice.call(files || e.target.files)
if (this.extensions) {
files = this.__filter(files)
if (files.length === 0) {
return
}
}
this.$refs.file.value = ''
let filesReady = [] // List of image load promises
files = files.filter(file => !this.queue.some(f => file.name === f.name))
if (typeof this.filter === 'function') {
files = this.filter(files)
}
files = files.map(file => {
initFile(file)
file.__size = humanStorageSize(file.size)
file.__timestamp = new Date().getTime()
if (this.noThumbnails || !file.type.toUpperCase().startsWith('IMAGE')) {
this.queue.push(file)
}
else {
const reader = new FileReader()
let p = new Promise((resolve, reject) => {
reader.onload = e => {
let img = new Image()
img.src = e.target.result
file.__img = img
this.queue.push(file)
this.__computeTotalSize()
resolve(true)
}
reader.onerror = e => { reject(e) }
})
reader.readAsDataURL(file)
filesReady.push(p)
}
return file
})
if (files.length > 0) {
this.files = this.files.concat(files)
Promise.all(filesReady).then(() => {
this.$emit('add', files)
})
this.__computeTotalSize()
}
},
__computeTotalSize () {
this.totalSize = this.queueLength
? this.queue.map(f => f.size).reduce((total, size) => total + size)
: 0
},
__remove (file) {
const
name = file.name,
done = file.__doneUploading
if (this.uploading && !done) {
this.$emit('remove:abort', file, file.xhr)
file.xhr && file.xhr.abort()
this.uploadedSize -= file.__uploaded
}
else {
this.$emit(`remove:${done ? 'done' : 'cancel'}`, file, file.xhr)
}
if (!done) {
this.queue = this.queue.filter(obj => obj.name !== name)
}
file.__removed = true
this.files = this.files.filter(obj => obj.name !== name)
if (!this.files.length) {
this.uploading = false
}
this.__computeTotalSize()
},
__pick () {
if (!this.addDisabled && this.$q.platform.is.mozilla) {
this.$refs.file.click()
}
},
__getUploadPromise (file) {
initFile(file)
if (this.uploadFactory) {
const updateProgress = (percentage) => {
let uploaded = percentage * file.size
this.uploadedSize += uploaded - file.__uploaded
file.__uploaded = uploaded
file.__progress = Math.min(99, parseInt(percentage * 100, 10))
this.$forceUpdate()
}
return new Promise((resolve, reject) => {
this.uploadFactory(file, updateProgress)
.then(file => {
file.__doneUploading = true
file.__progress = 100
this.$emit('uploaded', file)
this.$forceUpdate()
resolve(file)
})
.catch(error => {
file.__failed = true
this.$emit('fail', file)
this.$forceUpdate()
reject(error)
})
})
}
const
form = new FormData(),
xhr = new XMLHttpRequest()
try {
this.additionalFields.forEach(field => {
form.append(field.name, field.value)
})
if (this.noContentType !== true) {
form.append('Content-Type', file.type || 'application/octet-stream')
}
form.append(this.name, file)
}
catch (e) {
return
}
file.xhr = xhr
return new Promise((resolve, reject) => {
xhr.upload.addEventListener('progress', e => {
if (file.__removed) { return }
e.percent = e.total ? e.loaded / e.total : 0
let uploaded = e.percent * file.size
this.uploadedSize += uploaded - file.__uploaded
file.__uploaded = uploaded
file.__progress = Math.min(99, parseInt(e.percent * 100, 10))
}, false)
xhr.onreadystatechange = () => {
if (xhr.readyState < 4) {
return
}
if (xhr.status && xhr.status < 400) {
file.__doneUploading = true
file.__progress = 100
this.$emit('uploaded', file, xhr)
resolve(file)
}
else {
file.__failed = true
this.$emit('fail', file, xhr)
reject(xhr)
}
}
xhr.onerror = () => {
file.__failed = true
this.$emit('fail', file, xhr)
reject(xhr)
}
const resolver = this.urlFactory
? this.urlFactory(file)
: Promise.resolve(this.url)
resolver.then(url => {
xhr.open(this.method, url, true)
if (this.withCredentials) {
xhr.withCredentials = true
}
if (this.headers) {
Object.keys(this.headers).forEach(key => {
xhr.setRequestHeader(key, this.headers[key])
})
}
this.xhrs.push(xhr)
if (this.sendRaw) {
xhr.send(file)
}
else {
xhr.send(form)
}
})
})
},
pick () {
if (!this.addDisabled) {
this.$refs.file.click()
}
},
upload () {
const length = this.queueLength
if (this.disable || length === 0) {
return
}
let filesDone = 0
this.uploadedSize = 0
this.uploading = true
this.xhrs = []
this.$emit('start')
let solved = () => {
filesDone++
if (filesDone === length) {
this.uploading = false
this.xhrs = []
this.queue = this.queue.filter(f => !f.__doneUploading)
this.__computeTotalSize()
this.$emit('finish')
}
}
this.queue
.map(file => this.__getUploadPromise(file))
.forEach(promise => {
promise.then(solved).catch(solved)
})
},
abort () {
this.xhrs.forEach(xhr => { xhr.abort() })
this.uploading = false
this.$emit('abort')
},
reset () {
this.abort()
this.files = []
this.queue = []
this.expanded = false
this.__computeTotalSize()
this.$emit('reset')
}
},
render (h) {
const child = [
h('div', {
staticClass: 'col q-input-target ellipsis',
'class': this.alignClass
}, [ this.label ])
]
if (this.uploading) {
child.push(
this.$slots.loading
? h('div', {
slot: 'after',
staticClass: 'q-if-end self-center q-if-control'
}, this.$slots.loading)
: h(QSpinner, {
slot: 'after',
staticClass: 'q-if-end self-center',
props: { size: '24px' }
}),
h(QIcon, {
slot: 'after',
staticClass: 'q-if-end self-center q-if-control',
props: {
name: this.$q.icon.uploader[`clear${this.isInverted ? 'Inverted' : ''}`]
},
nativeOn: {
click: this.abort
}
})
)
}
else { // not uploading
child.push(
h(QIcon, {
slot: 'after',
staticClass: 'q-uploader-pick-button self-center q-if-control relative-position overflow-hidden',
props: {
name: this.$q.icon.uploader.add
},
attrs: {
disabled: this.addDisabled
}
}, [
h('input', {
ref: 'file',
staticClass: 'q-uploader-input absolute-full cursor-pointer',
attrs: Object.assign({
type: 'file',
accept: this.extensions
}, this.multiple ? { multiple: true } : {}),
on: {
change: this.__add
}
})
])
)
if (!this.hideUploadButton) {
child.push(
h(QIcon, {
slot: 'after',
staticClass: 'q-if-control self-center',
props: {
name: this.$q.icon.uploader.upload
},
attrs: {
disabled: this.queueLength === 0
},
nativeOn: {
click: this.upload
}
})
)
}
}
if (this.hasExpandedContent) {
child.push(
h(QIcon, {
slot: 'after',
staticClass: 'q-if-control generic_transition self-center',
'class': { 'rotate-180': this.expanded },
props: {
name: this.$q.icon.uploader.expand
},
nativeOn: {
click: () => { this.expanded = !this.expanded }
}
})
)
}
return h('div', {
staticClass: 'q-uploader relative-position',
'class': this.classes,
on: { dragover: this.__onDragOver }
}, [
h(QInputFrame, {
ref: 'input',
props: {
prefix: this.prefix,
suffix: this.suffix,
stackLabel: this.stackLabel,
floatLabel: this.floatLabel,
error: this.error,
warning: this.warning,
readonly: this.readonly,
inverted: this.inverted,
invertedLight: this.invertedLight,
dark: this.dark,
hideUnderline: this.hideUnderline,
before: this.before,
after: this.after,
color: this.color,
align: this.align,
noParentField: this.noParentField,
length: this.queueLength,
additionalLength: true
}
}, child),
h(QSlideTransition, [
h('div', {
'class': this.expandClass,
style: this.expandStyle,
directives: [{
name: 'show',
value: this.expanded
}]
}, [
h(QList, {
staticClass: 'q-uploader-files q-py-none scroll',
style: this.filesStyle,
props: { dark: this.dark }
}, this.files.map(file => {
return h(QItem, {
key: file.name + file.__timestamp,
staticClass: 'q-uploader-file q-pa-xs'
}, [
(!this.hideUploadProgress && h(QProgress, {
staticClass: 'q-uploader-progress-bg absolute-full',
props: {
color: file.__failed ? 'negative' : this.progressColor,
percentage: file.__progress,
height: '100%'
}
})) || void 0,
(!this.hideUploadProgress && h('div', {
staticClass: 'q-uploader-progress-text absolute'
}, [ file.__progress + '%' ])) || void 0,
h(QItemSide, {
props: file.__img
? { image: file.__img.src }
: {
icon: this.$q.icon.uploader.file,
color: this.color
}
}),
h(QItemMain, {
props: {
label: file.name,
sublabel: file.__size
}
}),
h(QItemSide, { props: { right: true } }, [
h(QItemTile, {
staticClass: 'cursor-pointer',
props: {
icon: this.$q.icon.uploader[file.__doneUploading ? 'done' : 'clear'],
color: this.color
},
nativeOn: {
click: () => { this.__remove(file) }
}
})
])
])
}))
])
]),
(this.dnd && h('div', {
staticClass: 'q-uploader-dnd flex row items-center justify-center absolute-full',
'class': this.dndClass,
on: {
dragenter: stopAndPrevent,
dragover: stopAndPrevent,
dragleave: this.__onDragLeave,
drop: this.__onDrop
}
})) || void 0
])
}
}