index.js 20 KB

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