index.js 16 KB

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