index.js 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. const { Plugin } = require('@uppy/core')
  2. const Translator = require('@uppy/utils/lib/Translator')
  3. const DashboardUI = require('./components/Dashboard')
  4. const StatusBar = require('@uppy/status-bar')
  5. const Informer = require('@uppy/informer')
  6. const ThumbnailGenerator = require('@uppy/thumbnail-generator')
  7. const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements')
  8. const toArray = require('@uppy/utils/lib/toArray')
  9. const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
  10. const trapFocus = require('./utils/trapFocus')
  11. const cuid = require('cuid')
  12. const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
  13. const { defaultPickerIcon } = require('./components/icons')
  14. const createSuperFocus = require('./utils/createSuperFocus')
  15. const TAB_KEY = 9
  16. const ESC_KEY = 27
  17. function createPromise () {
  18. const o = {}
  19. o.promise = new Promise((resolve, reject) => {
  20. o.resolve = resolve
  21. o.reject = reject
  22. })
  23. return o
  24. }
  25. /**
  26. * Dashboard UI with previews, metadata editing, tabs for various services and more
  27. */
  28. module.exports = class Dashboard extends Plugin {
  29. static VERSION = require('../package.json').version
  30. constructor (uppy, opts) {
  31. super(uppy, opts)
  32. this.id = this.opts.id || 'Dashboard'
  33. this.title = 'Dashboard'
  34. this.type = 'orchestrator'
  35. this.modalName = `uppy-Dashboard-${cuid()}`
  36. this.defaultLocale = {
  37. strings: {
  38. closeModal: 'Close Modal',
  39. importFrom: 'Import from %{name}',
  40. addingMoreFiles: 'Adding more files',
  41. addMoreFiles: 'Add more files',
  42. dashboardWindowTitle: 'File Uploader Window (Press escape to close)',
  43. dashboardTitle: 'File Uploader',
  44. copyLinkToClipboardSuccess: 'Link copied to clipboard',
  45. copyLinkToClipboardFallback: 'Copy the URL below',
  46. copyLink: 'Copy link',
  47. link: 'Link',
  48. fileSource: 'File source: %{name}',
  49. done: 'Done',
  50. back: 'Back',
  51. addMore: 'Add more',
  52. removeFile: 'Remove file',
  53. editFile: 'Edit file',
  54. editing: 'Editing %{file}',
  55. edit: 'Edit',
  56. finishEditingFile: 'Finish editing file',
  57. saveChanges: 'Save changes',
  58. cancel: 'Cancel',
  59. myDevice: 'My Device',
  60. dropPasteImport: 'Drop files here, paste, %{browse} or import from',
  61. dropPaste: 'Drop files here, paste or %{browse}',
  62. dropHint: 'Drop your files here',
  63. browse: 'browse',
  64. emptyFolderAdded: 'No files were added from empty folder',
  65. uploadComplete: 'Upload complete',
  66. uploadPaused: 'Upload paused',
  67. resumeUpload: 'Resume upload',
  68. pauseUpload: 'Pause upload',
  69. retryUpload: 'Retry upload',
  70. cancelUpload: 'Cancel upload',
  71. xFilesSelected: {
  72. 0: '%{smart_count} file selected',
  73. 1: '%{smart_count} files selected',
  74. 2: '%{smart_count} files selected'
  75. },
  76. uploadingXFiles: {
  77. 0: 'Uploading %{smart_count} file',
  78. 1: 'Uploading %{smart_count} files',
  79. 2: 'Uploading %{smart_count} files'
  80. },
  81. processingXFiles: {
  82. 0: 'Processing %{smart_count} file',
  83. 1: 'Processing %{smart_count} files',
  84. 2: 'Processing %{smart_count} files'
  85. },
  86. folderAdded: {
  87. 0: 'Added %{smart_count} file from %{folder}',
  88. 1: 'Added %{smart_count} files from %{folder}',
  89. 2: 'Added %{smart_count} files from %{folder}'
  90. },
  91. poweredBy: 'Powered by'
  92. }
  93. }
  94. // set default options
  95. const defaultOptions = {
  96. target: 'body',
  97. metaFields: [],
  98. trigger: '#uppy-select-files',
  99. inline: false,
  100. width: 750,
  101. height: 550,
  102. thumbnailWidth: 280,
  103. defaultPickerIcon,
  104. showLinkToFileUploadResult: true,
  105. showProgressDetails: false,
  106. hideUploadButton: false,
  107. hideRetryButton: false,
  108. hidePauseResumeCancelButtons: false,
  109. hideProgressAfterFinish: false,
  110. note: null,
  111. closeModalOnClickOutside: false,
  112. closeAfterFinish: false,
  113. disableStatusBar: false,
  114. disableInformer: false,
  115. disableThumbnailGenerator: false,
  116. disablePageScrollWhenModalOpen: true,
  117. animateOpenClose: true,
  118. proudlyDisplayPoweredByUppy: true,
  119. onRequestCloseModal: () => this.closeModal(),
  120. showSelectedFiles: true,
  121. browserBackButtonClose: false
  122. }
  123. // merge default options with the ones set by user
  124. this.opts = { ...defaultOptions, ...opts }
  125. // i18n
  126. this.translator = new Translator([ this.defaultLocale, this.uppy.locale, this.opts.locale ])
  127. this.i18n = this.translator.translate.bind(this.translator)
  128. this.i18nArray = this.translator.translateArray.bind(this.translator)
  129. this.openModal = this.openModal.bind(this)
  130. this.closeModal = this.closeModal.bind(this)
  131. this.requestCloseModal = this.requestCloseModal.bind(this)
  132. this.isModalOpen = this.isModalOpen.bind(this)
  133. this.addTarget = this.addTarget.bind(this)
  134. this.removeTarget = this.removeTarget.bind(this)
  135. this.hideAllPanels = this.hideAllPanels.bind(this)
  136. this.showPanel = this.showPanel.bind(this)
  137. this.toggleFileCard = this.toggleFileCard.bind(this)
  138. this.toggleAddFilesPanel = this.toggleAddFilesPanel.bind(this)
  139. this.initEvents = this.initEvents.bind(this)
  140. this.handlePopState = this.handlePopState.bind(this)
  141. this.handleKeyDownInModal = this.handleKeyDownInModal.bind(this)
  142. this.handleKeyDownInInline = this.handleKeyDownInInline.bind(this)
  143. this.handleComplete = this.handleComplete.bind(this)
  144. this.handleClickOutside = this.handleClickOutside.bind(this)
  145. this.handlePaste = this.handlePaste.bind(this)
  146. this.handlePasteOnBody = this.handlePasteOnBody.bind(this)
  147. this.handleInputChange = this.handleInputChange.bind(this)
  148. this.handleDragOver = this.handleDragOver.bind(this)
  149. this.handleDragLeave = this.handleDragLeave.bind(this)
  150. this.handleDrop = this.handleDrop.bind(this)
  151. this.superFocusOnEachUpdate = this.superFocusOnEachUpdate.bind(this)
  152. this.recordIfFocusedOnUppyRecently = this.recordIfFocusedOnUppyRecently.bind(this)
  153. this.render = this.render.bind(this)
  154. this.install = this.install.bind(this)
  155. this.superFocus = createSuperFocus()
  156. this.ifFocusedOnUppyRecently = false
  157. // Timeouts
  158. this.makeDashboardInsidesVisibleAnywayTimeout = null
  159. this.removeDragOverClassTimeout = null
  160. }
  161. removeTarget (plugin) {
  162. const pluginState = this.getPluginState()
  163. // filter out the one we want to remove
  164. const newTargets = pluginState.targets.filter(target => target.id !== plugin.id)
  165. this.setPluginState({
  166. targets: newTargets
  167. })
  168. }
  169. addTarget (plugin) {
  170. const callerPluginId = plugin.id || plugin.constructor.name
  171. const callerPluginName = plugin.title || callerPluginId
  172. const callerPluginType = plugin.type
  173. if (callerPluginType !== 'acquirer' &&
  174. callerPluginType !== 'progressindicator' &&
  175. callerPluginType !== 'presenter') {
  176. let msg = 'Dashboard: Modal can only be used by plugins of types: acquirer, progressindicator, presenter'
  177. this.uppy.log(msg)
  178. return
  179. }
  180. const target = {
  181. id: callerPluginId,
  182. name: callerPluginName,
  183. type: callerPluginType
  184. }
  185. const state = this.getPluginState()
  186. const newTargets = state.targets.slice()
  187. newTargets.push(target)
  188. this.setPluginState({
  189. targets: newTargets
  190. })
  191. return this.el
  192. }
  193. hideAllPanels () {
  194. this.setPluginState({
  195. activePickerPanel: false,
  196. showAddFilesPanel: false,
  197. activeOverlayType: null
  198. })
  199. }
  200. showPanel (id) {
  201. const { targets } = this.getPluginState()
  202. const activePickerPanel = targets.filter((target) => {
  203. return target.type === 'acquirer' && target.id === id
  204. })[0]
  205. this.setPluginState({
  206. activePickerPanel: activePickerPanel,
  207. activeOverlayType: 'PickerPanel'
  208. })
  209. }
  210. openModal () {
  211. const { promise, resolve } = createPromise()
  212. // save scroll position
  213. this.savedScrollPosition = window.pageYOffset
  214. // save active element, so we can restore focus when modal is closed
  215. this.savedActiveElement = document.activeElement
  216. if (this.opts.disablePageScrollWhenModalOpen) {
  217. document.body.classList.add('uppy-Dashboard-isFixed')
  218. }
  219. if (this.opts.animateOpenClose && this.getPluginState().isClosing) {
  220. const handler = () => {
  221. this.setPluginState({
  222. isHidden: false
  223. })
  224. this.el.removeEventListener('animationend', handler, false)
  225. resolve()
  226. }
  227. this.el.addEventListener('animationend', handler, false)
  228. } else {
  229. this.setPluginState({
  230. isHidden: false
  231. })
  232. resolve()
  233. }
  234. if (this.opts.browserBackButtonClose) {
  235. this.updateBrowserHistory()
  236. }
  237. // handle ESC and TAB keys in modal dialog
  238. document.addEventListener('keydown', this.handleKeyDownInModal)
  239. this.uppy.emit('dashboard:modal-open')
  240. return promise
  241. }
  242. closeModal (opts = {}) {
  243. const {
  244. manualClose = true // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button)
  245. } = opts
  246. const { isHidden, isClosing } = this.getPluginState()
  247. if (isHidden || isClosing) {
  248. // short-circuit if animation is ongoing
  249. return
  250. }
  251. const { promise, resolve } = createPromise()
  252. if (this.opts.disablePageScrollWhenModalOpen) {
  253. document.body.classList.remove('uppy-Dashboard-isFixed')
  254. }
  255. if (this.opts.animateOpenClose) {
  256. this.setPluginState({
  257. isClosing: true
  258. })
  259. const handler = () => {
  260. this.setPluginState({
  261. isHidden: true,
  262. isClosing: false
  263. })
  264. this.superFocus.cancel()
  265. this.savedActiveElement.focus()
  266. this.el.removeEventListener('animationend', handler, false)
  267. resolve()
  268. }
  269. this.el.addEventListener('animationend', handler, false)
  270. } else {
  271. this.setPluginState({
  272. isHidden: true
  273. })
  274. this.superFocus.cancel()
  275. this.savedActiveElement.focus()
  276. resolve()
  277. }
  278. // handle ESC and TAB keys in modal dialog
  279. document.removeEventListener('keydown', this.handleKeyDownInModal)
  280. if (manualClose) {
  281. if (this.opts.browserBackButtonClose) {
  282. // Make sure that the latest entry in the history state is our modal name
  283. if (history.state && history.state[this.modalName]) {
  284. // Go back in history to clear out the entry we created (ultimately closing the modal)
  285. history.go(-1)
  286. }
  287. }
  288. }
  289. this.uppy.emit('dashboard:modal-closed')
  290. return promise
  291. }
  292. isModalOpen () {
  293. return !this.getPluginState().isHidden || false
  294. }
  295. requestCloseModal () {
  296. if (this.opts.onRequestCloseModal) {
  297. return this.opts.onRequestCloseModal()
  298. }
  299. return this.closeModal()
  300. }
  301. toggleFileCard (fileId) {
  302. this.setPluginState({
  303. fileCardFor: fileId || null,
  304. activeOverlayType: fileId ? 'FileCard' : null
  305. })
  306. }
  307. toggleAddFilesPanel (show) {
  308. this.setPluginState({
  309. showAddFilesPanel: show,
  310. activeOverlayType: show ? 'AddFiles' : null
  311. })
  312. }
  313. addFile (file) {
  314. try {
  315. this.uppy.addFile({
  316. source: this.id,
  317. name: file.name,
  318. type: file.type,
  319. data: file,
  320. meta: {
  321. // path of the file relative to the ancestor directory the user selected.
  322. // e.g. 'docs/Old Prague/airbnb.pdf'
  323. relativePath: file.relativePath || null
  324. }
  325. })
  326. } catch (err) {
  327. // Nothing, restriction errors handled in Core
  328. }
  329. }
  330. // _Why make insides of Dashboard invisible until first ResizeObserver event is emitted?
  331. // ResizeOberserver doesn't emit the first resize event fast enough, users can see the jump from one .uppy-size-- to another (e.g. in Safari)
  332. // _Why not apply visibility property to .uppy-Dashboard-inner?
  333. // Because ideally, acc to specs, ResizeObserver should see invisible elements as of width 0. So even though applying invisibility to .uppy-Dashboard-inner works now, it may not work in the future.
  334. startListeningToResize () {
  335. // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize
  336. // and update containerWidth/containerHeight in plugin state accordingly.
  337. // Emits first event on initialization.
  338. this.resizeObserver = new ResizeObserver((entries, observer) => {
  339. for (const entry of entries) {
  340. const { width, height } = entry.contentRect
  341. this.uppy.log(`[Dashboard] resized: ${width} / ${height}`)
  342. this.setPluginState({
  343. containerWidth: width,
  344. containerHeight: height,
  345. areInsidesReadyToBeVisible: true
  346. })
  347. }
  348. })
  349. this.resizeObserver.observe(this.el.querySelector('.uppy-Dashboard-inner'))
  350. // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view
  351. this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => {
  352. const pluginState = this.getPluginState()
  353. if (!pluginState.areInsidesReadyToBeVisible) {
  354. this.uppy.log("[Dashboard] resize event didn't fire on time: defaulted to mobile layout")
  355. this.setPluginState({
  356. areInsidesReadyToBeVisible: true
  357. })
  358. }
  359. }, 1000)
  360. }
  361. stopListeningToResize () {
  362. this.resizeObserver.disconnect()
  363. clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout)
  364. }
  365. // Records whether we have been interacting with uppy right now, which is then used to determine whether state updates should trigger a refocusing.
  366. recordIfFocusedOnUppyRecently (event) {
  367. if (this.el.contains(event.target)) {
  368. this.ifFocusedOnUppyRecently = true
  369. } else {
  370. this.ifFocusedOnUppyRecently = false
  371. // ___Why run this.superFocus.cancel here when it already runs in superFocusOnEachUpdate?
  372. // Because superFocus is debounced, when we move from Uppy to some other element on the page,
  373. // previously run superFocus sometimes hits and moves focus back to Uppy.
  374. this.superFocus.cancel()
  375. }
  376. }
  377. updateBrowserHistory () {
  378. // Ensure history state does not already contain our modal name to avoid double-pushing
  379. if (!history.state || !history.state[this.modalName]) {
  380. // Push to history so that the page is not lost on browser back button press
  381. history.pushState({
  382. ...history.state,
  383. [this.modalName]: true
  384. }, '')
  385. }
  386. // Listen for back button presses
  387. window.addEventListener('popstate', this.handlePopState, false)
  388. }
  389. handlePopState (event) {
  390. // Close the modal if the history state no longer contains our modal name
  391. if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) {
  392. this.closeModal({ manualClose: false })
  393. }
  394. // When the browser back button is pressed and uppy is now the latest entry in the history but the modal is closed, fix the history by removing the uppy history entry
  395. // This occurs when another entry is added into the history state while the modal is open, and then the modal gets manually closed
  396. // Solves PR #575 (https://github.com/transloadit/uppy/pull/575)
  397. if (!this.isModalOpen() && event.state && event.state[this.modalName]) {
  398. history.go(-1)
  399. }
  400. }
  401. handleKeyDownInModal (event) {
  402. // close modal on esc key press
  403. if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
  404. // trap focus on tab key press
  405. if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el)
  406. }
  407. handleClickOutside () {
  408. if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
  409. }
  410. handlePaste (event) {
  411. // 1. Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root
  412. this.uppy.iteratePlugins((plugin) => {
  413. if (plugin.type === 'acquirer') {
  414. // Every Plugin with .type acquirer can define handleRootPaste(event)
  415. plugin.handleRootPaste && plugin.handleRootPaste(event)
  416. }
  417. })
  418. // 2. Add all dropped files
  419. const files = toArray(event.clipboardData.files)
  420. files.forEach((file) => {
  421. this.uppy.log('[Dashboard] File pasted')
  422. this.addFile(file)
  423. })
  424. }
  425. handleInputChange (event) {
  426. event.preventDefault()
  427. const files = toArray(event.target.files)
  428. files.forEach((file) =>
  429. this.addFile(file)
  430. )
  431. }
  432. handleDragOver (event) {
  433. event.preventDefault()
  434. event.stopPropagation()
  435. clearTimeout(this.removeDragOverClassTimeout)
  436. this.setPluginState({ isDraggingOver: true })
  437. }
  438. handleDragLeave (event) {
  439. event.preventDefault()
  440. event.stopPropagation()
  441. clearTimeout(this.removeDragOverClassTimeout)
  442. // Timeout against flickering, this solution is taken from drag-drop library. Solution with 'pointer-events: none' didn't work across browsers.
  443. this.removeDragOverClassTimeout = setTimeout(() => {
  444. this.setPluginState({ isDraggingOver: false })
  445. }, 50)
  446. }
  447. handleDrop (event, dropCategory) {
  448. event.preventDefault()
  449. event.stopPropagation()
  450. clearTimeout(this.removeDragOverClassTimeout)
  451. // 1. Add a small (+) icon on drop
  452. event.dataTransfer.dropEffect = 'copy'
  453. // 2. Remove dragover class
  454. this.setPluginState({ isDraggingOver: false })
  455. // 3. Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root
  456. this.uppy.iteratePlugins((plugin) => {
  457. if (plugin.type === 'acquirer') {
  458. // Every Plugin with .type acquirer can define handleRootDrop(event)
  459. plugin.handleRootDrop && plugin.handleRootDrop(event)
  460. }
  461. })
  462. // 4. Add all dropped files
  463. getDroppedFiles(event.dataTransfer)
  464. .then((files) => {
  465. if (files.length > 0) {
  466. this.uppy.log('[Dashboard] Files were dropped')
  467. files.forEach((file) =>
  468. this.addFile(file)
  469. )
  470. }
  471. })
  472. }
  473. handleKeyDownInInline (event) {
  474. // Trap focus on tab key press.
  475. if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el)
  476. }
  477. // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, or this.el.addEventListener('paste')?
  478. // Because (at least) Chrome doesn't handle paste if focus is on some button, e.g. 'My Device'.
  479. // => Therefore, the best option is to listen to all 'paste' events, and only react to them when we are focused on our particular Uppy instance.
  480. // ___Why do we still need onPaste={props.handlePaste} for the DashboardUi?
  481. // Because if we click on the 'Drop files here' caption e.g., `document.activeElement` will be 'body'. Which means our standard determination of whether we're pasting into our Uppy instance won't work.
  482. // => Therefore, we need a traditional onPaste={props.handlePaste} handler too.
  483. handlePasteOnBody (event) {
  484. const isFocusInOverlay = this.el.contains(document.activeElement)
  485. if (isFocusInOverlay) {
  486. this.handlePaste(event)
  487. }
  488. }
  489. handleComplete ({ failed, uploadID }) {
  490. if (this.opts.closeAfterFinish && failed.length === 0) {
  491. // All uploads are done
  492. this.requestCloseModal()
  493. }
  494. }
  495. initEvents () {
  496. // Modal open button
  497. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  498. if (!this.opts.inline && showModalTrigger) {
  499. showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal))
  500. }
  501. if (!this.opts.inline && !showModalTrigger) {
  502. 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', 'error')
  503. }
  504. this.startListeningToResize()
  505. document.addEventListener('paste', this.handlePasteOnBody)
  506. this.uppy.on('plugin-remove', this.removeTarget)
  507. this.uppy.on('file-added', this.hideAllPanels)
  508. this.uppy.on('dashboard:modal-closed', this.hideAllPanels)
  509. this.uppy.on('complete', this.handleComplete)
  510. // ___Why fire on capture?
  511. // Because this.ifFocusedOnUppyRecently needs to change before onUpdate() fires.
  512. document.addEventListener('focus', this.recordIfFocusedOnUppyRecently, true)
  513. document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true)
  514. if (this.opts.inline) {
  515. this.el.addEventListener('keydown', this.handleKeyDownInInline)
  516. }
  517. }
  518. removeEvents () {
  519. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  520. if (!this.opts.inline && showModalTrigger) {
  521. showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal))
  522. }
  523. this.stopListeningToResize()
  524. document.removeEventListener('paste', this.handlePasteOnBody)
  525. window.removeEventListener('popstate', this.handlePopState, false)
  526. this.uppy.off('plugin-remove', this.removeTarget)
  527. this.uppy.off('file-added', this.hideAllPanels)
  528. this.uppy.off('dashboard:modal-closed', this.hideAllPanels)
  529. this.uppy.off('complete', this.handleComplete)
  530. document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently)
  531. document.removeEventListener('click', this.recordIfFocusedOnUppyRecently)
  532. if (this.opts.inline) {
  533. this.el.removeEventListener('keydown', this.handleKeyDownInInline)
  534. }
  535. }
  536. superFocusOnEachUpdate () {
  537. const isFocusInUppy = this.el.contains(document.activeElement)
  538. // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11)
  539. const isFocusNowhere = document.activeElement === document.querySelector('body') || document.activeElement === null
  540. const isInformerHidden = this.uppy.getState().info.isHidden
  541. const isModal = !this.opts.inline
  542. if (
  543. // If update is connected to showing the Informer - let the screen reader calmly read it.
  544. isInformerHidden &&
  545. (
  546. // If we are in a modal - always superfocus without concern for other elements on the page (user is unlikely to want to interact with the rest of the page)
  547. isModal ||
  548. // If we are already inside of Uppy, or
  549. isFocusInUppy ||
  550. // If we are not focused on anything BUT we have already, at least once, focused on uppy
  551. // 1. We focus when isFocusNowhere, because when the element we were focused on disappears (e.g. an overlay), - focus gets lost. If user is typing something somewhere else on the page, - focus won't be 'nowhere'.
  552. // 2. We only focus when focus is nowhere AND this.ifFocusedOnUppyRecently, to avoid focus jumps if we do something else on the page.
  553. // [Practical check] Without '&& this.ifFocusedOnUppyRecently', in Safari, in inline mode, when file is uploading, - navigate via tab to the checkbox, try to press space multiple times. Focus will jump to Uppy.
  554. (isFocusNowhere && this.ifFocusedOnUppyRecently)
  555. )
  556. ) {
  557. this.superFocus(this.el, this.getPluginState().activeOverlayType)
  558. } else {
  559. this.superFocus.cancel()
  560. }
  561. }
  562. afterUpdate () {
  563. this.superFocusOnEachUpdate()
  564. }
  565. render (state) {
  566. const pluginState = this.getPluginState()
  567. const { files, capabilities, allowNewUpload } = state
  568. // TODO: move this to Core, to share between Status Bar and Dashboard
  569. // (and any other plugin that might need it, too)
  570. const newFiles = Object.keys(files).filter((file) => {
  571. return !files[file].progress.uploadStarted
  572. })
  573. const uploadStartedFiles = Object.keys(files).filter((file) => {
  574. return files[file].progress.uploadStarted
  575. })
  576. const pausedFiles = Object.keys(files).filter((file) => {
  577. return files[file].isPaused
  578. })
  579. const completeFiles = Object.keys(files).filter((file) => {
  580. return files[file].progress.uploadComplete
  581. })
  582. const erroredFiles = Object.keys(files).filter((file) => {
  583. return files[file].error
  584. })
  585. const inProgressFiles = Object.keys(files).filter((file) => {
  586. return !files[file].progress.uploadComplete &&
  587. files[file].progress.uploadStarted
  588. })
  589. const inProgressNotPausedFiles = inProgressFiles.filter((file) => {
  590. return !files[file].isPaused
  591. })
  592. const processingFiles = Object.keys(files).filter((file) => {
  593. return files[file].progress.preprocess || files[file].progress.postprocess
  594. })
  595. const isUploadStarted = uploadStartedFiles.length > 0
  596. const isAllComplete = state.totalProgress === 100 &&
  597. completeFiles.length === Object.keys(files).length &&
  598. processingFiles.length === 0
  599. const isAllErrored = isUploadStarted &&
  600. erroredFiles.length === uploadStartedFiles.length
  601. const isAllPaused = inProgressFiles.length !== 0 &&
  602. pausedFiles.length === inProgressFiles.length
  603. const attachRenderFunctionToTarget = (target) => {
  604. const plugin = this.uppy.getPlugin(target.id)
  605. return Object.assign({}, target, {
  606. icon: plugin.icon || this.opts.defaultPickerIcon,
  607. render: plugin.render
  608. })
  609. }
  610. const isSupported = (target) => {
  611. const plugin = this.uppy.getPlugin(target.id)
  612. // If the plugin does not provide a `supported` check, assume the plugin works everywhere.
  613. if (typeof plugin.isSupported !== 'function') {
  614. return true
  615. }
  616. return plugin.isSupported()
  617. }
  618. const acquirers = pluginState.targets
  619. .filter(target => target.type === 'acquirer' && isSupported(target))
  620. .map(attachRenderFunctionToTarget)
  621. const progressindicators = pluginState.targets
  622. .filter(target => target.type === 'progressindicator')
  623. .map(attachRenderFunctionToTarget)
  624. const startUpload = (ev) => {
  625. this.uppy.upload().catch((err) => {
  626. // Log error.
  627. this.uppy.log(err.stack || err.message || err)
  628. })
  629. }
  630. const cancelUpload = (fileID) => {
  631. this.uppy.removeFile(fileID)
  632. }
  633. const saveFileCard = (meta, fileID) => {
  634. this.uppy.setFileMeta(fileID, meta)
  635. this.toggleFileCard()
  636. }
  637. return DashboardUI({
  638. state,
  639. isHidden: pluginState.isHidden,
  640. files,
  641. newFiles,
  642. uploadStartedFiles,
  643. completeFiles,
  644. erroredFiles,
  645. inProgressFiles,
  646. inProgressNotPausedFiles,
  647. processingFiles,
  648. isUploadStarted,
  649. isAllComplete,
  650. isAllErrored,
  651. isAllPaused,
  652. totalFileCount: Object.keys(files).length,
  653. totalProgress: state.totalProgress,
  654. allowNewUpload,
  655. acquirers,
  656. activePickerPanel: pluginState.activePickerPanel,
  657. animateOpenClose: this.opts.animateOpenClose,
  658. isClosing: pluginState.isClosing,
  659. getPlugin: this.uppy.getPlugin,
  660. progressindicators: progressindicators,
  661. autoProceed: this.uppy.opts.autoProceed,
  662. id: this.id,
  663. closeModal: this.requestCloseModal,
  664. handleClickOutside: this.handleClickOutside,
  665. handleInputChange: this.handleInputChange,
  666. handlePaste: this.handlePaste,
  667. inline: this.opts.inline,
  668. showPanel: this.showPanel,
  669. hideAllPanels: this.hideAllPanels,
  670. log: this.uppy.log,
  671. i18n: this.i18n,
  672. i18nArray: this.i18nArray,
  673. addFile: this.uppy.addFile,
  674. removeFile: this.uppy.removeFile,
  675. info: this.uppy.info,
  676. note: this.opts.note,
  677. metaFields: pluginState.metaFields,
  678. resumableUploads: capabilities.resumableUploads || false,
  679. individualCancellation: capabilities.individualCancellation,
  680. startUpload,
  681. pauseUpload: this.uppy.pauseResume,
  682. retryUpload: this.uppy.retryUpload,
  683. cancelUpload,
  684. cancelAll: this.uppy.cancelAll,
  685. fileCardFor: pluginState.fileCardFor,
  686. toggleFileCard: this.toggleFileCard,
  687. toggleAddFilesPanel: this.toggleAddFilesPanel,
  688. showAddFilesPanel: pluginState.showAddFilesPanel,
  689. saveFileCard,
  690. width: this.opts.width,
  691. height: this.opts.height,
  692. showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
  693. proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
  694. containerWidth: pluginState.containerWidth,
  695. areInsidesReadyToBeVisible: pluginState.areInsidesReadyToBeVisible,
  696. isTargetDOMEl: this.isTargetDOMEl,
  697. parentElement: this.el,
  698. allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
  699. maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
  700. showSelectedFiles: this.opts.showSelectedFiles,
  701. // drag props
  702. isDraggingOver: pluginState.isDraggingOver,
  703. handleDragOver: this.handleDragOver,
  704. handleDragLeave: this.handleDragLeave,
  705. handleDrop: this.handleDrop
  706. })
  707. }
  708. discoverProviderPlugins () {
  709. this.uppy.iteratePlugins((plugin) => {
  710. if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) {
  711. this.addTarget(plugin)
  712. }
  713. })
  714. }
  715. install () {
  716. // Set default state for Dashboard
  717. this.setPluginState({
  718. isHidden: true,
  719. fileCardFor: null,
  720. activeOverlayType: null,
  721. showAddFilesPanel: false,
  722. activePickerPanel: false,
  723. metaFields: this.opts.metaFields,
  724. targets: [],
  725. // We'll make them visible once .containerWidth is determined
  726. areInsidesReadyToBeVisible: false,
  727. isDraggingOver: false
  728. })
  729. const { inline, closeAfterFinish } = this.opts
  730. if (inline && closeAfterFinish) {
  731. throw new Error('[Dashboard] `closeAfterFinish: true` cannot be used on an inline Dashboard, because an inline Dashboard cannot be closed at all. Either set `inline: false`, or disable the `closeAfterFinish` option.')
  732. }
  733. const { allowMultipleUploads } = this.uppy.opts
  734. if (allowMultipleUploads && closeAfterFinish) {
  735. this.uppy.log('[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploads` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true', 'warning')
  736. }
  737. const { target } = this.opts
  738. if (target) {
  739. this.mount(target, this)
  740. }
  741. const plugins = this.opts.plugins || []
  742. plugins.forEach((pluginID) => {
  743. const plugin = this.uppy.getPlugin(pluginID)
  744. if (plugin) {
  745. plugin.mount(this, plugin)
  746. }
  747. })
  748. if (!this.opts.disableStatusBar) {
  749. this.uppy.use(StatusBar, {
  750. id: `${this.id}:StatusBar`,
  751. target: this,
  752. hideUploadButton: this.opts.hideUploadButton,
  753. hideRetryButton: this.opts.hideRetryButton,
  754. hidePauseResumeButton: this.opts.hidePauseResumeButton,
  755. hideCancelButton: this.opts.hideCancelButton,
  756. showProgressDetails: this.opts.showProgressDetails,
  757. hideAfterFinish: this.opts.hideProgressAfterFinish,
  758. locale: this.opts.locale
  759. })
  760. }
  761. if (!this.opts.disableInformer) {
  762. this.uppy.use(Informer, {
  763. id: `${this.id}:Informer`,
  764. target: this
  765. })
  766. }
  767. if (!this.opts.disableThumbnailGenerator) {
  768. this.uppy.use(ThumbnailGenerator, {
  769. id: `${this.id}:ThumbnailGenerator`,
  770. thumbnailWidth: this.opts.thumbnailWidth
  771. })
  772. }
  773. this.discoverProviderPlugins()
  774. this.initEvents()
  775. }
  776. uninstall () {
  777. if (!this.opts.disableInformer) {
  778. const informer = this.uppy.getPlugin(`${this.id}:Informer`)
  779. // Checking if this plugin exists, in case it was removed by uppy-core
  780. // before the Dashboard was.
  781. if (informer) this.uppy.removePlugin(informer)
  782. }
  783. if (!this.opts.disableStatusBar) {
  784. const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`)
  785. if (statusBar) this.uppy.removePlugin(statusBar)
  786. }
  787. if (!this.opts.disableThumbnailGenerator) {
  788. const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`)
  789. if (thumbnail) this.uppy.removePlugin(thumbnail)
  790. }
  791. const plugins = this.opts.plugins || []
  792. plugins.forEach((pluginID) => {
  793. const plugin = this.uppy.getPlugin(pluginID)
  794. if (plugin) plugin.unmount()
  795. })
  796. this.unmount()
  797. this.removeEvents()
  798. }
  799. }