mirror of
https://github.com/transloadit/uppy.git
synced 2026-07-23 18:29:09 +00:00
603 lines
16 KiB
JavaScript
603 lines
16 KiB
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
|
||
*/
|
||
|
||
/**
|
||
* Shallow flatten nested arrays.
|
||
*/
|
||
function flatten (arr) {
|
||
return [].concat.apply([], arr)
|
||
}
|
||
|
||
function isTouchDevice () {
|
||
return 'ontouchstart' in window || // works on most browsers
|
||
navigator.maxTouchPoints // works on IE10/11 and Surface
|
||
}
|
||
|
||
// /**
|
||
// * Shorter and fast way to select a single node in the DOM
|
||
// * @param { String } selector - unique dom selector
|
||
// * @param { Object } ctx - DOM node where the target of our search will is located
|
||
// * @returns { Object } dom node found
|
||
// */
|
||
// function $ (selector, ctx) {
|
||
// return (ctx || document).querySelector(selector)
|
||
// }
|
||
|
||
// /**
|
||
// * Shorter and fast way to select multiple nodes in the DOM
|
||
// * @param { String|Array } selector - DOM selector or nodes list
|
||
// * @param { Object } ctx - DOM node where the targets of our search will is located
|
||
// * @returns { Object } dom nodes found
|
||
// */
|
||
// function $$ (selector, ctx) {
|
||
// var els
|
||
// if (typeof selector === 'string') {
|
||
// els = (ctx || document).querySelectorAll(selector)
|
||
// } else {
|
||
// els = selector
|
||
// return Array.prototype.slice.call(els)
|
||
// }
|
||
// }
|
||
|
||
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 }
|
||
}
|
||
|
||
/**
|
||
* Partition array by a grouping function.
|
||
* @param {[type]} array Input array
|
||
* @param {[type]} groupingFn Grouping function
|
||
* @return {[type]} Array of arrays
|
||
*/
|
||
function groupBy (array, groupingFn) {
|
||
return array.reduce((result, item) => {
|
||
let key = groupingFn(item)
|
||
let xs = result.get(key) || []
|
||
xs.push(item)
|
||
result.set(key, xs)
|
||
return result
|
||
}, new Map())
|
||
}
|
||
|
||
/**
|
||
* Tests if every array element passes predicate
|
||
* @param {Array} array Input array
|
||
* @param {Object} predicateFn Predicate
|
||
* @return {bool} Every element pass
|
||
*/
|
||
function every (array, predicateFn) {
|
||
return array.reduce((result, item) => {
|
||
if (!result) {
|
||
return false
|
||
}
|
||
|
||
return predicateFn(item)
|
||
}, true)
|
||
}
|
||
|
||
/**
|
||
* Converts list into array
|
||
*/
|
||
function toArray (list) {
|
||
return Array.prototype.slice.call(list || [], 0)
|
||
}
|
||
|
||
/**
|
||
* 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('-')
|
||
}
|
||
|
||
function extend (...objs) {
|
||
return Object.assign.apply(this, [{}].concat(objs))
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/**
|
||
* Takes function or class, returns its name.
|
||
* Because IE doesn’t support `constructor.name`.
|
||
* https://gist.github.com/dfkaye/6384439, http://stackoverflow.com/a/15714445
|
||
*
|
||
* @param {Object} fn — function
|
||
*
|
||
*/
|
||
// function getFnName (fn) {
|
||
// var f = typeof fn === 'function'
|
||
// var s = f && ((fn.name && ['', fn.name]) || fn.toString().match(/function ([^\(]+)/))
|
||
// return (!f && 'not a function') || (s && s[1] || 'anonymous')
|
||
// }
|
||
|
||
function isPreviewSupported (fileTypeSpecific) {
|
||
// 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 emptyFileType = ['', '']
|
||
const extensionsToMime = {
|
||
'md': 'text/markdown',
|
||
'markdown': 'text/markdown',
|
||
'mp4': 'video/mp4',
|
||
'mp3': 'audio/mp3'
|
||
}
|
||
|
||
// no smart detection for remote files, just trust the provider
|
||
if (file.isRemote) {
|
||
return Promise.resolve(file.type.split('/'))
|
||
}
|
||
|
||
const fileExtension = getFileNameAndExtension(file.name)[1]
|
||
|
||
// 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.split('/')
|
||
}
|
||
|
||
// 2. if that’s no good, check if mime type is set in the file object
|
||
if (file.type) {
|
||
return file.type.split('/')
|
||
}
|
||
|
||
// 3. if that’s no good, see if we can map extension to a mime type
|
||
if (extensionsToMime[fileExtension]) {
|
||
return extensionsToMime[fileExtension].split('/')
|
||
}
|
||
|
||
// if all fails, well, return empty
|
||
return emptyFileType
|
||
})
|
||
.catch(() => {
|
||
return emptyFileType
|
||
})
|
||
|
||
// if (file.type) {
|
||
// return Promise.resolve(file.type.split('/'))
|
||
// }
|
||
// return mime.lookup(file.name)
|
||
// return file.type ? file.type.split('/') : ['', '']
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// returns [fileName, fileExt]
|
||
function getFileNameAndExtension (fullFileName) {
|
||
var re = /(?:\.([^.]+))?$/
|
||
var fileExt = re.exec(fullFileName)[1]
|
||
var fileName = fullFileName.replace('.' + fileExt, '')
|
||
return [fileName, fileExt]
|
||
}
|
||
|
||
function supportsMediaRecorder () {
|
||
return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype &&
|
||
typeof MediaRecorder.prototype.start === 'function'
|
||
}
|
||
|
||
/**
|
||
* 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/jpeg')
|
||
}).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
|
||
|
||
const canvas = document.createElement('canvas')
|
||
const context = canvas.getContext('2d')
|
||
canvas.width = currentWidth / 2
|
||
canvas.height = currentHeight / 2
|
||
|
||
for (let i = 0; i < steps; i += 1) {
|
||
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: canvas,
|
||
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, reject) => {
|
||
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 = (err) => {
|
||
document.body.removeChild(textArea)
|
||
window.prompt(fallbackString, textToCopy)
|
||
return reject('Oops, unable to copy displayed fallback prompt: ' + err)
|
||
}
|
||
|
||
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.core.log(`Upload progress: ${progress}`)
|
||
uploader.core.emitter.emit('core:upload-progress', {
|
||
uploader,
|
||
id: file.id,
|
||
bytesUploaded: bytesUploaded,
|
||
bytesTotal: bytesTotal
|
||
})
|
||
}
|
||
}
|
||
|
||
const emitSocketProgress = throttle(_emitSocketProgress, 300, {leading: true, trailing: true})
|
||
|
||
module.exports = {
|
||
generateFileID,
|
||
toArray,
|
||
every,
|
||
flatten,
|
||
groupBy,
|
||
extend,
|
||
runPromiseSequence,
|
||
supportsMediaRecorder,
|
||
isTouchDevice,
|
||
getFileNameAndExtension,
|
||
truncateString,
|
||
getFileTypeExtension,
|
||
getFileType,
|
||
getArrayBuffer,
|
||
isPreviewSupported,
|
||
isObjectURL,
|
||
createThumbnail,
|
||
secondsToTime,
|
||
dataURItoBlob,
|
||
dataURItoFile,
|
||
getSpeed,
|
||
getBytesRemaining,
|
||
getETA,
|
||
copyToClipboard,
|
||
prettyETA,
|
||
findDOMElement,
|
||
findAllDOMElements,
|
||
getSocketHost,
|
||
emitSocketProgress
|
||
}
|