uppy
Version:
Extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:
597 lines (524 loc) • 15.5 kB
JavaScript
const throttle = require('lodash.throttle')
// we inline file-type module, as opposed to using the NPM version,
// because of this https://github.com/sindresorhus/file-type/issues/78
// and https://github.com/sindresorhus/copy-text-to-clipboard/issues/5
const fileType = require('../vendor/file-type')
/**
* A collection of small utility functions that help with dom manipulation, adding listeners,
* promises and other good things.
*
* @module Utils
*/
function isTouchDevice () {
return 'ontouchstart' in window || // works on most browsers
navigator.maxTouchPoints // works on IE10/11 and Surface
}
function truncateString (str, length) {
if (str.length > length) {
return str.substr(0, length / 2) + '...' + str.substr(str.length - length / 4, str.length)
}
return str
// more precise version if needed
// http://stackoverflow.com/a/831583
}
function secondsToTime (rawSeconds) {
const hours = Math.floor(rawSeconds / 3600) % 24
const minutes = Math.floor(rawSeconds / 60) % 60
const seconds = Math.floor(rawSeconds % 60)
return { hours, minutes, seconds }
}
/**
* Converts list into array
*/
function toArray (list) {
return Array.prototype.slice.call(list || [], 0)
}
/**
* Returns a timestamp in the format of `hours:minutes:seconds`
*/
function getTimeStamp () {
var date = new Date()
var hours = pad(date.getHours().toString())
var minutes = pad(date.getMinutes().toString())
var seconds = pad(date.getSeconds().toString())
return hours + ':' + minutes + ':' + seconds
}
/**
* Adds zero to strings shorter than two characters
*/
function pad (str) {
return str.length !== 2 ? 0 + str : str
}
/**
* Takes a file object and turns it into fileID, by converting file.name to lowercase,
* removing extra characters and adding type, size and lastModified
*
* @param {Object} file
* @return {String} the fileID
*
*/
function generateFileID (file) {
// filter is needed to not join empty values with `-`
return [
'uppy',
file.name ? file.name.toLowerCase().replace(/[^A-Z0-9]/ig, '') : '',
file.type,
file.data.size,
file.data.lastModified
].filter(val => val).join('-')
}
/**
* Runs an array of promise-returning functions in sequence.
*/
function runPromiseSequence (functions, ...args) {
let promise = Promise.resolve()
functions.forEach((func) => {
promise = promise.then(() => func(...args))
})
return promise
}
function isPreviewSupported (fileType) {
if (!fileType) return false
const fileTypeSpecific = fileType.split('/')[1]
// list of images that browsers can preview
if (/^(jpeg|gif|png|svg|svg\+xml|bmp)$/.test(fileTypeSpecific)) {
return true
}
return false
}
function getArrayBuffer (chunk) {
return new Promise(function (resolve, reject) {
var reader = new FileReader()
reader.addEventListener('load', function (e) {
// e.target.result is an ArrayBuffer
resolve(e.target.result)
})
reader.addEventListener('error', function (err) {
console.error('FileReader error' + err)
reject(err)
})
// file-type only needs the first 4100 bytes
reader.readAsArrayBuffer(chunk)
})
}
function getFileType (file) {
const extensionsToMime = {
'md': 'text/markdown',
'markdown': 'text/markdown',
'mp4': 'video/mp4',
'mp3': 'audio/mp3',
'svg': 'image/svg+xml',
'jpg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif'
}
const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
if (file.isRemote) {
// some remote providers do not support file types
const mime = file.type ? file.type : extensionsToMime[fileExtension]
return Promise.resolve(mime)
}
// 1. try to determine file type from magic bytes with file-type module
// this should be the most trustworthy way
const chunk = file.data.slice(0, 4100)
return getArrayBuffer(chunk)
.then((buffer) => {
const type = fileType(buffer)
if (type && type.mime) {
return type.mime
}
// 2. if that’s no good, check if mime type is set in the file object
if (file.type) {
return file.type
}
// 3. if that’s no good, see if we can map extension to a mime type
if (fileExtension && extensionsToMime[fileExtension]) {
return extensionsToMime[fileExtension]
}
// if all fails, well, return empty
return null
})
.catch(() => {
return null
})
}
// TODO Check which types are actually supported in browsers. Chrome likes webm
// from my testing, but we may need more.
// We could use a library but they tend to contain dozens of KBs of mappings,
// most of which will go unused, so not sure if that's worth it.
const mimeToExtensions = {
'video/ogg': 'ogv',
'audio/ogg': 'ogg',
'video/webm': 'webm',
'audio/webm': 'webm',
'video/mp4': 'mp4',
'audio/mp3': 'mp3'
}
function getFileTypeExtension (mimeType) {
return mimeToExtensions[mimeType] || null
}
/**
* Takes a full filename string and returns an object {name, extension}
*
* @param {string} fullFileName
* @return {object} {name, extension}
*/
function getFileNameAndExtension (fullFileName) {
var re = /(?:\.([^.]+))?$/
var fileExt = re.exec(fullFileName)[1]
var fileName = fullFileName.replace('.' + fileExt, '')
return {
name: fileName,
extension: fileExt
}
}
/**
* Check if a URL string is an object URL from `URL.createObjectURL`.
*
* @param {string} url
* @return {boolean}
*/
function isObjectURL (url) {
return url.indexOf('blob:') === 0
}
function getProportionalHeight (img, width) {
const aspect = img.width / img.height
return Math.round(width / aspect)
}
/**
* Create a thumbnail for the given Uppy file object.
*
* @param {{data: Blob}} file
* @param {number} width
* @return {Promise}
*/
function createThumbnail (file, targetWidth) {
const originalUrl = URL.createObjectURL(file.data)
const onload = new Promise((resolve, reject) => {
const image = new Image()
image.src = originalUrl
image.onload = () => {
URL.revokeObjectURL(originalUrl)
resolve(image)
}
image.onerror = () => {
// The onerror event is totally useless unfortunately, as far as I know
URL.revokeObjectURL(originalUrl)
reject(new Error('Could not create thumbnail'))
}
})
return onload.then((image) => {
const targetHeight = getProportionalHeight(image, targetWidth)
const canvas = resizeImage(image, targetWidth, targetHeight)
return canvasToBlob(canvas, 'image/png')
}).then((blob) => {
return URL.createObjectURL(blob)
})
}
/**
* Resize an image to the target `width` and `height`.
*
* Returns a Canvas with the resized image on it.
*/
function resizeImage (image, targetWidth, targetHeight) {
let sourceWidth = image.width
let sourceHeight = image.height
if (targetHeight < image.height / 2) {
const steps = Math.floor(Math.log(image.width / targetWidth) / Math.log(2))
const stepScaled = downScaleInSteps(image, steps)
image = stepScaled.image
sourceWidth = stepScaled.sourceWidth
sourceHeight = stepScaled.sourceHeight
}
const canvas = document.createElement('canvas')
canvas.width = targetWidth
canvas.height = targetHeight
const context = canvas.getContext('2d')
context.drawImage(image,
0, 0, sourceWidth, sourceHeight,
0, 0, targetWidth, targetHeight)
return canvas
}
/**
* Downscale an image by 50% `steps` times.
*/
function downScaleInSteps (image, steps) {
let source = image
let currentWidth = source.width
let currentHeight = source.height
for (let i = 0; i < steps; i += 1) {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = currentWidth / 2
canvas.height = currentHeight / 2
context.drawImage(source,
// The entire source image. We pass width and height here,
// because we reuse this canvas, and should only scale down
// the part of the canvas that contains the previous scale step.
0, 0, currentWidth, currentHeight,
// Draw to 50% size
0, 0, currentWidth / 2, currentHeight / 2)
currentWidth /= 2
currentHeight /= 2
source = canvas
}
return {
image: source,
sourceWidth: currentWidth,
sourceHeight: currentHeight
}
}
/**
* Save a <canvas> element's content to a Blob object.
*
* @param {HTMLCanvasElement} canvas
* @return {Promise}
*/
function canvasToBlob (canvas, type, quality) {
if (canvas.toBlob) {
return new Promise((resolve) => {
canvas.toBlob(resolve, type, quality)
})
}
return Promise.resolve().then(() => {
return dataURItoBlob(canvas.toDataURL(type, quality), {})
})
}
function dataURItoBlob (dataURI, opts, toFile) {
// get the base64 data
var data = dataURI.split(',')[1]
// user may provide mime type, if not get it from data URI
var mimeType = opts.mimeType || dataURI.split(',')[0].split(':')[1].split(';')[0]
// default to plain/text if data URI has no mimeType
if (mimeType == null) {
mimeType = 'plain/text'
}
var binary = atob(data)
var array = []
for (var i = 0; i < binary.length; i++) {
array.push(binary.charCodeAt(i))
}
// Convert to a File?
if (toFile) {
return new File([new Uint8Array(array)], opts.name || '', {type: mimeType})
}
return new Blob([new Uint8Array(array)], {type: mimeType})
}
function dataURItoFile (dataURI, opts) {
return dataURItoBlob(dataURI, opts, true)
}
/**
* Copies text to clipboard by creating an almost invisible textarea,
* adding text there, then running execCommand('copy').
* Falls back to prompt() when the easy way fails (hello, Safari!)
* From http://stackoverflow.com/a/30810322
*
* @param {String} textToCopy
* @param {String} fallbackString
* @return {Promise}
*/
function copyToClipboard (textToCopy, fallbackString) {
fallbackString = fallbackString || 'Copy the URL below'
return new Promise((resolve) => {
const textArea = document.createElement('textarea')
textArea.setAttribute('style', {
position: 'fixed',
top: 0,
left: 0,
width: '2em',
height: '2em',
padding: 0,
border: 'none',
outline: 'none',
boxShadow: 'none',
background: 'transparent'
})
textArea.value = textToCopy
document.body.appendChild(textArea)
textArea.select()
const magicCopyFailed = () => {
document.body.removeChild(textArea)
window.prompt(fallbackString, textToCopy)
resolve()
}
try {
const successful = document.execCommand('copy')
if (!successful) {
return magicCopyFailed('copy command unavailable')
}
document.body.removeChild(textArea)
return resolve()
} catch (err) {
document.body.removeChild(textArea)
return magicCopyFailed(err)
}
})
}
function getSpeed (fileProgress) {
if (!fileProgress.bytesUploaded) return 0
const timeElapsed = (new Date()) - fileProgress.uploadStarted
const uploadSpeed = fileProgress.bytesUploaded / (timeElapsed / 1000)
return uploadSpeed
}
function getBytesRemaining (fileProgress) {
return fileProgress.bytesTotal - fileProgress.bytesUploaded
}
function getETA (fileProgress) {
if (!fileProgress.bytesUploaded) return 0
const uploadSpeed = getSpeed(fileProgress)
const bytesRemaining = getBytesRemaining(fileProgress)
const secondsRemaining = Math.round(bytesRemaining / uploadSpeed * 10) / 10
return secondsRemaining
}
function prettyETA (seconds) {
const time = secondsToTime(seconds)
// Only display hours and minutes if they are greater than 0 but always
// display minutes if hours is being displayed
// Display a leading zero if the there is a preceding unit: 1m 05s, but 5s
const hoursStr = time.hours ? time.hours + 'h ' : ''
const minutesVal = time.hours ? ('0' + time.minutes).substr(-2) : time.minutes
const minutesStr = minutesVal ? minutesVal + 'm ' : ''
const secondsVal = minutesVal ? ('0' + time.seconds).substr(-2) : time.seconds
const secondsStr = secondsVal + 's'
return `${hoursStr}${minutesStr}${secondsStr}`
}
/**
* Check if an object is a DOM element. Duck-typing based on `nodeType`.
*
* @param {*} obj
*/
function isDOMElement (obj) {
return obj && typeof obj === 'object' && obj.nodeType === Node.ELEMENT_NODE
}
/**
* Find a DOM element.
*
* @param {Node|string} element
* @return {Node|null}
*/
function findDOMElement (element) {
if (typeof element === 'string') {
return document.querySelector(element)
}
if (typeof element === 'object' && isDOMElement(element)) {
return element
}
}
/**
* Find one or more DOM elements.
*
* @param {string} element
* @return {Array|null}
*/
function findAllDOMElements (element) {
if (typeof element === 'string') {
const elements = [].slice.call(document.querySelectorAll(element))
return elements.length > 0 ? elements : null
}
if (typeof element === 'object' && isDOMElement(element)) {
return [element]
}
}
function getSocketHost (url) {
// get the host domain
var regex = /^(?:https?:\/\/|\/\/)?(?:[^@\n]+@)?(?:www\.)?([^\n]+)/
var host = regex.exec(url)[1]
var socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${socketProtocol}://${host}`
}
function _emitSocketProgress (uploader, progressData, file) {
const { progress, bytesUploaded, bytesTotal } = progressData
if (progress) {
uploader.uppy.log(`Upload progress: ${progress}`)
uploader.uppy.emit('upload-progress', file, {
uploader,
bytesUploaded: bytesUploaded,
bytesTotal: bytesTotal
})
}
}
const emitSocketProgress = throttle(_emitSocketProgress, 300, {leading: true, trailing: true})
function settle (promises) {
const resolutions = []
const rejections = []
function resolved (value) {
resolutions.push(value)
}
function rejected (error) {
rejections.push(error)
}
const wait = Promise.all(
promises.map((promise) => promise.then(resolved, rejected))
)
return wait.then(() => {
return {
successful: resolutions,
failed: rejections
}
})
}
/**
* Limit the amount of simultaneously pending Promises.
* Returns a function that, when passed a function `fn`,
* will make sure that at most `limit` calls to `fn` are pending.
*
* @param {number} limit
* @return {function()}
*/
function limitPromises (limit) {
let pending = 0
const queue = []
return (fn) => {
return (...args) => {
const call = () => {
pending++
const promise = fn(...args)
promise.then(onfinish, onfinish)
return promise
}
if (pending >= limit) {
return new Promise((resolve, reject) => {
queue.push(() => {
call().then(resolve, reject)
})
})
}
return call()
}
}
function onfinish () {
pending--
const next = queue.shift()
if (next) next()
}
}
module.exports = {
generateFileID,
toArray,
getTimeStamp,
runPromiseSequence,
isTouchDevice,
getFileNameAndExtension,
truncateString,
getFileTypeExtension,
getFileType,
getArrayBuffer,
isPreviewSupported,
isObjectURL,
createThumbnail,
secondsToTime,
dataURItoBlob,
dataURItoFile,
canvasToBlob,
getSpeed,
getBytesRemaining,
getETA,
copyToClipboard,
prettyETA,
findDOMElement,
findAllDOMElements,
getSocketHost,
emitSocketProgress,
settle,
limitPromises
}