index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. const Plugin = require('../../core/Plugin')
  2. const Translator = require('../../core/Translator')
  3. const dragDrop = require('drag-drop')
  4. const DashboardUI = require('./Dashboard')
  5. const StatusBar = require('../StatusBar')
  6. const Informer = require('../Informer')
  7. const ThumbnailGenerator = require('../ThumbnailGenerator')
  8. const { findAllDOMElements, toArray } = require('../../core/Utils')
  9. const prettyBytes = require('prettier-bytes')
  10. const { defaultTabIcon } = require('./icons')
  11. // Some code for managing focus was adopted from https://github.com/ghosh/micromodal
  12. // MIT licence, https://github.com/ghosh/micromodal/blob/master/LICENSE.md
  13. // Copyright (c) 2017 Indrashish Ghosh
  14. const FOCUSABLE_ELEMENTS = [
  15. 'a[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  16. 'area[href]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  17. 'input:not([disabled]):not([inert]):not([aria-hidden])',
  18. 'select:not([disabled]):not([inert]):not([aria-hidden])',
  19. 'textarea:not([disabled]):not([inert]):not([aria-hidden])',
  20. 'button:not([disabled]):not([inert]):not([aria-hidden])',
  21. 'iframe:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  22. 'object:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  23. 'embed:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  24. '[contenteditable]:not([tabindex^="-"]):not([inert]):not([aria-hidden])',
  25. '[tabindex]:not([tabindex^="-"]):not([inert]):not([aria-hidden])'
  26. ]
  27. const TAB_KEY = 9
  28. const ESC_KEY = 27
  29. /**
  30. * Dashboard UI with previews, metadata editing, tabs for various services and more
  31. */
  32. module.exports = class Dashboard extends Plugin {
  33. constructor (uppy, opts) {
  34. super(uppy, opts)
  35. this.id = this.opts.id || 'Dashboard'
  36. this.title = 'Dashboard'
  37. this.type = 'orchestrator'
  38. const defaultLocale = {
  39. strings: {
  40. selectToUpload: 'Select files to upload',
  41. closeModal: 'Close Modal',
  42. upload: 'Upload',
  43. importFrom: 'Import from %{name}',
  44. dashboardWindowTitle: 'Uppy Dashboard Window (Press escape to close)',
  45. dashboardTitle: 'Uppy Dashboard',
  46. copyLinkToClipboardSuccess: 'Link copied to clipboard',
  47. copyLinkToClipboardFallback: 'Copy the URL below',
  48. copyLink: 'Copy link',
  49. fileSource: 'File source: %{name}',
  50. done: 'Done',
  51. name: 'Name',
  52. removeFile: 'Remove file',
  53. editFile: 'Edit file',
  54. editing: 'Editing %{file}',
  55. finishEditingFile: 'Finish editing file',
  56. saveChanges: 'Save changes',
  57. cancel: 'Cancel',
  58. localDisk: 'Local Disk',
  59. myDevice: 'My Device',
  60. dropPasteImport: 'Drop files here, paste, import from one of the locations above or %{browse}',
  61. dropPaste: 'Drop files here, paste or %{browse}',
  62. browse: 'browse',
  63. fileProgress: 'File progress: upload speed and ETA',
  64. numberOfSelectedFiles: 'Number of selected files',
  65. uploadAllNewFiles: 'Upload all new files',
  66. emptyFolderAdded: 'No files were added from empty folder',
  67. uploadComplete: 'Upload complete',
  68. resumeUpload: 'Resume upload',
  69. pauseUpload: 'Pause upload',
  70. retryUpload: 'Retry upload',
  71. uploadXFiles: {
  72. 0: 'Upload %{smart_count} file',
  73. 1: 'Upload %{smart_count} files'
  74. },
  75. uploadXNewFiles: {
  76. 0: 'Upload +%{smart_count} file',
  77. 1: 'Upload +%{smart_count} files'
  78. },
  79. folderAdded: {
  80. 0: 'Added %{smart_count} file from %{folder}',
  81. 1: 'Added %{smart_count} files from %{folder}'
  82. }
  83. }
  84. }
  85. // set default options
  86. const defaultOptions = {
  87. target: 'body',
  88. metaFields: [],
  89. trigger: '#uppy-select-files',
  90. inline: false,
  91. width: 750,
  92. height: 550,
  93. thumbnailWidth: 280,
  94. defaultTabIcon: defaultTabIcon,
  95. showLinkToFileUploadResult: true,
  96. showProgressDetails: false,
  97. hideUploadButton: false,
  98. hideRetryButton: false,
  99. hidePauseResumeCancelButtons: false,
  100. hideProgressAfterFinish: false,
  101. note: null,
  102. closeModalOnClickOutside: false,
  103. disableStatusBar: false,
  104. disableInformer: false,
  105. disableThumbnailGenerator: false,
  106. disablePageScrollWhenModalOpen: true,
  107. animateOpenClose: true,
  108. proudlyDisplayPoweredByUppy: true,
  109. onRequestCloseModal: () => this.closeModal(),
  110. locale: defaultLocale
  111. }
  112. // merge default options with the ones set by user
  113. this.opts = Object.assign({}, defaultOptions, opts)
  114. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  115. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  116. this.translator = new Translator({locale: this.locale})
  117. this.i18n = this.translator.translate.bind(this.translator)
  118. this.i18nArray = this.translator.translateArray.bind(this.translator)
  119. this.openModal = this.openModal.bind(this)
  120. this.closeModal = this.closeModal.bind(this)
  121. this.requestCloseModal = this.requestCloseModal.bind(this)
  122. this.isModalOpen = this.isModalOpen.bind(this)
  123. this.addTarget = this.addTarget.bind(this)
  124. this.removeTarget = this.removeTarget.bind(this)
  125. this.hideAllPanels = this.hideAllPanels.bind(this)
  126. this.showPanel = this.showPanel.bind(this)
  127. this.getFocusableNodes = this.getFocusableNodes.bind(this)
  128. this.setFocusToFirstNode = this.setFocusToFirstNode.bind(this)
  129. this.maintainFocus = this.maintainFocus.bind(this)
  130. this.initEvents = this.initEvents.bind(this)
  131. this.onKeydown = this.onKeydown.bind(this)
  132. this.handleClickOutside = this.handleClickOutside.bind(this)
  133. this.toggleFileCard = this.toggleFileCard.bind(this)
  134. this.handleDrop = this.handleDrop.bind(this)
  135. this.handlePaste = this.handlePaste.bind(this)
  136. this.handleInputChange = this.handleInputChange.bind(this)
  137. this.updateDashboardElWidth = this.updateDashboardElWidth.bind(this)
  138. this.render = this.render.bind(this)
  139. this.install = this.install.bind(this)
  140. }
  141. removeTarget (plugin) {
  142. const pluginState = this.getPluginState()
  143. // filter out the one we want to remove
  144. const newTargets = pluginState.targets.filter(target => target.id !== plugin.id)
  145. this.setPluginState({
  146. targets: newTargets
  147. })
  148. }
  149. addTarget (plugin) {
  150. const callerPluginId = plugin.id || plugin.constructor.name
  151. const callerPluginName = plugin.title || callerPluginId
  152. const callerPluginType = plugin.type
  153. if (callerPluginType !== 'acquirer' &&
  154. callerPluginType !== 'progressindicator' &&
  155. callerPluginType !== 'presenter') {
  156. let msg = 'Dashboard: Modal can only be used by plugins of types: acquirer, progressindicator, presenter'
  157. this.uppy.log(msg)
  158. return
  159. }
  160. const target = {
  161. id: callerPluginId,
  162. name: callerPluginName,
  163. type: callerPluginType
  164. }
  165. const state = this.getPluginState()
  166. const newTargets = state.targets.slice()
  167. newTargets.push(target)
  168. this.setPluginState({
  169. targets: newTargets
  170. })
  171. return this.el
  172. }
  173. hideAllPanels () {
  174. this.setPluginState({
  175. activePanel: false
  176. })
  177. }
  178. showPanel (id) {
  179. const { targets } = this.getPluginState()
  180. const activePanel = targets.filter((target) => {
  181. return target.type === 'acquirer' && target.id === id
  182. })[0]
  183. this.setPluginState({
  184. activePanel: activePanel
  185. })
  186. }
  187. requestCloseModal () {
  188. if (this.opts.onRequestCloseModal) {
  189. return this.opts.onRequestCloseModal()
  190. } else {
  191. this.closeModal()
  192. }
  193. }
  194. getFocusableNodes () {
  195. const nodes = this.el.querySelectorAll(FOCUSABLE_ELEMENTS)
  196. return Object.keys(nodes).map((key) => nodes[key])
  197. }
  198. setFocusToFirstNode () {
  199. const focusableNodes = this.getFocusableNodes()
  200. if (focusableNodes.length) focusableNodes[0].focus()
  201. }
  202. setFocusToBrowse () {
  203. const browseBtn = this.el.querySelector('.uppy-Dashboard-browse')
  204. if (browseBtn) browseBtn.focus()
  205. }
  206. maintainFocus (event) {
  207. var focusableNodes = this.getFocusableNodes()
  208. var focusedItemIndex = focusableNodes.indexOf(document.activeElement)
  209. if (event.shiftKey && focusedItemIndex === 0) {
  210. focusableNodes[focusableNodes.length - 1].focus()
  211. event.preventDefault()
  212. }
  213. if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) {
  214. focusableNodes[0].focus()
  215. event.preventDefault()
  216. }
  217. }
  218. openModal () {
  219. this.setPluginState({
  220. isHidden: false
  221. })
  222. // save scroll position
  223. this.savedScrollPosition = window.scrollY
  224. // save active element, so we can restore focus when modal is closed
  225. this.savedActiveElement = document.activeElement
  226. if (this.opts.disablePageScrollWhenModalOpen) {
  227. document.body.classList.add('uppy-Dashboard-isFixed')
  228. }
  229. // handle ESC and TAB keys in modal dialog
  230. document.addEventListener('keydown', this.onKeydown)
  231. this.rerender(this.uppy.getState())
  232. this.updateDashboardElWidth()
  233. this.setFocusToBrowse()
  234. }
  235. closeModal () {
  236. if (this.opts.disablePageScrollWhenModalOpen) {
  237. document.body.classList.remove('uppy-Dashboard-isFixed')
  238. }
  239. if (this.opts.animateOpenClose) {
  240. this.setPluginState({
  241. isClosing: true
  242. })
  243. const handler = () => {
  244. this.setPluginState({
  245. isHidden: true,
  246. isClosing: false
  247. })
  248. this.el.removeEventListener('animationend', handler, false)
  249. }
  250. this.el.addEventListener('animationend', handler, false)
  251. } else {
  252. this.setPluginState({
  253. isHidden: true
  254. })
  255. }
  256. // handle ESC and TAB keys in modal dialog
  257. document.removeEventListener('keydown', this.onKeydown)
  258. this.savedActiveElement.focus()
  259. }
  260. isModalOpen () {
  261. return !this.getPluginState().isHidden || false
  262. }
  263. onKeydown (event) {
  264. // close modal on esc key press
  265. if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
  266. // maintainFocus on tab key press
  267. if (event.keyCode === TAB_KEY) this.maintainFocus(event)
  268. }
  269. handleClickOutside () {
  270. if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
  271. }
  272. handlePaste (ev) {
  273. const files = toArray(ev.clipboardData.items)
  274. files.forEach((file) => {
  275. if (file.kind !== 'file') return
  276. const blob = file.getAsFile()
  277. if (!blob) {
  278. this.uppy.log('[Dashboard] File pasted, but the file blob is empty')
  279. this.uppy.info('Error pasting file', 'error')
  280. return
  281. }
  282. this.uppy.log('[Dashboard] File pasted')
  283. try {
  284. this.uppy.addFile({
  285. source: this.id,
  286. name: file.name,
  287. type: file.type,
  288. data: blob
  289. })
  290. } catch (err) {
  291. // Nothing, restriction errors handled in Core
  292. }
  293. })
  294. }
  295. handleInputChange (ev) {
  296. ev.preventDefault()
  297. const files = toArray(ev.target.files)
  298. files.forEach((file) => {
  299. try {
  300. this.uppy.addFile({
  301. source: this.id,
  302. name: file.name,
  303. type: file.type,
  304. data: file
  305. })
  306. } catch (err) {
  307. // Nothing, restriction errors handled in Core
  308. }
  309. })
  310. }
  311. initEvents () {
  312. // Modal open button
  313. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  314. if (!this.opts.inline && showModalTrigger) {
  315. showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal))
  316. }
  317. if (!this.opts.inline && !showModalTrigger) {
  318. this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options unless you are planning to call openModal() method yourself')
  319. }
  320. // Drag Drop
  321. this.removeDragDropListener = dragDrop(this.el, (files) => {
  322. this.handleDrop(files)
  323. })
  324. this.updateDashboardElWidth()
  325. window.addEventListener('resize', this.updateDashboardElWidth)
  326. this.uppy.on('plugin-removed', this.removeTarget)
  327. }
  328. removeEvents () {
  329. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  330. if (!this.opts.inline && showModalTrigger) {
  331. showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal))
  332. }
  333. this.removeDragDropListener()
  334. window.removeEventListener('resize', this.updateDashboardElWidth)
  335. this.uppy.off('plugin-removed', this.removeTarget)
  336. }
  337. updateDashboardElWidth () {
  338. const dashboardEl = this.el.querySelector('.uppy-Dashboard-inner')
  339. this.uppy.log(`Dashboard width: ${dashboardEl.offsetWidth}`)
  340. this.setPluginState({
  341. containerWidth: dashboardEl.offsetWidth
  342. })
  343. }
  344. toggleFileCard (fileId) {
  345. this.setPluginState({
  346. fileCardFor: fileId || false
  347. })
  348. }
  349. handleDrop (files) {
  350. this.uppy.log('[Dashboard] Files were dropped')
  351. files.forEach((file) => {
  352. try {
  353. this.uppy.addFile({
  354. source: this.id,
  355. name: file.name,
  356. type: file.type,
  357. data: file
  358. })
  359. } catch (err) {
  360. // Nothing, restriction errors handled in Core
  361. }
  362. })
  363. }
  364. render (state) {
  365. const pluginState = this.getPluginState()
  366. const { files, capabilities } = state
  367. const newFiles = Object.keys(files).filter((file) => {
  368. return !files[file].progress.uploadStarted
  369. })
  370. const inProgressFiles = Object.keys(files).filter((file) => {
  371. return !files[file].progress.uploadComplete &&
  372. files[file].progress.uploadStarted &&
  373. !files[file].isPaused
  374. })
  375. let inProgressFilesArray = []
  376. inProgressFiles.forEach((file) => {
  377. inProgressFilesArray.push(files[file])
  378. })
  379. let totalSize = 0
  380. let totalUploadedSize = 0
  381. inProgressFilesArray.forEach((file) => {
  382. totalSize = totalSize + (file.progress.bytesTotal || 0)
  383. totalUploadedSize = totalUploadedSize + (file.progress.bytesUploaded || 0)
  384. })
  385. totalSize = prettyBytes(totalSize)
  386. totalUploadedSize = prettyBytes(totalUploadedSize)
  387. const attachRenderFunctionToTarget = (target) => {
  388. const plugin = this.uppy.getPlugin(target.id)
  389. return Object.assign({}, target, {
  390. icon: plugin.icon || this.opts.defaultTabIcon,
  391. render: plugin.render
  392. })
  393. }
  394. const isSupported = (target) => {
  395. const plugin = this.uppy.getPlugin(target.id)
  396. // If the plugin does not provide a `supported` check, assume the plugin works everywhere.
  397. if (typeof plugin.isSupported !== 'function') {
  398. return true
  399. }
  400. return plugin.isSupported()
  401. }
  402. const acquirers = pluginState.targets
  403. .filter(target => target.type === 'acquirer' && isSupported(target))
  404. .map(attachRenderFunctionToTarget)
  405. const progressindicators = pluginState.targets
  406. .filter(target => target.type === 'progressindicator')
  407. .map(attachRenderFunctionToTarget)
  408. const startUpload = (ev) => {
  409. this.uppy.upload().catch((err) => {
  410. // Log error.
  411. this.uppy.log(err.stack || err.message || err)
  412. })
  413. }
  414. const cancelUpload = (fileID) => {
  415. this.uppy.emit('upload-cancel', fileID)
  416. this.uppy.removeFile(fileID)
  417. }
  418. const saveFileCard = (meta, fileID) => {
  419. this.uppy.setFileMeta(fileID, meta)
  420. this.toggleFileCard()
  421. }
  422. return DashboardUI({
  423. state: state,
  424. modal: pluginState,
  425. newFiles: newFiles,
  426. files: files,
  427. totalFileCount: Object.keys(files).length,
  428. totalProgress: state.totalProgress,
  429. acquirers: acquirers,
  430. activePanel: pluginState.activePanel,
  431. animateOpenClose: this.opts.animateOpenClose,
  432. isClosing: pluginState.isClosing,
  433. getPlugin: this.uppy.getPlugin,
  434. progressindicators: progressindicators,
  435. autoProceed: this.uppy.opts.autoProceed,
  436. hideUploadButton: this.opts.hideUploadButton,
  437. hideRetryButton: this.opts.hideRetryButton,
  438. hidePauseResumeCancelButtons: this.opts.hidePauseResumeCancelButtons,
  439. id: this.id,
  440. closeModal: this.requestCloseModal,
  441. handleClickOutside: this.handleClickOutside,
  442. handleInputChange: this.handleInputChange,
  443. handlePaste: this.handlePaste,
  444. inline: this.opts.inline,
  445. showPanel: this.showPanel,
  446. hideAllPanels: this.hideAllPanels,
  447. log: this.uppy.log,
  448. i18n: this.i18n,
  449. i18nArray: this.i18nArray,
  450. addFile: this.uppy.addFile,
  451. removeFile: this.uppy.removeFile,
  452. info: this.uppy.info,
  453. note: this.opts.note,
  454. metaFields: pluginState.metaFields,
  455. resumableUploads: capabilities.resumableUploads || false,
  456. bundled: capabilities.bundled || false,
  457. startUpload: startUpload,
  458. pauseUpload: this.uppy.pauseResume,
  459. retryUpload: this.uppy.retryUpload,
  460. cancelUpload: cancelUpload,
  461. fileCardFor: pluginState.fileCardFor,
  462. toggleFileCard: this.toggleFileCard,
  463. saveFileCard: saveFileCard,
  464. updateDashboardElWidth: this.updateDashboardElWidth,
  465. width: this.opts.width,
  466. height: this.opts.height,
  467. showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
  468. proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
  469. currentWidth: pluginState.containerWidth,
  470. isWide: pluginState.containerWidth > 400,
  471. isTargetDOMEl: this.isTargetDOMEl,
  472. allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
  473. maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles
  474. })
  475. }
  476. discoverProviderPlugins () {
  477. this.uppy.iteratePlugins((plugin) => {
  478. if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) {
  479. this.addTarget(plugin)
  480. }
  481. })
  482. }
  483. install () {
  484. // Set default state for Dashboard
  485. this.setPluginState({
  486. isHidden: true,
  487. showFileCard: false,
  488. activePanel: false,
  489. metaFields: this.opts.metaFields,
  490. targets: []
  491. })
  492. const target = this.opts.target
  493. if (target) {
  494. this.mount(target, this)
  495. }
  496. const plugins = this.opts.plugins || []
  497. plugins.forEach((pluginID) => {
  498. const plugin = this.uppy.getPlugin(pluginID)
  499. if (plugin) plugin.mount(this, plugin)
  500. })
  501. if (!this.opts.disableStatusBar) {
  502. this.uppy.use(StatusBar, {
  503. id: `${this.id}:StatusBar`,
  504. target: this,
  505. hideUploadButton: this.opts.hideUploadButton,
  506. hideRetryButton: this.opts.hideRetryButton,
  507. hidePauseResumeCancelButtons: this.opts.hidePauseResumeCancelButtons,
  508. showProgressDetails: this.opts.showProgressDetails,
  509. hideAfterFinish: this.opts.hideProgressAfterFinish,
  510. locale: this.opts.locale
  511. })
  512. }
  513. if (!this.opts.disableInformer) {
  514. this.uppy.use(Informer, {
  515. id: `${this.id}:Informer`,
  516. target: this
  517. })
  518. }
  519. if (!this.opts.disableThumbnailGenerator) {
  520. this.uppy.use(ThumbnailGenerator, {
  521. id: `${this.id}:ThumbnailGenerator`,
  522. thumbnailWidth: this.opts.thumbnailWidth
  523. })
  524. }
  525. this.discoverProviderPlugins()
  526. this.initEvents()
  527. }
  528. uninstall () {
  529. if (!this.opts.disableInformer) {
  530. const informer = this.uppy.getPlugin(`${this.id}:Informer`)
  531. // Checking if this plugin exists, in case it was removed by uppy-core
  532. // before the Dashboard was.
  533. if (informer) this.uppy.removePlugin(informer)
  534. }
  535. if (!this.opts.disableStatusBar) {
  536. const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`)
  537. if (statusBar) this.uppy.removePlugin(statusBar)
  538. }
  539. if (!this.opts.disableThumbnailGenerator) {
  540. const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`)
  541. if (thumbnail) this.uppy.removePlugin(thumbnail)
  542. }
  543. const plugins = this.opts.plugins || []
  544. plugins.forEach((pluginID) => {
  545. const plugin = this.uppy.getPlugin(pluginID)
  546. if (plugin) plugin.unmount()
  547. })
  548. this.unmount()
  549. this.removeEvents()
  550. }
  551. }