index.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  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',
  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',
  48. done: 'Done',
  49. name: 'Name',
  50. removeFile: 'Remove file',
  51. editFile: 'Edit file',
  52. editing: 'Editing',
  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',
  59. dropPaste: 'Drop files here, paste or',
  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. hideProgressAfterFinish: false,
  97. note: null,
  98. closeModalOnClickOutside: false,
  99. disableStatusBar: false,
  100. disableInformer: false,
  101. disableThumbnailGenerator: false,
  102. disablePageScrollWhenModalOpen: true,
  103. proudlyDisplayPoweredByUppy: true,
  104. onRequestCloseModal: () => this.closeModal(),
  105. locale: defaultLocale
  106. }
  107. // merge default options with the ones set by user
  108. this.opts = Object.assign({}, defaultOptions, opts)
  109. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  110. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  111. this.translator = new Translator({locale: this.locale})
  112. this.i18n = this.translator.translate.bind(this.translator)
  113. this.openModal = this.openModal.bind(this)
  114. this.closeModal = this.closeModal.bind(this)
  115. this.requestCloseModal = this.requestCloseModal.bind(this)
  116. this.isModalOpen = this.isModalOpen.bind(this)
  117. this.addTarget = this.addTarget.bind(this)
  118. this.hideAllPanels = this.hideAllPanels.bind(this)
  119. this.showPanel = this.showPanel.bind(this)
  120. this.getFocusableNodes = this.getFocusableNodes.bind(this)
  121. this.setFocusToFirstNode = this.setFocusToFirstNode.bind(this)
  122. this.maintainFocus = this.maintainFocus.bind(this)
  123. this.initEvents = this.initEvents.bind(this)
  124. this.onKeydown = this.onKeydown.bind(this)
  125. this.handleClickOutside = this.handleClickOutside.bind(this)
  126. this.toggleFileCard = this.toggleFileCard.bind(this)
  127. this.handleDrop = this.handleDrop.bind(this)
  128. this.handlePaste = this.handlePaste.bind(this)
  129. this.handleInputChange = this.handleInputChange.bind(this)
  130. this.updateDashboardElWidth = this.updateDashboardElWidth.bind(this)
  131. this.render = this.render.bind(this)
  132. this.install = this.install.bind(this)
  133. }
  134. addTarget (plugin) {
  135. const callerPluginId = plugin.id || plugin.constructor.name
  136. const callerPluginName = plugin.title || callerPluginId
  137. const callerPluginType = plugin.type
  138. if (callerPluginType !== 'acquirer' &&
  139. callerPluginType !== 'progressindicator' &&
  140. callerPluginType !== 'presenter') {
  141. let msg = 'Dashboard: Modal can only be used by plugins of types: acquirer, progressindicator, presenter'
  142. this.uppy.log(msg)
  143. return
  144. }
  145. const target = {
  146. id: callerPluginId,
  147. name: callerPluginName,
  148. type: callerPluginType
  149. }
  150. const state = this.getPluginState()
  151. const newTargets = state.targets.slice()
  152. newTargets.push(target)
  153. this.setPluginState({
  154. targets: newTargets
  155. })
  156. return this.el
  157. }
  158. hideAllPanels () {
  159. this.setPluginState({
  160. activePanel: false
  161. })
  162. }
  163. showPanel (id) {
  164. const { targets } = this.getPluginState()
  165. const activePanel = targets.filter((target) => {
  166. return target.type === 'acquirer' && target.id === id
  167. })[0]
  168. this.setPluginState({
  169. activePanel: activePanel
  170. })
  171. }
  172. requestCloseModal () {
  173. if (this.opts.onRequestCloseModal) {
  174. return this.opts.onRequestCloseModal()
  175. } else {
  176. this.closeModal()
  177. }
  178. }
  179. getFocusableNodes () {
  180. const nodes = this.el.querySelectorAll(FOCUSABLE_ELEMENTS)
  181. return Object.keys(nodes).map((key) => nodes[key])
  182. }
  183. setFocusToFirstNode () {
  184. const focusableNodes = this.getFocusableNodes()
  185. if (focusableNodes.length) focusableNodes[0].focus()
  186. }
  187. setFocusToBrowse () {
  188. const browseBtn = this.el.querySelector('.uppy-Dashboard-browse')
  189. if (browseBtn) browseBtn.focus()
  190. }
  191. maintainFocus (event) {
  192. var focusableNodes = this.getFocusableNodes()
  193. var focusedItemIndex = focusableNodes.indexOf(document.activeElement)
  194. if (event.shiftKey && focusedItemIndex === 0) {
  195. focusableNodes[focusableNodes.length - 1].focus()
  196. event.preventDefault()
  197. }
  198. if (!event.shiftKey && focusedItemIndex === focusableNodes.length - 1) {
  199. focusableNodes[0].focus()
  200. event.preventDefault()
  201. }
  202. }
  203. openModal () {
  204. this.setPluginState({
  205. isHidden: false
  206. })
  207. // save scroll position
  208. this.savedScrollPosition = window.scrollY
  209. // save active element, so we can restore focus when modal is closed
  210. this.savedActiveElement = document.activeElement
  211. if (this.opts.disablePageScrollWhenModalOpen) {
  212. document.body.classList.add('uppy-Dashboard-isOpen')
  213. }
  214. this.rerender()
  215. this.updateDashboardElWidth()
  216. this.setFocusToBrowse()
  217. }
  218. closeModal () {
  219. this.setPluginState({
  220. isHidden: true
  221. })
  222. if (this.opts.disablePageScrollWhenModalOpen) {
  223. document.body.classList.remove('uppy-Dashboard-isOpen')
  224. }
  225. this.savedActiveElement.focus()
  226. }
  227. isModalOpen () {
  228. return !this.getPluginState().isHidden || false
  229. }
  230. onKeydown (event) {
  231. // close modal on esc key press
  232. if (event.keyCode === 27) this.requestCloseModal(event)
  233. // maintainFocus on tab key press
  234. if (event.keyCode === 9) this.maintainFocus(event)
  235. }
  236. handleClickOutside () {
  237. if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
  238. }
  239. handlePaste (ev) {
  240. const files = toArray(ev.clipboardData.items)
  241. files.forEach((file) => {
  242. if (file.kind !== 'file') return
  243. const blob = file.getAsFile()
  244. if (!blob) {
  245. this.uppy.log('[Dashboard] File pasted, but the file blob is empty')
  246. this.uppy.info('Error pasting file', 'error')
  247. return
  248. }
  249. this.uppy.log('[Dashboard] File pasted')
  250. this.uppy.addFile({
  251. source: this.id,
  252. name: file.name,
  253. type: file.type,
  254. data: blob
  255. })
  256. })
  257. }
  258. handleInputChange (ev) {
  259. ev.preventDefault()
  260. const files = toArray(ev.target.files)
  261. files.forEach((file) => {
  262. this.uppy.addFile({
  263. source: this.id,
  264. name: file.name,
  265. type: file.type,
  266. data: file
  267. })
  268. })
  269. }
  270. initEvents () {
  271. // Modal open button
  272. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  273. if (!this.opts.inline && showModalTrigger) {
  274. showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal))
  275. }
  276. if (!this.opts.inline && !showModalTrigger) {
  277. 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')
  278. }
  279. if (!this.opts.inline) {
  280. document.addEventListener('keydown', this.onKeydown)
  281. }
  282. // Drag Drop
  283. this.removeDragDropListener = dragDrop(this.el, (files) => {
  284. this.handleDrop(files)
  285. })
  286. this.updateDashboardElWidth()
  287. window.addEventListener('resize', this.updateDashboardElWidth)
  288. }
  289. removeEvents () {
  290. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  291. if (!this.opts.inline && showModalTrigger) {
  292. showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal))
  293. }
  294. if (!this.opts.inline) {
  295. document.removeEventListener('keydown', this.onKeydown)
  296. }
  297. this.removeDragDropListener()
  298. window.removeEventListener('resize', this.updateDashboardElWidth)
  299. }
  300. updateDashboardElWidth () {
  301. const dashboardEl = this.el.querySelector('.uppy-Dashboard-inner')
  302. this.uppy.log(`Dashboard width: ${dashboardEl.offsetWidth}`)
  303. this.setPluginState({
  304. containerWidth: dashboardEl.offsetWidth
  305. })
  306. }
  307. toggleFileCard (fileId) {
  308. this.setPluginState({
  309. fileCardFor: fileId || false
  310. })
  311. }
  312. handleDrop (files) {
  313. this.uppy.log('[Dashboard] Files were dropped')
  314. console.log(files)
  315. files.forEach((file) => {
  316. this.uppy.addFile({
  317. source: this.id,
  318. name: file.name,
  319. type: file.type,
  320. data: file
  321. })
  322. })
  323. }
  324. render (state) {
  325. const pluginState = this.getPluginState()
  326. const files = state.files
  327. const newFiles = Object.keys(files).filter((file) => {
  328. return !files[file].progress.uploadStarted
  329. })
  330. const inProgressFiles = Object.keys(files).filter((file) => {
  331. return !files[file].progress.uploadComplete &&
  332. files[file].progress.uploadStarted &&
  333. !files[file].isPaused
  334. })
  335. let inProgressFilesArray = []
  336. inProgressFiles.forEach((file) => {
  337. inProgressFilesArray.push(files[file])
  338. })
  339. let totalSize = 0
  340. let totalUploadedSize = 0
  341. inProgressFilesArray.forEach((file) => {
  342. totalSize = totalSize + (file.progress.bytesTotal || 0)
  343. totalUploadedSize = totalUploadedSize + (file.progress.bytesUploaded || 0)
  344. })
  345. totalSize = prettyBytes(totalSize)
  346. totalUploadedSize = prettyBytes(totalUploadedSize)
  347. const attachRenderFunctionToTarget = (target) => {
  348. const plugin = this.uppy.getPlugin(target.id)
  349. return Object.assign({}, target, {
  350. icon: plugin.icon || this.opts.defaultTabIcon,
  351. render: plugin.render
  352. })
  353. }
  354. const isSupported = (target) => {
  355. const plugin = this.uppy.getPlugin(target.id)
  356. // If the plugin does not provide a `supported` check, assume the plugin works everywhere.
  357. if (typeof plugin.isSupported !== 'function') {
  358. return true
  359. }
  360. return plugin.isSupported()
  361. }
  362. const acquirers = pluginState.targets
  363. .filter(target => target.type === 'acquirer' && isSupported(target))
  364. .map(attachRenderFunctionToTarget)
  365. const progressindicators = pluginState.targets
  366. .filter(target => target.type === 'progressindicator')
  367. .map(attachRenderFunctionToTarget)
  368. const startUpload = (ev) => {
  369. this.uppy.upload().catch((err) => {
  370. // Log error.
  371. this.uppy.log(err.stack || err.message || err)
  372. })
  373. }
  374. const cancelUpload = (fileID) => {
  375. this.uppy.emit('upload-cancel', fileID)
  376. this.uppy.removeFile(fileID)
  377. }
  378. const saveFileCard = (meta, fileID) => {
  379. this.uppy.setFileMeta(fileID, meta)
  380. this.toggleFileCard()
  381. }
  382. return DashboardUI({
  383. state: state,
  384. modal: pluginState,
  385. newFiles: newFiles,
  386. files: files,
  387. totalFileCount: Object.keys(files).length,
  388. totalProgress: state.totalProgress,
  389. acquirers: acquirers,
  390. activePanel: pluginState.activePanel,
  391. getPlugin: this.uppy.getPlugin,
  392. progressindicators: progressindicators,
  393. autoProceed: this.uppy.opts.autoProceed,
  394. hideUploadButton: this.opts.hideUploadButton,
  395. id: this.id,
  396. closeModal: this.requestCloseModal,
  397. handleClickOutside: this.handleClickOutside,
  398. handleInputChange: this.handleInputChange,
  399. handlePaste: this.handlePaste,
  400. inline: this.opts.inline,
  401. showPanel: this.showPanel,
  402. hideAllPanels: this.hideAllPanels,
  403. log: this.uppy.log,
  404. i18n: this.i18n,
  405. addFile: this.uppy.addFile,
  406. removeFile: this.uppy.removeFile,
  407. info: this.uppy.info,
  408. note: this.opts.note,
  409. metaFields: pluginState.metaFields,
  410. resumableUploads: this.uppy.state.capabilities.resumableUploads || false,
  411. startUpload: startUpload,
  412. pauseUpload: this.uppy.pauseResume,
  413. retryUpload: this.uppy.retryUpload,
  414. cancelUpload: cancelUpload,
  415. fileCardFor: pluginState.fileCardFor,
  416. toggleFileCard: this.toggleFileCard,
  417. saveFileCard: saveFileCard,
  418. updateDashboardElWidth: this.updateDashboardElWidth,
  419. width: this.opts.width,
  420. height: this.opts.height,
  421. showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
  422. proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
  423. currentWidth: pluginState.containerWidth,
  424. isWide: pluginState.containerWidth > 400,
  425. isTargetDOMEl: this.isTargetDOMEl,
  426. allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
  427. maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles
  428. })
  429. }
  430. discoverProviderPlugins () {
  431. this.uppy.iteratePlugins((plugin) => {
  432. if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) {
  433. this.addTarget(plugin)
  434. }
  435. })
  436. }
  437. install () {
  438. // Set default state for Dashboard
  439. this.setPluginState({
  440. isHidden: true,
  441. showFileCard: false,
  442. activePanel: false,
  443. metaFields: this.opts.metaFields,
  444. targets: []
  445. })
  446. const target = this.opts.target
  447. if (target) {
  448. this.mount(target, this)
  449. }
  450. const plugins = this.opts.plugins || []
  451. plugins.forEach((pluginID) => {
  452. const plugin = this.uppy.getPlugin(pluginID)
  453. if (plugin) plugin.mount(this, plugin)
  454. })
  455. if (!this.opts.disableStatusBar) {
  456. this.uppy.use(StatusBar, {
  457. id: `${this.id}:StatusBar`,
  458. target: this,
  459. hideUploadButton: this.opts.hideUploadButton,
  460. showProgressDetails: this.opts.showProgressDetails,
  461. hideAfterFinish: this.opts.hideProgressAfterFinish,
  462. locale: this.opts.locale
  463. })
  464. }
  465. if (!this.opts.disableInformer) {
  466. this.uppy.use(Informer, {
  467. id: `${this.id}:Informer`,
  468. target: this
  469. })
  470. }
  471. if (!this.opts.disableThumbnailGenerator) {
  472. this.uppy.use(ThumbnailGenerator, {
  473. id: `${this.id}:ThumbnailGenerator`,
  474. thumbnailWidth: this.opts.thumbnailWidth
  475. })
  476. }
  477. this.discoverProviderPlugins()
  478. this.initEvents()
  479. }
  480. uninstall () {
  481. if (!this.opts.disableInformer) {
  482. const informer = this.uppy.getPlugin('Informer')
  483. // Checking if this plugin exists, in case it was removed by uppy-core
  484. // before the Dashboard was.
  485. if (informer) this.uppy.removePlugin(informer)
  486. }
  487. if (!this.opts.disableStatusBar) {
  488. const statusBar = this.uppy.getPlugin('StatusBar')
  489. if (statusBar) this.uppy.removePlugin(statusBar)
  490. }
  491. if (!this.opts.disableThumbnailGenerator) {
  492. const thumbnail = this.uppy.getPlugin('ThumbnailGenerator')
  493. if (thumbnail) this.uppy.removePlugin(thumbnail)
  494. }
  495. const plugins = this.opts.plugins || []
  496. plugins.forEach((pluginID) => {
  497. const plugin = this.uppy.getPlugin(pluginID)
  498. if (plugin) plugin.unmount()
  499. })
  500. this.unmount()
  501. this.removeEvents()
  502. }
  503. }