UNPKG

39.5 kBJavaScriptView Raw
1const { h } = require('preact')
2const { Plugin } = require('@uppy/core')
3const Translator = require('@uppy/utils/lib/Translator')
4const DashboardUI = require('./components/Dashboard')
5const StatusBar = require('@uppy/status-bar')
6const Informer = require('@uppy/informer')
7const ThumbnailGenerator = require('@uppy/thumbnail-generator')
8const findAllDOMElements = require('@uppy/utils/lib/findAllDOMElements')
9const toArray = require('@uppy/utils/lib/toArray')
10const getDroppedFiles = require('@uppy/utils/lib/getDroppedFiles')
11const getTextDirection = require('@uppy/utils/lib/getTextDirection')
12const trapFocus = require('./utils/trapFocus')
13const cuid = require('cuid')
14const ResizeObserver = require('resize-observer-polyfill').default || require('resize-observer-polyfill')
15const createSuperFocus = require('./utils/createSuperFocus')
16const memoize = require('memoize-one').default || require('memoize-one')
17const FOCUSABLE_ELEMENTS = require('@uppy/utils/lib/FOCUSABLE_ELEMENTS')
18
19const TAB_KEY = 9
20const ESC_KEY = 27
21
22function createPromise () {
23 const o = {}
24 o.promise = new Promise((resolve, reject) => {
25 o.resolve = resolve
26 o.reject = reject
27 })
28 return o
29}
30
31function defaultPickerIcon () {
32 return (
33 <svg aria-hidden="true" focusable="false" width="30" height="30" viewBox="0 0 30 30">
34 <path d="M15 30c8.284 0 15-6.716 15-15 0-8.284-6.716-15-15-15C6.716 0 0 6.716 0 15c0 8.284 6.716 15 15 15zm4.258-12.676v6.846h-8.426v-6.846H5.204l9.82-12.364 9.82 12.364H19.26z" />
35 </svg>
36 )
37}
38
39/**
40 * Dashboard UI with previews, metadata editing, tabs for various services and more
41 */
42module.exports = class Dashboard extends Plugin {
43 static VERSION = require('../package.json').version
44
45 constructor (uppy, opts) {
46 super(uppy, opts)
47 this.id = this.opts.id || 'Dashboard'
48 this.title = 'Dashboard'
49 this.type = 'orchestrator'
50 this.modalName = `uppy-Dashboard-${cuid()}`
51
52 this.defaultLocale = {
53 strings: {
54 closeModal: 'Close Modal',
55 importFrom: 'Import from %{name}',
56 addingMoreFiles: 'Adding more files',
57 addMoreFiles: 'Add more files',
58 dashboardWindowTitle: 'File Uploader Window (Press escape to close)',
59 dashboardTitle: 'File Uploader',
60 copyLinkToClipboardSuccess: 'Link copied to clipboard',
61 copyLinkToClipboardFallback: 'Copy the URL below',
62 copyLink: 'Copy link',
63 fileSource: 'File source: %{name}',
64 done: 'Done',
65 back: 'Back',
66 addMore: 'Add more',
67 removeFile: 'Remove file',
68 editFile: 'Edit file',
69 editing: 'Editing %{file}',
70 finishEditingFile: 'Finish editing file',
71 saveChanges: 'Save changes',
72 cancel: 'Cancel',
73 myDevice: 'My Device',
74 dropPasteFiles: 'Drop files here, paste or %{browseFiles}',
75 dropPasteFolders: 'Drop files here, paste or %{browseFolders}',
76 dropPasteBoth: 'Drop files here, paste, %{browseFiles} or %{browseFolders}',
77 dropPasteImportFiles: 'Drop files here, paste, %{browseFiles} or import from:',
78 dropPasteImportFolders: 'Drop files here, paste, %{browseFolders} or import from:',
79 dropPasteImportBoth: 'Drop files here, paste, %{browseFiles}, %{browseFolders} or import from:',
80 dropHint: 'Drop your files here',
81 browseFiles: 'browse files',
82 browseFolders: 'browse folders',
83 uploadComplete: 'Upload complete',
84 uploadPaused: 'Upload paused',
85 resumeUpload: 'Resume upload',
86 pauseUpload: 'Pause upload',
87 retryUpload: 'Retry upload',
88 cancelUpload: 'Cancel upload',
89 xFilesSelected: {
90 0: '%{smart_count} file selected',
91 1: '%{smart_count} files selected'
92 },
93 uploadingXFiles: {
94 0: 'Uploading %{smart_count} file',
95 1: 'Uploading %{smart_count} files'
96 },
97 processingXFiles: {
98 0: 'Processing %{smart_count} file',
99 1: 'Processing %{smart_count} files'
100 },
101 // The default `poweredBy2` string only combines the `poweredBy` string (%{backwardsCompat}) with the size.
102 // Locales can override `poweredBy2` to specify a different word order. This is for backwards compat with
103 // Uppy 1.9.x and below which did a naive concatenation of `poweredBy2 + size` instead of using a locale-specific
104 // substitution.
105 // TODO: In 2.0 `poweredBy2` should be removed in and `poweredBy` updated to use substitution.
106 poweredBy2: '%{backwardsCompat} %{uppy}',
107 poweredBy: 'Powered by'
108 }
109 }
110
111 // set default options
112 const defaultOptions = {
113 target: 'body',
114 metaFields: [],
115 trigger: '#uppy-select-files',
116 inline: false,
117 width: 750,
118 height: 550,
119 thumbnailWidth: 280,
120 thumbnailType: 'image/jpeg',
121 waitForThumbnailsBeforeUpload: false,
122 defaultPickerIcon,
123 showLinkToFileUploadResult: true,
124 showProgressDetails: false,
125 hideUploadButton: false,
126 hideCancelButton: false,
127 hideRetryButton: false,
128 hidePauseResumeButton: false,
129 hideProgressAfterFinish: false,
130 doneButtonHandler: () => {
131 this.uppy.reset()
132 this.requestCloseModal()
133 },
134 note: null,
135 closeModalOnClickOutside: false,
136 closeAfterFinish: false,
137 disableStatusBar: false,
138 disableInformer: false,
139 disableThumbnailGenerator: false,
140 disablePageScrollWhenModalOpen: true,
141 animateOpenClose: true,
142 fileManagerSelectionType: 'files',
143 proudlyDisplayPoweredByUppy: true,
144 onRequestCloseModal: () => this.closeModal(),
145 showSelectedFiles: true,
146 showRemoveButtonAfterComplete: false,
147 browserBackButtonClose: false,
148 theme: 'light',
149 autoOpenFileEditor: false,
150 disabled: false
151 }
152
153 // merge default options with the ones set by user
154 this.opts = { ...defaultOptions, ...opts }
155
156 this.i18nInit()
157
158 this.superFocus = createSuperFocus()
159 this.ifFocusedOnUppyRecently = false
160
161 // Timeouts
162 this.makeDashboardInsidesVisibleAnywayTimeout = null
163 this.removeDragOverClassTimeout = null
164 }
165
166 setOptions = (newOpts) => {
167 super.setOptions(newOpts)
168 this.i18nInit()
169 }
170
171 i18nInit = () => {
172 this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
173 this.i18n = this.translator.translate.bind(this.translator)
174 this.i18nArray = this.translator.translateArray.bind(this.translator)
175 this.setPluginState() // so that UI re-renders and we see the updated locale
176 }
177
178 removeTarget = (plugin) => {
179 const pluginState = this.getPluginState()
180 // filter out the one we want to remove
181 const newTargets = pluginState.targets.filter(target => target.id !== plugin.id)
182
183 this.setPluginState({
184 targets: newTargets
185 })
186 }
187
188 addTarget = (plugin) => {
189 const callerPluginId = plugin.id || plugin.constructor.name
190 const callerPluginName = plugin.title || callerPluginId
191 const callerPluginType = plugin.type
192
193 if (callerPluginType !== 'acquirer' &&
194 callerPluginType !== 'progressindicator' &&
195 callerPluginType !== 'editor') {
196 const msg = 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor'
197 this.uppy.log(msg, 'error')
198 return
199 }
200
201 const target = {
202 id: callerPluginId,
203 name: callerPluginName,
204 type: callerPluginType
205 }
206
207 const state = this.getPluginState()
208 const newTargets = state.targets.slice()
209 newTargets.push(target)
210
211 this.setPluginState({
212 targets: newTargets
213 })
214
215 return this.el
216 }
217
218 hideAllPanels = () => {
219 const update = {
220 activePickerPanel: false,
221 showAddFilesPanel: false,
222 activeOverlayType: null,
223 fileCardFor: null,
224 showFileEditor: false
225 }
226
227 const current = this.getPluginState()
228 if (current.activePickerPanel === update.activePickerPanel &&
229 current.showAddFilesPanel === update.showAddFilesPanel &&
230 current.showFileEditor === update.showFileEditor &&
231 current.activeOverlayType === update.activeOverlayType) {
232 // avoid doing a state update if nothing changed
233 return
234 }
235
236 this.setPluginState(update)
237 }
238
239 showPanel = (id) => {
240 const { targets } = this.getPluginState()
241
242 const activePickerPanel = targets.filter((target) => {
243 return target.type === 'acquirer' && target.id === id
244 })[0]
245
246 this.setPluginState({
247 activePickerPanel: activePickerPanel,
248 activeOverlayType: 'PickerPanel'
249 })
250 }
251
252 canEditFile = (file) => {
253 const { targets } = this.getPluginState()
254 const editors = this._getEditors(targets)
255
256 return editors.some((target) => (
257 this.uppy.getPlugin(target.id).canEditFile(file)
258 ))
259 }
260
261 openFileEditor = (file) => {
262 const { targets } = this.getPluginState()
263 const editors = this._getEditors(targets)
264
265 this.setPluginState({
266 showFileEditor: true,
267 fileCardFor: file.id || null,
268 activeOverlayType: 'FileEditor'
269 })
270
271 editors.forEach((editor) => {
272 this.uppy.getPlugin(editor.id).selectFile(file)
273 })
274 }
275
276 openModal = () => {
277 const { promise, resolve } = createPromise()
278 // save scroll position
279 this.savedScrollPosition = window.pageYOffset
280 // save active element, so we can restore focus when modal is closed
281 this.savedActiveElement = document.activeElement
282
283 if (this.opts.disablePageScrollWhenModalOpen) {
284 document.body.classList.add('uppy-Dashboard-isFixed')
285 }
286
287 if (this.opts.animateOpenClose && this.getPluginState().isClosing) {
288 const handler = () => {
289 this.setPluginState({
290 isHidden: false
291 })
292 this.el.removeEventListener('animationend', handler, false)
293 resolve()
294 }
295 this.el.addEventListener('animationend', handler, false)
296 } else {
297 this.setPluginState({
298 isHidden: false
299 })
300 resolve()
301 }
302
303 if (this.opts.browserBackButtonClose) {
304 this.updateBrowserHistory()
305 }
306
307 // handle ESC and TAB keys in modal dialog
308 document.addEventListener('keydown', this.handleKeyDownInModal)
309
310 this.uppy.emit('dashboard:modal-open')
311
312 return promise
313 }
314
315 closeModal = (opts = {}) => {
316 const {
317 manualClose = true // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button)
318 } = opts
319
320 const { isHidden, isClosing } = this.getPluginState()
321 if (isHidden || isClosing) {
322 // short-circuit if animation is ongoing
323 return
324 }
325
326 const { promise, resolve } = createPromise()
327
328 if (this.opts.disablePageScrollWhenModalOpen) {
329 document.body.classList.remove('uppy-Dashboard-isFixed')
330 }
331
332 if (this.opts.animateOpenClose) {
333 this.setPluginState({
334 isClosing: true
335 })
336 const handler = () => {
337 this.setPluginState({
338 isHidden: true,
339 isClosing: false
340 })
341
342 this.superFocus.cancel()
343 this.savedActiveElement.focus()
344
345 this.el.removeEventListener('animationend', handler, false)
346 resolve()
347 }
348 this.el.addEventListener('animationend', handler, false)
349 } else {
350 this.setPluginState({
351 isHidden: true
352 })
353
354 this.superFocus.cancel()
355 this.savedActiveElement.focus()
356
357 resolve()
358 }
359
360 // handle ESC and TAB keys in modal dialog
361 document.removeEventListener('keydown', this.handleKeyDownInModal)
362
363 if (manualClose) {
364 if (this.opts.browserBackButtonClose) {
365 // Make sure that the latest entry in the history state is our modal name
366 if (history.state && history.state[this.modalName]) {
367 // Go back in history to clear out the entry we created (ultimately closing the modal)
368 history.go(-1)
369 }
370 }
371 }
372
373 this.uppy.emit('dashboard:modal-closed')
374
375 return promise
376 }
377
378 isModalOpen = () => {
379 return !this.getPluginState().isHidden || false
380 }
381
382 requestCloseModal = () => {
383 if (this.opts.onRequestCloseModal) {
384 return this.opts.onRequestCloseModal()
385 }
386 return this.closeModal()
387 }
388
389 setDarkModeCapability = (isDarkModeOn) => {
390 const { capabilities } = this.uppy.getState()
391 this.uppy.setState({
392 capabilities: {
393 ...capabilities,
394 darkMode: isDarkModeOn
395 }
396 })
397 }
398
399 handleSystemDarkModeChange = (event) => {
400 const isDarkModeOnNow = event.matches
401 this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnNow ? 'on' : 'off'}`)
402 this.setDarkModeCapability(isDarkModeOnNow)
403 }
404
405 toggleFileCard = (show, fileID) => {
406 const file = this.uppy.getFile(fileID)
407 if (show) {
408 this.uppy.emit('dashboard:file-edit-start', file)
409 } else {
410 this.uppy.emit('dashboard:file-edit-complete', file)
411 }
412
413 this.setPluginState({
414 fileCardFor: show ? fileID : null,
415 activeOverlayType: show ? 'FileCard' : null
416 })
417 }
418
419 toggleAddFilesPanel = (show) => {
420 this.setPluginState({
421 showAddFilesPanel: show,
422 activeOverlayType: show ? 'AddFiles' : null
423 })
424 }
425
426 addFiles = (files) => {
427 const descriptors = files.map((file) => ({
428 source: this.id,
429 name: file.name,
430 type: file.type,
431 data: file,
432 meta: {
433 // path of the file relative to the ancestor directory the user selected.
434 // e.g. 'docs/Old Prague/airbnb.pdf'
435 relativePath: file.relativePath || null
436 }
437 }))
438
439 try {
440 this.uppy.addFiles(descriptors)
441 } catch (err) {
442 this.uppy.log(err)
443 }
444 }
445
446 // ___Why make insides of Dashboard invisible until first ResizeObserver event is emitted?
447 // 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)
448 // ___Why not apply visibility property to .uppy-Dashboard-inner?
449 // 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.
450 startListeningToResize = () => {
451 // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize
452 // and update containerWidth/containerHeight in plugin state accordingly.
453 // Emits first event on initialization.
454 this.resizeObserver = new ResizeObserver((entries, observer) => {
455 const uppyDashboardInnerEl = entries[0]
456
457 const { width, height } = uppyDashboardInnerEl.contentRect
458
459 this.uppy.log(`[Dashboard] resized: ${width} / ${height}`, 'debug')
460
461 this.setPluginState({
462 containerWidth: width,
463 containerHeight: height,
464 areInsidesReadyToBeVisible: true
465 })
466 })
467 this.resizeObserver.observe(this.el.querySelector('.uppy-Dashboard-inner'))
468
469 // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view
470 this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => {
471 const pluginState = this.getPluginState()
472 const isModalAndClosed = !this.opts.inline && pluginState.isHidden
473 if (
474 // if ResizeObserver hasn't yet fired,
475 !pluginState.areInsidesReadyToBeVisible &&
476 // and it's not due to the modal being closed
477 !isModalAndClosed
478 ) {
479 this.uppy.log("[Dashboard] resize event didn't fire on time: defaulted to mobile layout", 'debug')
480
481 this.setPluginState({
482 areInsidesReadyToBeVisible: true
483 })
484 }
485 }, 1000)
486 }
487
488 stopListeningToResize = () => {
489 this.resizeObserver.disconnect()
490
491 clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout)
492 }
493
494 // Records whether we have been interacting with uppy right now, which is then used to determine whether state updates should trigger a refocusing.
495 recordIfFocusedOnUppyRecently = (event) => {
496 if (this.el.contains(event.target)) {
497 this.ifFocusedOnUppyRecently = true
498 } else {
499 this.ifFocusedOnUppyRecently = false
500 // ___Why run this.superFocus.cancel here when it already runs in superFocusOnEachUpdate?
501 // Because superFocus is debounced, when we move from Uppy to some other element on the page,
502 // previously run superFocus sometimes hits and moves focus back to Uppy.
503 this.superFocus.cancel()
504 }
505 }
506
507 disableAllFocusableElements = (disable) => {
508 const focusableNodes = toArray(this.el.querySelectorAll(FOCUSABLE_ELEMENTS))
509 if (disable) {
510 focusableNodes.forEach((node) => {
511 // save previous tabindex in a data-attribute, to restore when enabling
512 const currentTabIndex = node.getAttribute('tabindex')
513 if (currentTabIndex) {
514 node.dataset.inertTabindex = currentTabIndex
515 }
516 node.setAttribute('tabindex', '-1')
517 })
518 } else {
519 focusableNodes.forEach((node) => {
520 if ('inertTabindex' in node.dataset) {
521 node.setAttribute('tabindex', node.dataset.inertTabindex)
522 } else {
523 node.removeAttribute('tabindex')
524 }
525 })
526 }
527 this.dashboardIsDisabled = disable
528 }
529
530 updateBrowserHistory = () => {
531 // Ensure history state does not already contain our modal name to avoid double-pushing
532 if (!history.state || !history.state[this.modalName]) {
533 // Push to history so that the page is not lost on browser back button press
534 history.pushState({
535 ...history.state,
536 [this.modalName]: true
537 }, '')
538 }
539
540 // Listen for back button presses
541 window.addEventListener('popstate', this.handlePopState, false)
542 }
543
544 handlePopState = (event) => {
545 // Close the modal if the history state no longer contains our modal name
546 if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) {
547 this.closeModal({ manualClose: false })
548 }
549
550 // 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
551 // This occurs when another entry is added into the history state while the modal is open, and then the modal gets manually closed
552 // Solves PR #575 (https://github.com/transloadit/uppy/pull/575)
553 if (!this.isModalOpen() && event.state && event.state[this.modalName]) {
554 history.go(-1)
555 }
556 }
557
558 handleKeyDownInModal = (event) => {
559 // close modal on esc key press
560 if (event.keyCode === ESC_KEY) this.requestCloseModal(event)
561 // trap focus on tab key press
562 if (event.keyCode === TAB_KEY) trapFocus.forModal(event, this.getPluginState().activeOverlayType, this.el)
563 }
564
565 handleClickOutside = () => {
566 if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
567 }
568
569 handlePaste = (event) => {
570 // 1. Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root
571 this.uppy.iteratePlugins((plugin) => {
572 if (plugin.type === 'acquirer') {
573 // Every Plugin with .type acquirer can define handleRootPaste(event)
574 plugin.handleRootPaste && plugin.handleRootPaste(event)
575 }
576 })
577
578 // 2. Add all dropped files
579 const files = toArray(event.clipboardData.files)
580 this.addFiles(files)
581 }
582
583 handleInputChange = (event) => {
584 event.preventDefault()
585 const files = toArray(event.target.files)
586 this.addFiles(files)
587 }
588
589 handleDragOver = (event) => {
590 event.preventDefault()
591 event.stopPropagation()
592
593 if (this.opts.disabled) {
594 return
595 }
596
597 // 1. Add a small (+) icon on drop
598 // (and prevent browsers from interpreting this as files being _moved_ into the browser, https://github.com/transloadit/uppy/issues/1978)
599 event.dataTransfer.dropEffect = 'copy'
600
601 clearTimeout(this.removeDragOverClassTimeout)
602 this.setPluginState({ isDraggingOver: true })
603 }
604
605 handleDragLeave = (event) => {
606 event.preventDefault()
607 event.stopPropagation()
608
609 if (this.opts.disabled) {
610 return
611 }
612
613 clearTimeout(this.removeDragOverClassTimeout)
614 // Timeout against flickering, this solution is taken from drag-drop library. Solution with 'pointer-events: none' didn't work across browsers.
615 this.removeDragOverClassTimeout = setTimeout(() => {
616 this.setPluginState({ isDraggingOver: false })
617 }, 50)
618 }
619
620 handleDrop = (event, dropCategory) => {
621 event.preventDefault()
622 event.stopPropagation()
623
624 if (this.opts.disabled) {
625 return
626 }
627
628 clearTimeout(this.removeDragOverClassTimeout)
629
630 // 2. Remove dragover class
631 this.setPluginState({ isDraggingOver: false })
632
633 // 3. Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root
634 this.uppy.iteratePlugins((plugin) => {
635 if (plugin.type === 'acquirer') {
636 // Every Plugin with .type acquirer can define handleRootDrop(event)
637 plugin.handleRootDrop && plugin.handleRootDrop(event)
638 }
639 })
640
641 // 4. Add all dropped files
642 let executedDropErrorOnce = false
643 const logDropError = (error) => {
644 this.uppy.log(error, 'error')
645
646 // In practice all drop errors are most likely the same, so let's just show one to avoid overwhelming the user
647 if (!executedDropErrorOnce) {
648 this.uppy.info(error.message, 'error')
649 executedDropErrorOnce = true
650 }
651 }
652
653 getDroppedFiles(event.dataTransfer, { logDropError })
654 .then((files) => {
655 if (files.length > 0) {
656 this.uppy.log('[Dashboard] Files were dropped')
657 this.addFiles(files)
658 }
659 })
660 }
661
662 handleRequestThumbnail = (file) => {
663 if (!this.opts.waitForThumbnailsBeforeUpload) {
664 this.uppy.emit('thumbnail:request', file)
665 }
666 }
667
668 /**
669 * We cancel thumbnail requests when a file item component unmounts to avoid clogging up the queue when the user scrolls past many elements.
670 */
671 handleCancelThumbnail = (file) => {
672 if (!this.opts.waitForThumbnailsBeforeUpload) {
673 this.uppy.emit('thumbnail:cancel', file)
674 }
675 }
676
677 handleKeyDownInInline = (event) => {
678 // Trap focus on tab key press.
679 if (event.keyCode === TAB_KEY) trapFocus.forInline(event, this.getPluginState().activeOverlayType, this.el)
680 }
681
682 // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop, or this.el.addEventListener('paste')?
683 // Because (at least) Chrome doesn't handle paste if focus is on some button, e.g. 'My Device'.
684 // => 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.
685 // ___Why do we still need onPaste={props.handlePaste} for the DashboardUi?
686 // 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.
687 // => Therefore, we need a traditional onPaste={props.handlePaste} handler too.
688 handlePasteOnBody = (event) => {
689 const isFocusInOverlay = this.el.contains(document.activeElement)
690 if (isFocusInOverlay) {
691 this.handlePaste(event)
692 }
693 }
694
695 handleComplete = ({ failed }) => {
696 if (this.opts.closeAfterFinish && failed.length === 0) {
697 // All uploads are done
698 this.requestCloseModal()
699 }
700 }
701
702 _openFileEditorWhenFilesAdded = (files) => {
703 const firstFile = files[0]
704 if (this.canEditFile(firstFile)) {
705 this.openFileEditor(firstFile)
706 }
707 }
708
709 initEvents = () => {
710 // Modal open button
711 if (this.opts.trigger && !this.opts.inline) {
712 const showModalTrigger = findAllDOMElements(this.opts.trigger)
713 if (showModalTrigger) {
714 showModalTrigger.forEach(trigger => trigger.addEventListener('click', this.openModal))
715 } else {
716 this.uppy.log('Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself', 'warning')
717 }
718 }
719
720 this.startListeningToResize()
721 document.addEventListener('paste', this.handlePasteOnBody)
722
723 this.uppy.on('plugin-remove', this.removeTarget)
724 this.uppy.on('file-added', this.hideAllPanels)
725 this.uppy.on('dashboard:modal-closed', this.hideAllPanels)
726 this.uppy.on('file-editor:complete', this.hideAllPanels)
727 this.uppy.on('complete', this.handleComplete)
728
729 // ___Why fire on capture?
730 // Because this.ifFocusedOnUppyRecently needs to change before onUpdate() fires.
731 document.addEventListener('focus', this.recordIfFocusedOnUppyRecently, true)
732 document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true)
733
734 if (this.opts.inline) {
735 this.el.addEventListener('keydown', this.handleKeyDownInInline)
736 }
737
738 if (this.opts.autoOpenFileEditor) {
739 this.uppy.on('files-added', this._openFileEditorWhenFilesAdded)
740 }
741 }
742
743 removeEvents = () => {
744 const showModalTrigger = findAllDOMElements(this.opts.trigger)
745 if (!this.opts.inline && showModalTrigger) {
746 showModalTrigger.forEach(trigger => trigger.removeEventListener('click', this.openModal))
747 }
748
749 this.stopListeningToResize()
750 document.removeEventListener('paste', this.handlePasteOnBody)
751
752 window.removeEventListener('popstate', this.handlePopState, false)
753 this.uppy.off('plugin-remove', this.removeTarget)
754 this.uppy.off('file-added', this.hideAllPanels)
755 this.uppy.off('dashboard:modal-closed', this.hideAllPanels)
756 this.uppy.off('complete', this.handleComplete)
757
758 document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently)
759 document.removeEventListener('click', this.recordIfFocusedOnUppyRecently)
760
761 if (this.opts.inline) {
762 this.el.removeEventListener('keydown', this.handleKeyDownInInline)
763 }
764
765 if (this.opts.autoOpenFileEditor) {
766 this.uppy.off('files-added', this._openFileEditorWhenFilesAdded)
767 }
768 }
769
770 superFocusOnEachUpdate = () => {
771 const isFocusInUppy = this.el.contains(document.activeElement)
772 // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11)
773 const isFocusNowhere = document.activeElement === document.body || document.activeElement === null
774 const isInformerHidden = this.uppy.getState().info.isHidden
775 const isModal = !this.opts.inline
776
777 if (
778 // If update is connected to showing the Informer - let the screen reader calmly read it.
779 isInformerHidden &&
780 (
781 // 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)
782 isModal ||
783 // If we are already inside of Uppy, or
784 isFocusInUppy ||
785 // If we are not focused on anything BUT we have already, at least once, focused on uppy
786 // 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'.
787 // 2. We only focus when focus is nowhere AND this.ifFocusedOnUppyRecently, to avoid focus jumps if we do something else on the page.
788 // [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.
789 (isFocusNowhere && this.ifFocusedOnUppyRecently)
790 )
791 ) {
792 this.superFocus(this.el, this.getPluginState().activeOverlayType)
793 } else {
794 this.superFocus.cancel()
795 }
796 }
797
798 afterUpdate = () => {
799 if (this.opts.disabled && !this.dashboardIsDisabled) {
800 this.disableAllFocusableElements(true)
801 return
802 }
803
804 if (!this.opts.disabled && this.dashboardIsDisabled) {
805 this.disableAllFocusableElements(false)
806 }
807
808 this.superFocusOnEachUpdate()
809 }
810
811 cancelUpload = (fileID) => {
812 this.uppy.removeFile(fileID)
813 }
814
815 saveFileCard = (meta, fileID) => {
816 this.uppy.setFileMeta(fileID, meta)
817 this.toggleFileCard(false, fileID)
818 }
819
820 _attachRenderFunctionToTarget = (target) => {
821 const plugin = this.uppy.getPlugin(target.id)
822 return {
823 ...target,
824 icon: plugin.icon || this.opts.defaultPickerIcon,
825 render: plugin.render
826 }
827 }
828
829 _isTargetSupported = (target) => {
830 const plugin = this.uppy.getPlugin(target.id)
831 // If the plugin does not provide a `supported` check, assume the plugin works everywhere.
832 if (typeof plugin.isSupported !== 'function') {
833 return true
834 }
835 return plugin.isSupported()
836 }
837
838 _getAcquirers = memoize((targets) => {
839 return targets
840 .filter(target => target.type === 'acquirer' && this._isTargetSupported(target))
841 .map(this._attachRenderFunctionToTarget)
842 })
843
844 _getProgressIndicators = memoize((targets) => {
845 return targets
846 .filter(target => target.type === 'progressindicator')
847 .map(this._attachRenderFunctionToTarget)
848 })
849
850 _getEditors = memoize((targets) => {
851 return targets
852 .filter(target => target.type === 'editor')
853 .map(this._attachRenderFunctionToTarget)
854 })
855
856 render = (state) => {
857 const pluginState = this.getPluginState()
858 const { files, capabilities, allowNewUpload } = state
859
860 // TODO: move this to Core, to share between Status Bar and Dashboard
861 // (and any other plugin that might need it, too)
862 const newFiles = Object.keys(files).filter((file) => {
863 return !files[file].progress.uploadStarted
864 })
865
866 const uploadStartedFiles = Object.keys(files).filter((file) => {
867 return files[file].progress.uploadStarted
868 })
869
870 const pausedFiles = Object.keys(files).filter((file) => {
871 return files[file].isPaused
872 })
873
874 const completeFiles = Object.keys(files).filter((file) => {
875 return files[file].progress.uploadComplete
876 })
877
878 const erroredFiles = Object.keys(files).filter((file) => {
879 return files[file].error
880 })
881
882 const inProgressFiles = Object.keys(files).filter((file) => {
883 return !files[file].progress.uploadComplete &&
884 files[file].progress.uploadStarted
885 })
886
887 const inProgressNotPausedFiles = inProgressFiles.filter((file) => {
888 return !files[file].isPaused
889 })
890
891 const processingFiles = Object.keys(files).filter((file) => {
892 return files[file].progress.preprocess || files[file].progress.postprocess
893 })
894
895 const isUploadStarted = uploadStartedFiles.length > 0
896
897 const isAllComplete = state.totalProgress === 100 &&
898 completeFiles.length === Object.keys(files).length &&
899 processingFiles.length === 0
900
901 const isAllErrored = isUploadStarted &&
902 erroredFiles.length === uploadStartedFiles.length
903
904 const isAllPaused = inProgressFiles.length !== 0 &&
905 pausedFiles.length === inProgressFiles.length
906
907 const acquirers = this._getAcquirers(pluginState.targets)
908 const progressindicators = this._getProgressIndicators(pluginState.targets)
909 const editors = this._getEditors(pluginState.targets)
910
911 let theme
912 if (this.opts.theme === 'auto') {
913 theme = capabilities.darkMode ? 'dark' : 'light'
914 } else {
915 theme = this.opts.theme
916 }
917
918 if (['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) < 0) {
919 this.opts.fileManagerSelectionType = 'files'
920 console.error(`Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`)
921 }
922
923 return DashboardUI({
924 state,
925 isHidden: pluginState.isHidden,
926 files,
927 newFiles,
928 uploadStartedFiles,
929 completeFiles,
930 erroredFiles,
931 inProgressFiles,
932 inProgressNotPausedFiles,
933 processingFiles,
934 isUploadStarted,
935 isAllComplete,
936 isAllErrored,
937 isAllPaused,
938 totalFileCount: Object.keys(files).length,
939 totalProgress: state.totalProgress,
940 allowNewUpload,
941 acquirers,
942 theme,
943 disabled: this.opts.disabled,
944 direction: this.opts.direction,
945 activePickerPanel: pluginState.activePickerPanel,
946 showFileEditor: pluginState.showFileEditor,
947 disableAllFocusableElements: this.disableAllFocusableElements,
948 animateOpenClose: this.opts.animateOpenClose,
949 isClosing: pluginState.isClosing,
950 getPlugin: this.uppy.getPlugin,
951 progressindicators: progressindicators,
952 editors: editors,
953 autoProceed: this.uppy.opts.autoProceed,
954 id: this.id,
955 closeModal: this.requestCloseModal,
956 handleClickOutside: this.handleClickOutside,
957 handleInputChange: this.handleInputChange,
958 handlePaste: this.handlePaste,
959 inline: this.opts.inline,
960 showPanel: this.showPanel,
961 hideAllPanels: this.hideAllPanels,
962 log: this.uppy.log,
963 i18n: this.i18n,
964 i18nArray: this.i18nArray,
965 removeFile: this.uppy.removeFile,
966 uppy: this.uppy,
967 info: this.uppy.info,
968 note: this.opts.note,
969 metaFields: pluginState.metaFields,
970 resumableUploads: capabilities.resumableUploads || false,
971 individualCancellation: capabilities.individualCancellation,
972 isMobileDevice: capabilities.isMobileDevice,
973 pauseUpload: this.uppy.pauseResume,
974 retryUpload: this.uppy.retryUpload,
975 cancelUpload: this.cancelUpload,
976 cancelAll: this.uppy.cancelAll,
977 fileCardFor: pluginState.fileCardFor,
978 toggleFileCard: this.toggleFileCard,
979 toggleAddFilesPanel: this.toggleAddFilesPanel,
980 showAddFilesPanel: pluginState.showAddFilesPanel,
981 saveFileCard: this.saveFileCard,
982 openFileEditor: this.openFileEditor,
983 canEditFile: this.canEditFile,
984 width: this.opts.width,
985 height: this.opts.height,
986 showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
987 fileManagerSelectionType: this.opts.fileManagerSelectionType,
988 proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
989 hideCancelButton: this.opts.hideCancelButton,
990 hideRetryButton: this.opts.hideRetryButton,
991 hidePauseResumeButton: this.opts.hidePauseResumeButton,
992 showRemoveButtonAfterComplete: this.opts.showRemoveButtonAfterComplete,
993 containerWidth: pluginState.containerWidth,
994 containerHeight: pluginState.containerHeight,
995 areInsidesReadyToBeVisible: pluginState.areInsidesReadyToBeVisible,
996 isTargetDOMEl: this.isTargetDOMEl,
997 parentElement: this.el,
998 allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
999 maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
1000 showSelectedFiles: this.opts.showSelectedFiles,
1001 handleRequestThumbnail: this.handleRequestThumbnail,
1002 handleCancelThumbnail: this.handleCancelThumbnail,
1003 // drag props
1004 isDraggingOver: pluginState.isDraggingOver,
1005 handleDragOver: this.handleDragOver,
1006 handleDragLeave: this.handleDragLeave,
1007 handleDrop: this.handleDrop
1008 })
1009 }
1010
1011 discoverProviderPlugins = () => {
1012 this.uppy.iteratePlugins((plugin) => {
1013 if (plugin && !plugin.target && plugin.opts && plugin.opts.target === this.constructor) {
1014 this.addTarget(plugin)
1015 }
1016 })
1017 }
1018
1019 onMount () {
1020 // Set the text direction if the page has not defined one.
1021 const element = this.el
1022 const direction = getTextDirection(element)
1023 if (!direction) {
1024 element.dir = 'ltr'
1025 }
1026 }
1027
1028 install = () => {
1029 // Set default state for Dashboard
1030 this.setPluginState({
1031 isHidden: true,
1032 fileCardFor: null,
1033 activeOverlayType: null,
1034 showAddFilesPanel: false,
1035 activePickerPanel: false,
1036 showFileEditor: false,
1037 metaFields: this.opts.metaFields,
1038 targets: [],
1039 // We'll make them visible once .containerWidth is determined
1040 areInsidesReadyToBeVisible: false,
1041 isDraggingOver: false
1042 })
1043
1044 const { inline, closeAfterFinish } = this.opts
1045 if (inline && closeAfterFinish) {
1046 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.')
1047 }
1048
1049 const { allowMultipleUploads } = this.uppy.opts
1050 if (allowMultipleUploads && closeAfterFinish) {
1051 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')
1052 }
1053
1054 const { target } = this.opts
1055 if (target) {
1056 this.mount(target, this)
1057 }
1058
1059 const plugins = this.opts.plugins || []
1060 plugins.forEach((pluginID) => {
1061 const plugin = this.uppy.getPlugin(pluginID)
1062 if (plugin) {
1063 plugin.mount(this, plugin)
1064 }
1065 })
1066
1067 if (!this.opts.disableStatusBar) {
1068 this.uppy.use(StatusBar, {
1069 id: `${this.id}:StatusBar`,
1070 target: this,
1071 hideUploadButton: this.opts.hideUploadButton,
1072 hideRetryButton: this.opts.hideRetryButton,
1073 hidePauseResumeButton: this.opts.hidePauseResumeButton,
1074 hideCancelButton: this.opts.hideCancelButton,
1075 showProgressDetails: this.opts.showProgressDetails,
1076 hideAfterFinish: this.opts.hideProgressAfterFinish,
1077 locale: this.opts.locale,
1078 doneButtonHandler: this.opts.doneButtonHandler
1079 })
1080 }
1081
1082 if (!this.opts.disableInformer) {
1083 this.uppy.use(Informer, {
1084 id: `${this.id}:Informer`,
1085 target: this
1086 })
1087 }
1088
1089 if (!this.opts.disableThumbnailGenerator) {
1090 this.uppy.use(ThumbnailGenerator, {
1091 id: `${this.id}:ThumbnailGenerator`,
1092 thumbnailWidth: this.opts.thumbnailWidth,
1093 thumbnailType: this.opts.thumbnailType,
1094 waitForThumbnailsBeforeUpload: this.opts.waitForThumbnailsBeforeUpload,
1095 // If we don't block on thumbnails, we can lazily generate them
1096 lazy: !this.opts.waitForThumbnailsBeforeUpload
1097 })
1098 }
1099
1100 // Dark Mode / theme
1101 this.darkModeMediaQuery = (typeof window !== 'undefined' && window.matchMedia)
1102 ? window.matchMedia('(prefers-color-scheme: dark)')
1103 : null
1104
1105 const isDarkModeOnFromTheStart = this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false
1106 this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`)
1107 this.setDarkModeCapability(isDarkModeOnFromTheStart)
1108
1109 if (this.opts.theme === 'auto') {
1110 this.darkModeMediaQuery.addListener(this.handleSystemDarkModeChange)
1111 }
1112
1113 this.discoverProviderPlugins()
1114 this.initEvents()
1115 }
1116
1117 uninstall = () => {
1118 if (!this.opts.disableInformer) {
1119 const informer = this.uppy.getPlugin(`${this.id}:Informer`)
1120 // Checking if this plugin exists, in case it was removed by uppy-core
1121 // before the Dashboard was.
1122 if (informer) this.uppy.removePlugin(informer)
1123 }
1124
1125 if (!this.opts.disableStatusBar) {
1126 const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`)
1127 if (statusBar) this.uppy.removePlugin(statusBar)
1128 }
1129
1130 if (!this.opts.disableThumbnailGenerator) {
1131 const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`)
1132 if (thumbnail) this.uppy.removePlugin(thumbnail)
1133 }
1134
1135 const plugins = this.opts.plugins || []
1136 plugins.forEach((pluginID) => {
1137 const plugin = this.uppy.getPlugin(pluginID)
1138 if (plugin) plugin.unmount()
1139 })
1140
1141 if (this.opts.theme === 'auto') {
1142 this.darkModeMediaQuery.removeListener(this.handleSystemDarkModeChange)
1143 }
1144
1145 this.unmount()
1146 this.removeEvents()
1147 }
1148}