index.js 18 KB

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