Utils.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. const throttle = require('lodash.throttle')
  2. // we inline file-type module, as opposed to using the NPM version,
  3. // because of this https://github.com/sindresorhus/file-type/issues/78
  4. // and https://github.com/sindresorhus/copy-text-to-clipboard/issues/5
  5. const fileType = require('../vendor/file-type')
  6. /**
  7. * A collection of small utility functions that help with dom manipulation, adding listeners,
  8. * promises and other good things.
  9. *
  10. * @module Utils
  11. */
  12. function isTouchDevice () {
  13. return 'ontouchstart' in window || // works on most browsers
  14. navigator.maxTouchPoints // works on IE10/11 and Surface
  15. }
  16. function truncateString (str, length) {
  17. if (str.length > length) {
  18. return str.substr(0, length / 2) + '...' + str.substr(str.length - length / 4, str.length)
  19. }
  20. return str
  21. // more precise version if needed
  22. // http://stackoverflow.com/a/831583
  23. }
  24. function secondsToTime (rawSeconds) {
  25. const hours = Math.floor(rawSeconds / 3600) % 24
  26. const minutes = Math.floor(rawSeconds / 60) % 60
  27. const seconds = Math.floor(rawSeconds % 60)
  28. return { hours, minutes, seconds }
  29. }
  30. /**
  31. * Converts list into array
  32. */
  33. function toArray (list) {
  34. return Array.prototype.slice.call(list || [], 0)
  35. }
  36. /**
  37. * Returns a timestamp in the format of `hours:minutes:seconds`
  38. */
  39. function getTimeStamp () {
  40. var date = new Date()
  41. var hours = pad(date.getHours().toString())
  42. var minutes = pad(date.getMinutes().toString())
  43. var seconds = pad(date.getSeconds().toString())
  44. return hours + ':' + minutes + ':' + seconds
  45. }
  46. /**
  47. * Adds zero to strings shorter than two characters
  48. */
  49. function pad (str) {
  50. return str.length !== 2 ? 0 + str : str
  51. }
  52. /**
  53. * Takes a file object and turns it into fileID, by converting file.name to lowercase,
  54. * removing extra characters and adding type, size and lastModified
  55. *
  56. * @param {Object} file
  57. * @return {String} the fileID
  58. *
  59. */
  60. function generateFileID (file) {
  61. // filter is needed to not join empty values with `-`
  62. return [
  63. 'uppy',
  64. file.name ? file.name.toLowerCase().replace(/[^A-Z0-9]/ig, '') : '',
  65. file.type,
  66. file.data.size,
  67. file.data.lastModified
  68. ].filter(val => val).join('-')
  69. }
  70. /**
  71. * Runs an array of promise-returning functions in sequence.
  72. */
  73. function runPromiseSequence (functions, ...args) {
  74. let promise = Promise.resolve()
  75. functions.forEach((func) => {
  76. promise = promise.then(() => func(...args))
  77. })
  78. return promise
  79. }
  80. function isPreviewSupported (fileTypeSpecific) {
  81. // list of images that browsers can preview
  82. if (/^(jpeg|gif|png|svg|svg\+xml|bmp)$/.test(fileTypeSpecific)) {
  83. return true
  84. }
  85. return false
  86. }
  87. function getArrayBuffer (chunk) {
  88. return new Promise(function (resolve, reject) {
  89. var reader = new FileReader()
  90. reader.addEventListener('load', function (e) {
  91. // e.target.result is an ArrayBuffer
  92. resolve(e.target.result)
  93. })
  94. reader.addEventListener('error', function (err) {
  95. console.error('FileReader error' + err)
  96. reject(err)
  97. })
  98. // file-type only needs the first 4100 bytes
  99. reader.readAsArrayBuffer(chunk)
  100. })
  101. }
  102. function getFileType (file) {
  103. const emptyFileType = ['', '']
  104. const extensionsToMime = {
  105. 'md': 'text/markdown',
  106. 'markdown': 'text/markdown',
  107. 'mp4': 'video/mp4',
  108. 'mp3': 'audio/mp3'
  109. }
  110. const fileExtension = getFileNameAndExtension(file.name)[1]
  111. if (file.isRemote) {
  112. // some providers do not support for file types
  113. let mime = file.type ? file.type : extensionsToMime[fileExtension]
  114. const type = mime ? mime.split('/') : emptyFileType
  115. return Promise.resolve(type)
  116. }
  117. // 1. try to determine file type from magic bytes with file-type module
  118. // this should be the most trustworthy way
  119. const chunk = file.data.slice(0, 4100)
  120. return getArrayBuffer(chunk)
  121. .then((buffer) => {
  122. const type = fileType(buffer)
  123. if (type && type.mime) {
  124. return type.mime.split('/')
  125. }
  126. // 2. if that’s no good, check if mime type is set in the file object
  127. if (file.type) {
  128. return file.type.split('/')
  129. }
  130. // 3. if that’s no good, see if we can map extension to a mime type
  131. if (extensionsToMime[fileExtension]) {
  132. return extensionsToMime[fileExtension].split('/')
  133. }
  134. // if all fails, well, return empty
  135. return emptyFileType
  136. })
  137. .catch(() => {
  138. return emptyFileType
  139. })
  140. // if (file.type) {
  141. // return Promise.resolve(file.type.split('/'))
  142. // }
  143. // return mime.lookup(file.name)
  144. // return file.type ? file.type.split('/') : ['', '']
  145. }
  146. // TODO Check which types are actually supported in browsers. Chrome likes webm
  147. // from my testing, but we may need more.
  148. // We could use a library but they tend to contain dozens of KBs of mappings,
  149. // most of which will go unused, so not sure if that's worth it.
  150. const mimeToExtensions = {
  151. 'video/ogg': 'ogv',
  152. 'audio/ogg': 'ogg',
  153. 'video/webm': 'webm',
  154. 'audio/webm': 'webm',
  155. 'video/mp4': 'mp4',
  156. 'audio/mp3': 'mp3'
  157. }
  158. function getFileTypeExtension (mimeType) {
  159. return mimeToExtensions[mimeType] || null
  160. }
  161. // returns [fileName, fileExt]
  162. function getFileNameAndExtension (fullFileName) {
  163. var re = /(?:\.([^.]+))?$/
  164. var fileExt = re.exec(fullFileName)[1]
  165. var fileName = fullFileName.replace('.' + fileExt, '')
  166. return [fileName, fileExt]
  167. }
  168. function supportsMediaRecorder () {
  169. return typeof MediaRecorder === 'function' && !!MediaRecorder.prototype &&
  170. typeof MediaRecorder.prototype.start === 'function'
  171. }
  172. /**
  173. * Check if a URL string is an object URL from `URL.createObjectURL`.
  174. *
  175. * @param {string} url
  176. * @return {boolean}
  177. */
  178. function isObjectURL (url) {
  179. return url.indexOf('blob:') === 0
  180. }
  181. function getProportionalHeight (img, width) {
  182. const aspect = img.width / img.height
  183. return Math.round(width / aspect)
  184. }
  185. /**
  186. * Create a thumbnail for the given Uppy file object.
  187. *
  188. * @param {{data: Blob}} file
  189. * @param {number} width
  190. * @return {Promise}
  191. */
  192. function createThumbnail (file, targetWidth) {
  193. const originalUrl = URL.createObjectURL(file.data)
  194. const onload = new Promise((resolve, reject) => {
  195. const image = new Image()
  196. image.src = originalUrl
  197. image.onload = () => {
  198. URL.revokeObjectURL(originalUrl)
  199. resolve(image)
  200. }
  201. image.onerror = () => {
  202. // The onerror event is totally useless unfortunately, as far as I know
  203. URL.revokeObjectURL(originalUrl)
  204. reject(new Error('Could not create thumbnail'))
  205. }
  206. })
  207. return onload.then((image) => {
  208. const targetHeight = getProportionalHeight(image, targetWidth)
  209. const canvas = resizeImage(image, targetWidth, targetHeight)
  210. return canvasToBlob(canvas, 'image/png')
  211. }).then((blob) => {
  212. return URL.createObjectURL(blob)
  213. })
  214. }
  215. /**
  216. * Resize an image to the target `width` and `height`.
  217. *
  218. * Returns a Canvas with the resized image on it.
  219. */
  220. function resizeImage (image, targetWidth, targetHeight) {
  221. let sourceWidth = image.width
  222. let sourceHeight = image.height
  223. if (targetHeight < image.height / 2) {
  224. const steps = Math.floor(Math.log(image.width / targetWidth) / Math.log(2))
  225. const stepScaled = downScaleInSteps(image, steps)
  226. image = stepScaled.image
  227. sourceWidth = stepScaled.sourceWidth
  228. sourceHeight = stepScaled.sourceHeight
  229. }
  230. const canvas = document.createElement('canvas')
  231. canvas.width = targetWidth
  232. canvas.height = targetHeight
  233. const context = canvas.getContext('2d')
  234. context.drawImage(image,
  235. 0, 0, sourceWidth, sourceHeight,
  236. 0, 0, targetWidth, targetHeight)
  237. return canvas
  238. }
  239. /**
  240. * Downscale an image by 50% `steps` times.
  241. */
  242. function downScaleInSteps (image, steps) {
  243. let source = image
  244. let currentWidth = source.width
  245. let currentHeight = source.height
  246. for (let i = 0; i < steps; i += 1) {
  247. const canvas = document.createElement('canvas')
  248. const context = canvas.getContext('2d')
  249. canvas.width = currentWidth / 2
  250. canvas.height = currentHeight / 2
  251. context.drawImage(source,
  252. // The entire source image. We pass width and height here,
  253. // because we reuse this canvas, and should only scale down
  254. // the part of the canvas that contains the previous scale step.
  255. 0, 0, currentWidth, currentHeight,
  256. // Draw to 50% size
  257. 0, 0, currentWidth / 2, currentHeight / 2)
  258. currentWidth /= 2
  259. currentHeight /= 2
  260. source = canvas
  261. }
  262. return {
  263. image: source,
  264. sourceWidth: currentWidth,
  265. sourceHeight: currentHeight
  266. }
  267. }
  268. /**
  269. * Save a <canvas> element's content to a Blob object.
  270. *
  271. * @param {HTMLCanvasElement} canvas
  272. * @return {Promise}
  273. */
  274. function canvasToBlob (canvas, type, quality) {
  275. if (canvas.toBlob) {
  276. return new Promise((resolve) => {
  277. canvas.toBlob(resolve, type, quality)
  278. })
  279. }
  280. return Promise.resolve().then(() => {
  281. return dataURItoBlob(canvas.toDataURL(type, quality), {})
  282. })
  283. }
  284. function dataURItoBlob (dataURI, opts, toFile) {
  285. // get the base64 data
  286. var data = dataURI.split(',')[1]
  287. // user may provide mime type, if not get it from data URI
  288. var mimeType = opts.mimeType || dataURI.split(',')[0].split(':')[1].split(';')[0]
  289. // default to plain/text if data URI has no mimeType
  290. if (mimeType == null) {
  291. mimeType = 'plain/text'
  292. }
  293. var binary = atob(data)
  294. var array = []
  295. for (var i = 0; i < binary.length; i++) {
  296. array.push(binary.charCodeAt(i))
  297. }
  298. // Convert to a File?
  299. if (toFile) {
  300. return new File([new Uint8Array(array)], opts.name || '', {type: mimeType})
  301. }
  302. return new Blob([new Uint8Array(array)], {type: mimeType})
  303. }
  304. function dataURItoFile (dataURI, opts) {
  305. return dataURItoBlob(dataURI, opts, true)
  306. }
  307. /**
  308. * Copies text to clipboard by creating an almost invisible textarea,
  309. * adding text there, then running execCommand('copy').
  310. * Falls back to prompt() when the easy way fails (hello, Safari!)
  311. * From http://stackoverflow.com/a/30810322
  312. *
  313. * @param {String} textToCopy
  314. * @param {String} fallbackString
  315. * @return {Promise}
  316. */
  317. function copyToClipboard (textToCopy, fallbackString) {
  318. fallbackString = fallbackString || 'Copy the URL below'
  319. return new Promise((resolve) => {
  320. const textArea = document.createElement('textarea')
  321. textArea.setAttribute('style', {
  322. position: 'fixed',
  323. top: 0,
  324. left: 0,
  325. width: '2em',
  326. height: '2em',
  327. padding: 0,
  328. border: 'none',
  329. outline: 'none',
  330. boxShadow: 'none',
  331. background: 'transparent'
  332. })
  333. textArea.value = textToCopy
  334. document.body.appendChild(textArea)
  335. textArea.select()
  336. const magicCopyFailed = () => {
  337. document.body.removeChild(textArea)
  338. window.prompt(fallbackString, textToCopy)
  339. resolve()
  340. }
  341. try {
  342. const successful = document.execCommand('copy')
  343. if (!successful) {
  344. return magicCopyFailed('copy command unavailable')
  345. }
  346. document.body.removeChild(textArea)
  347. return resolve()
  348. } catch (err) {
  349. document.body.removeChild(textArea)
  350. return magicCopyFailed(err)
  351. }
  352. })
  353. }
  354. function getSpeed (fileProgress) {
  355. if (!fileProgress.bytesUploaded) return 0
  356. const timeElapsed = (new Date()) - fileProgress.uploadStarted
  357. const uploadSpeed = fileProgress.bytesUploaded / (timeElapsed / 1000)
  358. return uploadSpeed
  359. }
  360. function getBytesRemaining (fileProgress) {
  361. return fileProgress.bytesTotal - fileProgress.bytesUploaded
  362. }
  363. function getETA (fileProgress) {
  364. if (!fileProgress.bytesUploaded) return 0
  365. const uploadSpeed = getSpeed(fileProgress)
  366. const bytesRemaining = getBytesRemaining(fileProgress)
  367. const secondsRemaining = Math.round(bytesRemaining / uploadSpeed * 10) / 10
  368. return secondsRemaining
  369. }
  370. function prettyETA (seconds) {
  371. const time = secondsToTime(seconds)
  372. // Only display hours and minutes if they are greater than 0 but always
  373. // display minutes if hours is being displayed
  374. // Display a leading zero if the there is a preceding unit: 1m 05s, but 5s
  375. const hoursStr = time.hours ? time.hours + 'h ' : ''
  376. const minutesVal = time.hours ? ('0' + time.minutes).substr(-2) : time.minutes
  377. const minutesStr = minutesVal ? minutesVal + 'm ' : ''
  378. const secondsVal = minutesVal ? ('0' + time.seconds).substr(-2) : time.seconds
  379. const secondsStr = secondsVal + 's'
  380. return `${hoursStr}${minutesStr}${secondsStr}`
  381. }
  382. /**
  383. * Check if an object is a DOM element. Duck-typing based on `nodeType`.
  384. *
  385. * @param {*} obj
  386. */
  387. function isDOMElement (obj) {
  388. return obj && typeof obj === 'object' && obj.nodeType === Node.ELEMENT_NODE
  389. }
  390. /**
  391. * Find a DOM element.
  392. *
  393. * @param {Node|string} element
  394. * @return {Node|null}
  395. */
  396. function findDOMElement (element) {
  397. if (typeof element === 'string') {
  398. return document.querySelector(element)
  399. }
  400. if (typeof element === 'object' && isDOMElement(element)) {
  401. return element
  402. }
  403. }
  404. /**
  405. * Find one or more DOM elements.
  406. *
  407. * @param {string} element
  408. * @return {Array|null}
  409. */
  410. function findAllDOMElements (element) {
  411. if (typeof element === 'string') {
  412. const elements = [].slice.call(document.querySelectorAll(element))
  413. return elements.length > 0 ? elements : null
  414. }
  415. if (typeof element === 'object' && isDOMElement(element)) {
  416. return [element]
  417. }
  418. }
  419. function getSocketHost (url) {
  420. // get the host domain
  421. var regex = /^(?:https?:\/\/|\/\/)?(?:[^@\n]+@)?(?:www\.)?([^\n]+)/
  422. var host = regex.exec(url)[1]
  423. var socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws'
  424. return `${socketProtocol}://${host}`
  425. }
  426. function _emitSocketProgress (uploader, progressData, file) {
  427. const {progress, bytesUploaded, bytesTotal} = progressData
  428. if (progress) {
  429. uploader.core.log(`Upload progress: ${progress}`)
  430. uploader.core.emitter.emit('core:upload-progress', {
  431. uploader,
  432. id: file.id,
  433. bytesUploaded: bytesUploaded,
  434. bytesTotal: bytesTotal
  435. })
  436. }
  437. }
  438. const emitSocketProgress = throttle(_emitSocketProgress, 300, {leading: true, trailing: true})
  439. function settle (promises) {
  440. const resolutions = []
  441. const rejections = []
  442. function resolved (value) {
  443. resolutions.push(value)
  444. }
  445. function rejected (error) {
  446. rejections.push(error)
  447. }
  448. const wait = Promise.all(
  449. promises.map((promise) => promise.then(resolved, rejected))
  450. )
  451. return wait.then(() => {
  452. if (rejections.length === promises.length) {
  453. // Very ad-hoc multiple-error reporting, should wrap this in a
  454. // CombinedError or whatever kind of error class instead.
  455. const error = rejections[0]
  456. error.errors = rejections
  457. return Promise.reject(error)
  458. }
  459. return {
  460. successful: resolutions,
  461. failed: rejections
  462. }
  463. })
  464. }
  465. module.exports = {
  466. generateFileID,
  467. toArray,
  468. getTimeStamp,
  469. runPromiseSequence,
  470. supportsMediaRecorder,
  471. isTouchDevice,
  472. getFileNameAndExtension,
  473. truncateString,
  474. getFileTypeExtension,
  475. getFileType,
  476. getArrayBuffer,
  477. isPreviewSupported,
  478. isObjectURL,
  479. createThumbnail,
  480. secondsToTime,
  481. dataURItoBlob,
  482. dataURItoFile,
  483. getSpeed,
  484. getBytesRemaining,
  485. getETA,
  486. copyToClipboard,
  487. prettyETA,
  488. findDOMElement,
  489. findAllDOMElements,
  490. getSocketHost,
  491. emitSocketProgress,
  492. settle
  493. }