Dashboard.tsx 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483
  1. import {
  2. UIPlugin,
  3. type UIPluginOptions,
  4. type UnknownPlugin,
  5. type Uppy,
  6. type UploadResult,
  7. type State,
  8. } from '@uppy/core'
  9. import type { ComponentChild, VNode } from 'preact'
  10. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin.js'
  11. import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
  12. import StatusBar from '@uppy/status-bar'
  13. import Informer from '@uppy/informer'
  14. import ThumbnailGenerator from '@uppy/thumbnail-generator'
  15. import findAllDOMElements from '@uppy/utils/lib/findAllDOMElements'
  16. import toArray from '@uppy/utils/lib/toArray'
  17. import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
  18. import { defaultPickerIcon } from '@uppy/provider-views'
  19. import { nanoid } from 'nanoid/non-secure'
  20. import memoizeOne from 'memoize-one'
  21. import * as trapFocus from './utils/trapFocus.ts'
  22. import createSuperFocus from './utils/createSuperFocus.ts'
  23. import DashboardUI from './components/Dashboard.tsx'
  24. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  25. // @ts-ignore We don't want TS to generate types for the package.json
  26. import packageJson from '../package.json'
  27. import locale from './locale.ts'
  28. type GenericEventCallback = () => void
  29. export type DashboardFileEditStartCallback<M extends Meta, B extends Body> = (
  30. file?: UppyFile<M, B>,
  31. ) => void
  32. export type DashboardFileEditCompleteCallback<
  33. M extends Meta,
  34. B extends Body,
  35. > = (file?: UppyFile<M, B>) => void
  36. export type DashboardShowPlanelCallback = (id: string) => void
  37. declare module '@uppy/core' {
  38. export interface UppyEventMap<M extends Meta, B extends Body> {
  39. 'dashboard:modal-open': GenericEventCallback
  40. 'dashboard:modal-closed': GenericEventCallback
  41. 'dashboard:show-panel': DashboardShowPlanelCallback
  42. 'dashboard:file-edit-start': DashboardFileEditStartCallback<M, B>
  43. 'dashboard:file-edit-complete': DashboardFileEditCompleteCallback<M, B>
  44. 'dashboard:close-panel': (id: string | undefined) => void
  45. 'restore-canceled': GenericEventCallback
  46. }
  47. }
  48. interface PromiseWithResolvers<T> {
  49. promise: Promise<T>
  50. resolve: (value: T | PromiseLike<T>) => void
  51. reject: (reason?: any) => void
  52. }
  53. const memoize = ((memoizeOne as any).default as false) || memoizeOne
  54. const TAB_KEY = 9
  55. const ESC_KEY = 27
  56. function createPromise<T>(): PromiseWithResolvers<T> {
  57. const o = {} as PromiseWithResolvers<T>
  58. o.promise = new Promise<T>((resolve, reject) => {
  59. o.resolve = resolve
  60. o.reject = reject
  61. })
  62. return o
  63. }
  64. type FieldRenderOptions = {
  65. value: string
  66. onChange: (newVal: string) => void
  67. fieldCSSClasses: { text: string }
  68. required: boolean
  69. form: string
  70. }
  71. type PreactRender = (
  72. node: any,
  73. params: Record<string, unknown> | null,
  74. ...children: any[]
  75. ) => VNode<any>
  76. interface MetaField {
  77. id: string
  78. name: string
  79. placeholder?: string
  80. render?: (field: FieldRenderOptions, h: PreactRender) => VNode<any>
  81. }
  82. interface Target {
  83. id: string
  84. name: string
  85. type: string
  86. }
  87. interface TargetWithRender extends Target {
  88. icon: ComponentChild
  89. render: () => ComponentChild
  90. }
  91. export interface DashboardState<M extends Meta, B extends Body> {
  92. targets: Target[]
  93. activePickerPanel: Target | undefined
  94. showAddFilesPanel: boolean
  95. activeOverlayType: string | null
  96. fileCardFor: string | null
  97. showFileEditor: boolean
  98. metaFields?: MetaField[] | ((file: UppyFile<M, B>) => MetaField[])
  99. [key: string]: unknown
  100. }
  101. export interface DashboardModalOptions {
  102. inline?: false
  103. animateOpenClose?: boolean
  104. browserBackButtonClose?: boolean
  105. closeAfterFinish?: boolean
  106. closeModalOnClickOutside?: boolean
  107. disablePageScrollWhenModalOpen?: boolean
  108. }
  109. export interface DashboardInlineOptions {
  110. inline: true
  111. height?: string | number
  112. width?: string | number
  113. }
  114. interface DashboardMiscOptions<M extends Meta, B extends Body>
  115. extends UIPluginOptions {
  116. autoOpen?: 'metaEditor' | 'imageEditor' | null
  117. defaultPickerIcon?: typeof defaultPickerIcon
  118. disabled?: boolean
  119. disableInformer?: boolean
  120. disableLocalFiles?: boolean
  121. disableStatusBar?: boolean
  122. disableThumbnailGenerator?: boolean
  123. doneButtonHandler?: null | (() => void)
  124. fileManagerSelectionType?: 'files' | 'folders' | 'both'
  125. hideCancelButton?: boolean
  126. hidePauseResumeButton?: boolean
  127. hideProgressAfterFinish?: boolean
  128. hideRetryButton?: boolean
  129. hideUploadButton?: boolean
  130. metaFields?: MetaField[] | ((file: UppyFile<M, B>) => MetaField[])
  131. nativeCameraFacingMode?: ConstrainDOMString
  132. note?: string | null
  133. onDragLeave?: (event: DragEvent) => void
  134. onDragOver?: (event: DragEvent) => void
  135. onDrop?: (event: DragEvent) => void
  136. onRequestCloseModal?: () => void
  137. plugins?: string[]
  138. proudlyDisplayPoweredByUppy?: boolean
  139. showLinkToFileUploadResult?: boolean
  140. showNativePhotoCameraButton?: boolean
  141. showNativeVideoCameraButton?: boolean
  142. showProgressDetails?: boolean
  143. showRemoveButtonAfterComplete?: boolean
  144. showSelectedFiles?: boolean
  145. singleFileFullScreen?: boolean
  146. theme?: 'auto' | 'dark' | 'light'
  147. thumbnailHeight?: number
  148. thumbnailType?: string
  149. thumbnailWidth?: number
  150. trigger?: string | Element
  151. waitForThumbnailsBeforeUpload?: boolean
  152. }
  153. export type DashboardOptions<
  154. M extends Meta,
  155. B extends Body,
  156. > = DashboardMiscOptions<M, B> &
  157. (DashboardModalOptions | DashboardInlineOptions)
  158. const defaultOptions = {
  159. target: 'body',
  160. metaFields: [],
  161. inline: false as boolean,
  162. width: 750,
  163. height: 550,
  164. thumbnailWidth: 280,
  165. thumbnailType: 'image/jpeg',
  166. waitForThumbnailsBeforeUpload: false,
  167. defaultPickerIcon,
  168. showLinkToFileUploadResult: false,
  169. showProgressDetails: false,
  170. hideUploadButton: false,
  171. hideCancelButton: false,
  172. hideRetryButton: false,
  173. hidePauseResumeButton: false,
  174. hideProgressAfterFinish: false,
  175. note: null,
  176. closeModalOnClickOutside: false,
  177. closeAfterFinish: false,
  178. singleFileFullScreen: true,
  179. disableStatusBar: false,
  180. disableInformer: false,
  181. disableThumbnailGenerator: false,
  182. disablePageScrollWhenModalOpen: true,
  183. animateOpenClose: true,
  184. fileManagerSelectionType: 'files',
  185. proudlyDisplayPoweredByUppy: true,
  186. showSelectedFiles: true,
  187. showRemoveButtonAfterComplete: false,
  188. browserBackButtonClose: false,
  189. showNativePhotoCameraButton: false,
  190. showNativeVideoCameraButton: false,
  191. theme: 'light',
  192. autoOpen: null,
  193. disabled: false,
  194. disableLocalFiles: false,
  195. // Dynamic default options, they have to be defined in the constructor (because
  196. // they require access to the `this` keyword), but we still want them to
  197. // appear in the default options so TS knows they'll be defined.
  198. doneButtonHandler: undefined as any,
  199. onRequestCloseModal: null as any,
  200. } satisfies Partial<DashboardOptions<any, any>>
  201. /**
  202. * Dashboard UI with previews, metadata editing, tabs for various services and more
  203. */
  204. export default class Dashboard<M extends Meta, B extends Body> extends UIPlugin<
  205. DefinePluginOpts<
  206. // The options object inside the class is not the discriminated union but and intersection of the different subtypes.
  207. DashboardMiscOptions<M, B> &
  208. Omit<DashboardInlineOptions, 'inline'> &
  209. Omit<DashboardModalOptions, 'inline'> & { inline?: boolean },
  210. keyof typeof defaultOptions
  211. >,
  212. M,
  213. B,
  214. DashboardState<M, B>
  215. > {
  216. static VERSION = packageJson.version
  217. #disabledNodes!: HTMLElement[] | null
  218. private modalName = `uppy-Dashboard-${nanoid()}`
  219. private superFocus = createSuperFocus()
  220. private ifFocusedOnUppyRecently = false
  221. private dashboardIsDisabled!: boolean
  222. private savedScrollPosition!: number
  223. private savedActiveElement!: HTMLElement
  224. private resizeObserver!: ResizeObserver
  225. private darkModeMediaQuery!: MediaQueryList | null
  226. // Timeouts
  227. private makeDashboardInsidesVisibleAnywayTimeout!: ReturnType<
  228. typeof setTimeout
  229. >
  230. constructor(uppy: Uppy<M, B>, opts?: DashboardOptions<M, B>) {
  231. const autoOpen = opts?.autoOpen ?? null
  232. super(uppy, { ...defaultOptions, ...opts, autoOpen })
  233. this.id = this.opts.id || 'Dashboard'
  234. this.title = 'Dashboard'
  235. this.type = 'orchestrator'
  236. this.defaultLocale = locale
  237. // Dynamic default options:
  238. if (this.opts.doneButtonHandler === undefined) {
  239. // `null` means "do not display a Done button", while `undefined` means
  240. // "I want the default behavior". For this reason, we need to differentiate `null` and `undefined`.
  241. this.opts.doneButtonHandler = () => {
  242. this.uppy.clear()
  243. this.requestCloseModal()
  244. }
  245. }
  246. this.opts.onRequestCloseModal ??= () => this.closeModal()
  247. this.i18nInit()
  248. }
  249. removeTarget = (plugin: UnknownPlugin<M, B>): void => {
  250. const pluginState = this.getPluginState()
  251. // filter out the one we want to remove
  252. const newTargets = pluginState.targets.filter(
  253. (target) => target.id !== plugin.id,
  254. )
  255. this.setPluginState({
  256. targets: newTargets,
  257. })
  258. }
  259. addTarget = (plugin: UnknownPlugin<M, B>): HTMLElement | null => {
  260. const callerPluginId = plugin.id || plugin.constructor.name
  261. const callerPluginName =
  262. (plugin as any as { title: string }).title || callerPluginId
  263. const callerPluginType = plugin.type
  264. if (
  265. callerPluginType !== 'acquirer' &&
  266. callerPluginType !== 'progressindicator' &&
  267. callerPluginType !== 'editor'
  268. ) {
  269. const msg =
  270. 'Dashboard: can only be targeted by plugins of types: acquirer, progressindicator, editor'
  271. this.uppy.log(msg, 'error')
  272. return null
  273. }
  274. const target = {
  275. id: callerPluginId,
  276. name: callerPluginName,
  277. type: callerPluginType,
  278. }
  279. const state = this.getPluginState()
  280. const newTargets = state.targets.slice()
  281. newTargets.push(target)
  282. this.setPluginState({
  283. targets: newTargets,
  284. })
  285. return this.el
  286. }
  287. hideAllPanels = (): void => {
  288. const state = this.getPluginState()
  289. const update = {
  290. activePickerPanel: undefined,
  291. showAddFilesPanel: false,
  292. activeOverlayType: null,
  293. fileCardFor: null,
  294. showFileEditor: false,
  295. }
  296. if (
  297. state.activePickerPanel === update.activePickerPanel &&
  298. state.showAddFilesPanel === update.showAddFilesPanel &&
  299. state.showFileEditor === update.showFileEditor &&
  300. state.activeOverlayType === update.activeOverlayType
  301. ) {
  302. // avoid doing a state update if nothing changed
  303. return
  304. }
  305. this.setPluginState(update)
  306. this.uppy.emit('dashboard:close-panel', state.activePickerPanel?.id)
  307. }
  308. showPanel = (id: string): void => {
  309. const { targets } = this.getPluginState()
  310. const activePickerPanel = targets.find((target) => {
  311. return target.type === 'acquirer' && target.id === id
  312. })
  313. this.setPluginState({
  314. activePickerPanel,
  315. activeOverlayType: 'PickerPanel',
  316. })
  317. this.uppy.emit('dashboard:show-panel', id)
  318. }
  319. private canEditFile = (file: UppyFile<M, B>): boolean => {
  320. const { targets } = this.getPluginState()
  321. const editors = this.#getEditors(targets)
  322. return editors.some((target) =>
  323. (this.uppy.getPlugin(target.id) as any).canEditFile(file),
  324. )
  325. }
  326. openFileEditor = (file: UppyFile<M, B>): void => {
  327. const { targets } = this.getPluginState()
  328. const editors = this.#getEditors(targets)
  329. this.setPluginState({
  330. showFileEditor: true,
  331. fileCardFor: file.id || null,
  332. activeOverlayType: 'FileEditor',
  333. })
  334. editors.forEach((editor) => {
  335. ;(this.uppy.getPlugin(editor.id) as any).selectFile(file)
  336. })
  337. }
  338. closeFileEditor = (): void => {
  339. const { metaFields } = this.getPluginState()
  340. const isMetaEditorEnabled = metaFields && metaFields.length > 0
  341. if (isMetaEditorEnabled) {
  342. this.setPluginState({
  343. showFileEditor: false,
  344. activeOverlayType: 'FileCard',
  345. })
  346. } else {
  347. this.setPluginState({
  348. showFileEditor: false,
  349. fileCardFor: null,
  350. activeOverlayType: 'AddFiles',
  351. })
  352. }
  353. }
  354. saveFileEditor = (): void => {
  355. const { targets } = this.getPluginState()
  356. const editors = this.#getEditors(targets)
  357. editors.forEach((editor) => {
  358. ;(this.uppy.getPlugin(editor.id) as any).save()
  359. })
  360. this.closeFileEditor()
  361. }
  362. openModal = (): Promise<void> => {
  363. const { promise, resolve } = createPromise<void>()
  364. // save scroll position
  365. this.savedScrollPosition = window.pageYOffset
  366. // save active element, so we can restore focus when modal is closed
  367. this.savedActiveElement = document.activeElement as HTMLElement
  368. if (this.opts.disablePageScrollWhenModalOpen) {
  369. document.body.classList.add('uppy-Dashboard-isFixed')
  370. }
  371. if (this.opts.animateOpenClose && this.getPluginState().isClosing) {
  372. const handler = () => {
  373. this.setPluginState({
  374. isHidden: false,
  375. })
  376. this.el!.removeEventListener('animationend', handler, false)
  377. resolve()
  378. }
  379. this.el!.addEventListener('animationend', handler, false)
  380. } else {
  381. this.setPluginState({
  382. isHidden: false,
  383. })
  384. resolve()
  385. }
  386. if (this.opts.browserBackButtonClose) {
  387. this.updateBrowserHistory()
  388. }
  389. // handle ESC and TAB keys in modal dialog
  390. document.addEventListener('keydown', this.handleKeyDownInModal)
  391. this.uppy.emit('dashboard:modal-open')
  392. return promise
  393. }
  394. closeModal = (opts?: { manualClose: boolean }): void | Promise<void> => {
  395. // Whether the modal is being closed by the user (`true`) or by other means (e.g. browser back button)
  396. const manualClose = opts?.manualClose ?? true
  397. const { isHidden, isClosing } = this.getPluginState()
  398. if (isHidden || isClosing) {
  399. // short-circuit if animation is ongoing
  400. return undefined
  401. }
  402. const { promise, resolve } = createPromise<void>()
  403. if (this.opts.disablePageScrollWhenModalOpen) {
  404. document.body.classList.remove('uppy-Dashboard-isFixed')
  405. }
  406. if (this.opts.animateOpenClose) {
  407. this.setPluginState({
  408. isClosing: true,
  409. })
  410. const handler = () => {
  411. this.setPluginState({
  412. isHidden: true,
  413. isClosing: false,
  414. })
  415. this.superFocus.cancel()
  416. this.savedActiveElement.focus()
  417. this.el!.removeEventListener('animationend', handler, false)
  418. resolve()
  419. }
  420. this.el!.addEventListener('animationend', handler, false)
  421. } else {
  422. this.setPluginState({
  423. isHidden: true,
  424. })
  425. this.superFocus.cancel()
  426. this.savedActiveElement.focus()
  427. resolve()
  428. }
  429. // handle ESC and TAB keys in modal dialog
  430. document.removeEventListener('keydown', this.handleKeyDownInModal)
  431. if (manualClose) {
  432. if (this.opts.browserBackButtonClose) {
  433. // Make sure that the latest entry in the history state is our modal name
  434. // eslint-disable-next-line no-restricted-globals
  435. if (history.state?.[this.modalName]) {
  436. // Go back in history to clear out the entry we created (ultimately closing the modal)
  437. // eslint-disable-next-line no-restricted-globals
  438. history.back()
  439. }
  440. }
  441. }
  442. this.uppy.emit('dashboard:modal-closed')
  443. return promise
  444. }
  445. isModalOpen = (): boolean => {
  446. return !this.getPluginState().isHidden || false
  447. }
  448. private requestCloseModal = (): void | Promise<void> => {
  449. if (this.opts.onRequestCloseModal) {
  450. return this.opts.onRequestCloseModal()
  451. }
  452. return this.closeModal()
  453. }
  454. setDarkModeCapability = (isDarkModeOn: boolean): void => {
  455. const { capabilities } = this.uppy.getState()
  456. this.uppy.setState({
  457. capabilities: {
  458. ...capabilities,
  459. darkMode: isDarkModeOn,
  460. },
  461. })
  462. }
  463. private handleSystemDarkModeChange = (event: MediaQueryListEvent) => {
  464. const isDarkModeOnNow = event.matches
  465. this.uppy.log(`[Dashboard] Dark mode is ${isDarkModeOnNow ? 'on' : 'off'}`)
  466. this.setDarkModeCapability(isDarkModeOnNow)
  467. }
  468. private toggleFileCard = (show: boolean, fileID: string) => {
  469. const file = this.uppy.getFile(fileID)
  470. if (show) {
  471. this.uppy.emit('dashboard:file-edit-start', file)
  472. } else {
  473. this.uppy.emit('dashboard:file-edit-complete', file)
  474. }
  475. this.setPluginState({
  476. fileCardFor: show ? fileID : null,
  477. activeOverlayType: show ? 'FileCard' : null,
  478. })
  479. }
  480. private toggleAddFilesPanel = (show: boolean) => {
  481. this.setPluginState({
  482. showAddFilesPanel: show,
  483. activeOverlayType: show ? 'AddFiles' : null,
  484. })
  485. }
  486. addFiles = (files: File[]): void => {
  487. const descriptors = files.map((file) => ({
  488. source: this.id,
  489. name: file.name,
  490. type: file.type,
  491. data: file,
  492. meta: {
  493. // path of the file relative to the ancestor directory the user selected.
  494. // e.g. 'docs/Old Prague/airbnb.pdf'
  495. relativePath:
  496. (file as any).relativePath || file.webkitRelativePath || null,
  497. } as any as M,
  498. }))
  499. try {
  500. this.uppy.addFiles(descriptors)
  501. } catch (err) {
  502. this.uppy.log(err as any)
  503. }
  504. }
  505. // ___Why make insides of Dashboard invisible until first ResizeObserver event is emitted?
  506. // ResizeOberserver doesn't emit the first resize event fast enough, users can see the jump from one .uppy-size-- to
  507. // another (e.g. in Safari)
  508. // ___Why not apply visibility property to .uppy-Dashboard-inner?
  509. // Because ideally, acc to specs, ResizeObserver should see invisible elements as of width 0. So even though applying
  510. // invisibility to .uppy-Dashboard-inner works now, it may not work in the future.
  511. private startListeningToResize = () => {
  512. // Watch for Dashboard container (`.uppy-Dashboard-inner`) resize
  513. // and update containerWidth/containerHeight in plugin state accordingly.
  514. // Emits first event on initialization.
  515. this.resizeObserver = new ResizeObserver((entries) => {
  516. const uppyDashboardInnerEl = entries[0]
  517. const { width, height } = uppyDashboardInnerEl.contentRect
  518. this.setPluginState({
  519. containerWidth: width,
  520. containerHeight: height,
  521. areInsidesReadyToBeVisible: true,
  522. })
  523. })
  524. this.resizeObserver.observe(
  525. this.el!.querySelector('.uppy-Dashboard-inner')!,
  526. )
  527. // If ResizeObserver fails to emit an event telling us what size to use - default to the mobile view
  528. this.makeDashboardInsidesVisibleAnywayTimeout = setTimeout(() => {
  529. const pluginState = this.getPluginState()
  530. const isModalAndClosed = !this.opts.inline && pluginState.isHidden
  531. if (
  532. // We might want to enable this in the future
  533. // if ResizeObserver hasn't yet fired,
  534. !pluginState.areInsidesReadyToBeVisible &&
  535. // and it's not due to the modal being closed
  536. !isModalAndClosed
  537. ) {
  538. this.uppy.log(
  539. '[Dashboard] resize event didn’t fire on time: defaulted to mobile layout',
  540. 'warning',
  541. )
  542. this.setPluginState({
  543. areInsidesReadyToBeVisible: true,
  544. })
  545. }
  546. }, 1000)
  547. }
  548. private stopListeningToResize = () => {
  549. this.resizeObserver.disconnect()
  550. clearTimeout(this.makeDashboardInsidesVisibleAnywayTimeout)
  551. }
  552. // Records whether we have been interacting with uppy right now,
  553. // which is then used to determine whether state updates should trigger a refocusing.
  554. private recordIfFocusedOnUppyRecently = (event: Event) => {
  555. if (this.el!.contains(event.target as HTMLElement)) {
  556. this.ifFocusedOnUppyRecently = true
  557. } else {
  558. this.ifFocusedOnUppyRecently = false
  559. // ___Why run this.superFocus.cancel here when it already runs in superFocusOnEachUpdate?
  560. // Because superFocus is debounced, when we move from Uppy to some other element on the page,
  561. // previously run superFocus sometimes hits and moves focus back to Uppy.
  562. this.superFocus.cancel()
  563. }
  564. }
  565. private disableInteractiveElements = (disable: boolean) => {
  566. const NODES_TO_DISABLE = [
  567. 'a[href]',
  568. 'input:not([disabled])',
  569. 'select:not([disabled])',
  570. 'textarea:not([disabled])',
  571. 'button:not([disabled])',
  572. '[role="button"]:not([disabled])',
  573. ]
  574. const nodesToDisable =
  575. this.#disabledNodes ??
  576. toArray(this.el!.querySelectorAll(NODES_TO_DISABLE as any)).filter(
  577. (node) => !node.classList.contains('uppy-Dashboard-close'),
  578. )
  579. for (const node of nodesToDisable) {
  580. // Links can’t have `disabled` attr, so we use `aria-disabled` for a11y
  581. if (node.tagName === 'A') {
  582. node.setAttribute('aria-disabled', disable)
  583. } else {
  584. node.disabled = disable
  585. }
  586. }
  587. if (disable) {
  588. this.#disabledNodes = nodesToDisable
  589. } else {
  590. this.#disabledNodes = null
  591. }
  592. this.dashboardIsDisabled = disable
  593. }
  594. private updateBrowserHistory = () => {
  595. // Ensure history state does not already contain our modal name to avoid double-pushing
  596. // eslint-disable-next-line no-restricted-globals
  597. if (!history.state?.[this.modalName]) {
  598. // Push to history so that the page is not lost on browser back button press
  599. // eslint-disable-next-line no-restricted-globals
  600. history.pushState(
  601. {
  602. // eslint-disable-next-line no-restricted-globals
  603. ...history.state,
  604. [this.modalName]: true,
  605. },
  606. '',
  607. )
  608. }
  609. // Listen for back button presses
  610. window.addEventListener('popstate', this.handlePopState, false)
  611. }
  612. private handlePopState = (event: PopStateEvent) => {
  613. // Close the modal if the history state no longer contains our modal name
  614. if (this.isModalOpen() && (!event.state || !event.state[this.modalName])) {
  615. this.closeModal({ manualClose: false })
  616. }
  617. // When the browser back button is pressed and uppy is now the latest entry
  618. // in the history but the modal is closed, fix the history by removing the
  619. // uppy history entry.
  620. // This occurs when another entry is added into the history state while the
  621. // modal is open, and then the modal gets manually closed.
  622. // Solves PR #575 (https://github.com/transloadit/uppy/pull/575)
  623. if (!this.isModalOpen() && event.state?.[this.modalName]) {
  624. // eslint-disable-next-line no-restricted-globals
  625. history.back()
  626. }
  627. }
  628. private handleKeyDownInModal = (event: KeyboardEvent) => {
  629. // close modal on esc key press
  630. if (event.keyCode === ESC_KEY) this.requestCloseModal()
  631. // trap focus on tab key press
  632. if (event.keyCode === TAB_KEY)
  633. trapFocus.forModal(
  634. event,
  635. this.getPluginState().activeOverlayType,
  636. this.el,
  637. )
  638. }
  639. private handleClickOutside = () => {
  640. if (this.opts.closeModalOnClickOutside) this.requestCloseModal()
  641. }
  642. private handlePaste = (event: ClipboardEvent) => {
  643. // Let any acquirer plugin (Url/Webcam/etc.) handle pastes to the root
  644. this.uppy.iteratePlugins((plugin) => {
  645. if (plugin.type === 'acquirer') {
  646. // Every Plugin with .type acquirer can define handleRootPaste(event)
  647. ;(plugin as any).handleRootPaste?.(event)
  648. }
  649. })
  650. // Add all dropped files
  651. const files = toArray(event.clipboardData!.files)
  652. if (files.length > 0) {
  653. this.uppy.log('[Dashboard] Files pasted')
  654. this.addFiles(files)
  655. }
  656. }
  657. private handleInputChange = (event: InputEvent) => {
  658. event.preventDefault()
  659. const files = toArray((event.target as HTMLInputElement).files!)
  660. if (files.length > 0) {
  661. this.uppy.log('[Dashboard] Files selected through input')
  662. this.addFiles(files)
  663. }
  664. }
  665. private handleDragOver = (event: DragEvent) => {
  666. event.preventDefault()
  667. event.stopPropagation()
  668. // Check if some plugin can handle the datatransfer without files —
  669. // for instance, the Url plugin can import a url
  670. const canSomePluginHandleRootDrop = () => {
  671. let somePluginCanHandleRootDrop = true
  672. this.uppy.iteratePlugins((plugin) => {
  673. if ((plugin as any).canHandleRootDrop?.(event)) {
  674. somePluginCanHandleRootDrop = true
  675. }
  676. })
  677. return somePluginCanHandleRootDrop
  678. }
  679. // Check if the "type" of the datatransfer object includes files
  680. const doesEventHaveFiles = () => {
  681. const { types } = event.dataTransfer!
  682. return types.some((type) => type === 'Files')
  683. }
  684. // Deny drop, if no plugins can handle datatransfer, there are no files,
  685. // or when opts.disabled is set, or new uploads are not allowed
  686. const somePluginCanHandleRootDrop = canSomePluginHandleRootDrop()
  687. const hasFiles = doesEventHaveFiles()
  688. if (
  689. (!somePluginCanHandleRootDrop && !hasFiles) ||
  690. this.opts.disabled ||
  691. // opts.disableLocalFiles should only be taken into account if no plugins
  692. // can handle the datatransfer
  693. (this.opts.disableLocalFiles &&
  694. (hasFiles || !somePluginCanHandleRootDrop)) ||
  695. !this.uppy.getState().allowNewUpload
  696. ) {
  697. event.dataTransfer!.dropEffect = 'none' // eslint-disable-line no-param-reassign
  698. return
  699. }
  700. // Add a small (+) icon on drop
  701. // (and prevent browsers from interpreting this as files being _moved_ into the
  702. // browser, https://github.com/transloadit/uppy/issues/1978).
  703. event.dataTransfer!.dropEffect = 'copy' // eslint-disable-line no-param-reassign
  704. this.setPluginState({ isDraggingOver: true })
  705. this.opts.onDragOver?.(event)
  706. }
  707. private handleDragLeave = (event: DragEvent) => {
  708. event.preventDefault()
  709. event.stopPropagation()
  710. this.setPluginState({ isDraggingOver: false })
  711. this.opts.onDragLeave?.(event)
  712. }
  713. private handleDrop = async (event: DragEvent) => {
  714. event.preventDefault()
  715. event.stopPropagation()
  716. this.setPluginState({ isDraggingOver: false })
  717. // Let any acquirer plugin (Url/Webcam/etc.) handle drops to the root
  718. this.uppy.iteratePlugins((plugin) => {
  719. if (plugin.type === 'acquirer') {
  720. // Every Plugin with .type acquirer can define handleRootDrop(event)
  721. ;(plugin as any).handleRootDrop?.(event)
  722. }
  723. })
  724. // Add all dropped files
  725. let executedDropErrorOnce = false
  726. const logDropError = (error: any) => {
  727. this.uppy.log(error, 'error')
  728. // In practice all drop errors are most likely the same,
  729. // so let's just show one to avoid overwhelming the user
  730. if (!executedDropErrorOnce) {
  731. this.uppy.info(error.message, 'error')
  732. executedDropErrorOnce = true
  733. }
  734. }
  735. this.uppy.log('[Dashboard] Processing dropped files')
  736. // Add all dropped files
  737. const files = await getDroppedFiles(event.dataTransfer!, { logDropError })
  738. if (files.length > 0) {
  739. this.uppy.log('[Dashboard] Files dropped')
  740. this.addFiles(files)
  741. }
  742. this.opts.onDrop?.(event)
  743. }
  744. private handleRequestThumbnail = (file: UppyFile<M, B>) => {
  745. if (!this.opts.waitForThumbnailsBeforeUpload) {
  746. this.uppy.emit('thumbnail:request', file)
  747. }
  748. }
  749. /**
  750. * We cancel thumbnail requests when a file item component unmounts to avoid
  751. * clogging up the queue when the user scrolls past many elements.
  752. */
  753. private handleCancelThumbnail = (file: UppyFile<M, B>) => {
  754. if (!this.opts.waitForThumbnailsBeforeUpload) {
  755. this.uppy.emit('thumbnail:cancel', file)
  756. }
  757. }
  758. private handleKeyDownInInline = (event: KeyboardEvent) => {
  759. // Trap focus on tab key press.
  760. if (event.keyCode === TAB_KEY)
  761. trapFocus.forInline(
  762. event,
  763. this.getPluginState().activeOverlayType,
  764. this.el,
  765. )
  766. }
  767. // ___Why do we listen to the 'paste' event on a document instead of onPaste={props.handlePaste} prop,
  768. // or this.el.addEventListener('paste')?
  769. // Because (at least) Chrome doesn't handle paste if focus is on some button, e.g. 'My Device'.
  770. // => Therefore, the best option is to listen to all 'paste' events, and only react to them when we are focused on our
  771. // particular Uppy instance.
  772. // ___Why do we still need onPaste={props.handlePaste} for the DashboardUi?
  773. // Because if we click on the 'Drop files here' caption e.g., `document.activeElement` will be 'body'. Which means our
  774. // standard determination of whether we're pasting into our Uppy instance won't work.
  775. // => Therefore, we need a traditional onPaste={props.handlePaste} handler too.
  776. private handlePasteOnBody = (event: ClipboardEvent) => {
  777. const isFocusInOverlay = this.el!.contains(document.activeElement)
  778. if (isFocusInOverlay) {
  779. this.handlePaste(event)
  780. }
  781. }
  782. private handleComplete = ({ failed }: UploadResult<M, B>) => {
  783. if (this.opts.closeAfterFinish && !failed?.length) {
  784. // All uploads are done
  785. this.requestCloseModal()
  786. }
  787. }
  788. private handleCancelRestore = () => {
  789. this.uppy.emit('restore-canceled')
  790. }
  791. #generateLargeThumbnailIfSingleFile = () => {
  792. if (this.opts.disableThumbnailGenerator) {
  793. return
  794. }
  795. const LARGE_THUMBNAIL = 600
  796. const files = this.uppy.getFiles()
  797. if (files.length === 1) {
  798. const thumbnailGenerator = this.uppy.getPlugin(
  799. `${this.id}:ThumbnailGenerator`,
  800. ) as ThumbnailGenerator<M, B> | undefined
  801. thumbnailGenerator?.setOptions({ thumbnailWidth: LARGE_THUMBNAIL })
  802. const fileForThumbnail = { ...files[0], preview: undefined }
  803. thumbnailGenerator?.requestThumbnail(fileForThumbnail).then(() => {
  804. thumbnailGenerator?.setOptions({
  805. thumbnailWidth: this.opts.thumbnailWidth,
  806. })
  807. })
  808. }
  809. }
  810. #openFileEditorWhenFilesAdded = (files: UppyFile<M, B>[]) => {
  811. const firstFile = files[0]
  812. const { metaFields } = this.getPluginState()
  813. const isMetaEditorEnabled = metaFields && metaFields.length > 0
  814. const isImageEditorEnabled = this.canEditFile(firstFile)
  815. if (isMetaEditorEnabled && this.opts.autoOpen === 'metaEditor') {
  816. this.toggleFileCard(true, firstFile.id)
  817. } else if (isImageEditorEnabled && this.opts.autoOpen === 'imageEditor') {
  818. this.openFileEditor(firstFile)
  819. }
  820. }
  821. initEvents = (): void => {
  822. // Modal open button
  823. if (this.opts.trigger && !this.opts.inline) {
  824. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  825. if (showModalTrigger) {
  826. showModalTrigger.forEach((trigger) =>
  827. trigger.addEventListener('click', this.openModal),
  828. )
  829. } else {
  830. this.uppy.log(
  831. 'Dashboard modal trigger not found. Make sure `trigger` is set in Dashboard options, unless you are planning to call `dashboard.openModal()` method yourself',
  832. 'warning',
  833. )
  834. }
  835. }
  836. this.startListeningToResize()
  837. document.addEventListener('paste', this.handlePasteOnBody)
  838. this.uppy.on('plugin-added', this.#addSupportedPluginIfNoTarget)
  839. this.uppy.on('plugin-remove', this.removeTarget)
  840. this.uppy.on('file-added', this.hideAllPanels)
  841. this.uppy.on('dashboard:modal-closed', this.hideAllPanels)
  842. this.uppy.on('complete', this.handleComplete)
  843. this.uppy.on('files-added', this.#generateLargeThumbnailIfSingleFile)
  844. this.uppy.on('file-removed', this.#generateLargeThumbnailIfSingleFile)
  845. // ___Why fire on capture?
  846. // Because this.ifFocusedOnUppyRecently needs to change before onUpdate() fires.
  847. document.addEventListener('focus', this.recordIfFocusedOnUppyRecently, true)
  848. document.addEventListener('click', this.recordIfFocusedOnUppyRecently, true)
  849. if (this.opts.inline) {
  850. this.el!.addEventListener('keydown', this.handleKeyDownInInline)
  851. }
  852. if (this.opts.autoOpen) {
  853. this.uppy.on('files-added', this.#openFileEditorWhenFilesAdded)
  854. }
  855. }
  856. removeEvents = (): void => {
  857. const showModalTrigger = findAllDOMElements(this.opts.trigger)
  858. if (!this.opts.inline && showModalTrigger) {
  859. showModalTrigger.forEach((trigger) =>
  860. trigger.removeEventListener('click', this.openModal),
  861. )
  862. }
  863. this.stopListeningToResize()
  864. document.removeEventListener('paste', this.handlePasteOnBody)
  865. window.removeEventListener('popstate', this.handlePopState, false)
  866. this.uppy.off('plugin-added', this.#addSupportedPluginIfNoTarget)
  867. this.uppy.off('plugin-remove', this.removeTarget)
  868. this.uppy.off('file-added', this.hideAllPanels)
  869. this.uppy.off('dashboard:modal-closed', this.hideAllPanels)
  870. this.uppy.off('complete', this.handleComplete)
  871. this.uppy.off('files-added', this.#generateLargeThumbnailIfSingleFile)
  872. this.uppy.off('file-removed', this.#generateLargeThumbnailIfSingleFile)
  873. document.removeEventListener('focus', this.recordIfFocusedOnUppyRecently)
  874. document.removeEventListener('click', this.recordIfFocusedOnUppyRecently)
  875. if (this.opts.inline) {
  876. this.el!.removeEventListener('keydown', this.handleKeyDownInInline)
  877. }
  878. if (this.opts.autoOpen) {
  879. this.uppy.off('files-added', this.#openFileEditorWhenFilesAdded)
  880. }
  881. }
  882. private superFocusOnEachUpdate = () => {
  883. const isFocusInUppy = this.el!.contains(document.activeElement)
  884. // When focus is lost on the page (== focus is on body for most browsers, or focus is null for IE11)
  885. const isFocusNowhere =
  886. document.activeElement === document.body ||
  887. document.activeElement === null
  888. const isInformerHidden = this.uppy.getState().info.length === 0
  889. const isModal = !this.opts.inline
  890. if (
  891. // If update is connected to showing the Informer - let the screen reader calmly read it.
  892. isInformerHidden &&
  893. // If we are in a modal - always superfocus without concern for other elements
  894. // on the page (user is unlikely to want to interact with the rest of the page)
  895. (isModal ||
  896. // If we are already inside of Uppy, or
  897. isFocusInUppy ||
  898. // If we are not focused on anything BUT we have already, at least once, focused on uppy
  899. // 1. We focus when isFocusNowhere, because when the element we were focused
  900. // on disappears (e.g. an overlay), - focus gets lost. If user is typing
  901. // something somewhere else on the page, - focus won't be 'nowhere'.
  902. // 2. We only focus when focus is nowhere AND this.ifFocusedOnUppyRecently,
  903. // to avoid focus jumps if we do something else on the page.
  904. // [Practical check] Without '&& this.ifFocusedOnUppyRecently', in Safari, in inline mode,
  905. // when file is uploading, - navigate via tab to the checkbox,
  906. // try to press space multiple times. Focus will jump to Uppy.
  907. (isFocusNowhere && this.ifFocusedOnUppyRecently))
  908. ) {
  909. this.superFocus(this.el, this.getPluginState().activeOverlayType)
  910. } else {
  911. this.superFocus.cancel()
  912. }
  913. }
  914. readonly afterUpdate = (): void => {
  915. if (this.opts.disabled && !this.dashboardIsDisabled) {
  916. this.disableInteractiveElements(true)
  917. return
  918. }
  919. if (!this.opts.disabled && this.dashboardIsDisabled) {
  920. this.disableInteractiveElements(false)
  921. }
  922. this.superFocusOnEachUpdate()
  923. }
  924. saveFileCard = (meta: M, fileID: string): void => {
  925. this.uppy.setFileMeta(fileID, meta)
  926. this.toggleFileCard(false, fileID)
  927. }
  928. #attachRenderFunctionToTarget = (target: Target): TargetWithRender => {
  929. const plugin = this.uppy.getPlugin(target.id) as any
  930. return {
  931. ...target,
  932. icon: plugin.icon || this.opts.defaultPickerIcon,
  933. render: plugin.render,
  934. }
  935. }
  936. #isTargetSupported = (target: Target) => {
  937. const plugin = this.uppy.getPlugin(target.id) as any as {
  938. isSupported?: () => boolean
  939. }
  940. // If the plugin does not provide a `supported` check, assume the plugin works everywhere.
  941. if (typeof plugin.isSupported !== 'function') {
  942. return true
  943. }
  944. return plugin.isSupported()
  945. }
  946. #getAcquirers = memoize((targets: Target[]) => {
  947. return targets
  948. .filter(
  949. (target) =>
  950. target.type === 'acquirer' && this.#isTargetSupported(target),
  951. )
  952. .map(this.#attachRenderFunctionToTarget)
  953. })
  954. #getProgressIndicators = memoize((targets: Target[]) => {
  955. return targets
  956. .filter((target) => target.type === 'progressindicator')
  957. .map(this.#attachRenderFunctionToTarget)
  958. })
  959. #getEditors = memoize((targets: Target[]) => {
  960. return targets
  961. .filter((target) => target.type === 'editor')
  962. .map(this.#attachRenderFunctionToTarget)
  963. })
  964. render = (state: State<M, B>) => {
  965. const pluginState = this.getPluginState()
  966. const { files, capabilities, allowNewUpload } = state
  967. const {
  968. newFiles,
  969. uploadStartedFiles,
  970. completeFiles,
  971. erroredFiles,
  972. inProgressFiles,
  973. inProgressNotPausedFiles,
  974. processingFiles,
  975. isUploadStarted,
  976. isAllComplete,
  977. isAllPaused,
  978. } = this.uppy.getObjectOfFilesPerState()
  979. const acquirers = this.#getAcquirers(pluginState.targets)
  980. const progressindicators = this.#getProgressIndicators(pluginState.targets)
  981. const editors = this.#getEditors(pluginState.targets)
  982. let theme
  983. if (this.opts.theme === 'auto') {
  984. theme = capabilities.darkMode ? 'dark' : 'light'
  985. } else {
  986. theme = this.opts.theme
  987. }
  988. if (
  989. ['files', 'folders', 'both'].indexOf(this.opts.fileManagerSelectionType) <
  990. 0
  991. ) {
  992. this.opts.fileManagerSelectionType = 'files'
  993. // eslint-disable-next-line no-console
  994. console.warn(
  995. `Unsupported option for "fileManagerSelectionType". Using default of "${this.opts.fileManagerSelectionType}".`,
  996. )
  997. }
  998. return DashboardUI({
  999. state,
  1000. isHidden: pluginState.isHidden,
  1001. files,
  1002. newFiles,
  1003. uploadStartedFiles,
  1004. completeFiles,
  1005. erroredFiles,
  1006. inProgressFiles,
  1007. inProgressNotPausedFiles,
  1008. processingFiles,
  1009. isUploadStarted,
  1010. isAllComplete,
  1011. isAllPaused,
  1012. totalFileCount: Object.keys(files).length,
  1013. totalProgress: state.totalProgress,
  1014. allowNewUpload,
  1015. acquirers,
  1016. theme,
  1017. disabled: this.opts.disabled,
  1018. disableLocalFiles: this.opts.disableLocalFiles,
  1019. direction: this.opts.direction,
  1020. activePickerPanel: pluginState.activePickerPanel,
  1021. showFileEditor: pluginState.showFileEditor,
  1022. saveFileEditor: this.saveFileEditor,
  1023. closeFileEditor: this.closeFileEditor,
  1024. disableInteractiveElements: this.disableInteractiveElements,
  1025. animateOpenClose: this.opts.animateOpenClose,
  1026. isClosing: pluginState.isClosing,
  1027. progressindicators,
  1028. editors,
  1029. autoProceed: this.uppy.opts.autoProceed,
  1030. id: this.id,
  1031. closeModal: this.requestCloseModal,
  1032. handleClickOutside: this.handleClickOutside,
  1033. handleInputChange: this.handleInputChange,
  1034. handlePaste: this.handlePaste,
  1035. inline: this.opts.inline,
  1036. showPanel: this.showPanel,
  1037. hideAllPanels: this.hideAllPanels,
  1038. i18n: this.i18n,
  1039. i18nArray: this.i18nArray,
  1040. uppy: this.uppy,
  1041. note: this.opts.note,
  1042. recoveredState: state.recoveredState,
  1043. metaFields: pluginState.metaFields,
  1044. resumableUploads: capabilities.resumableUploads || false,
  1045. individualCancellation: capabilities.individualCancellation,
  1046. isMobileDevice: capabilities.isMobileDevice,
  1047. fileCardFor: pluginState.fileCardFor,
  1048. toggleFileCard: this.toggleFileCard,
  1049. toggleAddFilesPanel: this.toggleAddFilesPanel,
  1050. showAddFilesPanel: pluginState.showAddFilesPanel,
  1051. saveFileCard: this.saveFileCard,
  1052. openFileEditor: this.openFileEditor,
  1053. canEditFile: this.canEditFile,
  1054. width: this.opts.width,
  1055. height: this.opts.height,
  1056. showLinkToFileUploadResult: this.opts.showLinkToFileUploadResult,
  1057. fileManagerSelectionType: this.opts.fileManagerSelectionType,
  1058. proudlyDisplayPoweredByUppy: this.opts.proudlyDisplayPoweredByUppy,
  1059. hideCancelButton: this.opts.hideCancelButton,
  1060. hideRetryButton: this.opts.hideRetryButton,
  1061. hidePauseResumeButton: this.opts.hidePauseResumeButton,
  1062. showRemoveButtonAfterComplete: this.opts.showRemoveButtonAfterComplete,
  1063. containerWidth: pluginState.containerWidth,
  1064. containerHeight: pluginState.containerHeight,
  1065. areInsidesReadyToBeVisible: pluginState.areInsidesReadyToBeVisible,
  1066. parentElement: this.el,
  1067. allowedFileTypes: this.uppy.opts.restrictions.allowedFileTypes,
  1068. maxNumberOfFiles: this.uppy.opts.restrictions.maxNumberOfFiles,
  1069. requiredMetaFields: this.uppy.opts.restrictions.requiredMetaFields,
  1070. showSelectedFiles: this.opts.showSelectedFiles,
  1071. showNativePhotoCameraButton: this.opts.showNativePhotoCameraButton,
  1072. showNativeVideoCameraButton: this.opts.showNativeVideoCameraButton,
  1073. nativeCameraFacingMode: this.opts.nativeCameraFacingMode,
  1074. singleFileFullScreen: this.opts.singleFileFullScreen,
  1075. handleCancelRestore: this.handleCancelRestore,
  1076. handleRequestThumbnail: this.handleRequestThumbnail,
  1077. handleCancelThumbnail: this.handleCancelThumbnail,
  1078. // drag props
  1079. isDraggingOver: pluginState.isDraggingOver,
  1080. handleDragOver: this.handleDragOver,
  1081. handleDragLeave: this.handleDragLeave,
  1082. handleDrop: this.handleDrop,
  1083. })
  1084. }
  1085. #addSpecifiedPluginsFromOptions = () => {
  1086. const plugins = this.opts.plugins || []
  1087. plugins.forEach((pluginID) => {
  1088. const plugin = this.uppy.getPlugin(pluginID)
  1089. if (plugin) {
  1090. ;(plugin as any).mount(this, plugin)
  1091. } else {
  1092. this.uppy.log(
  1093. `[Uppy] Dashboard could not find plugin '${pluginID}', make sure to uppy.use() the plugins you are specifying`,
  1094. 'warning',
  1095. )
  1096. }
  1097. })
  1098. }
  1099. #autoDiscoverPlugins = () => {
  1100. this.uppy.iteratePlugins(this.#addSupportedPluginIfNoTarget)
  1101. }
  1102. #addSupportedPluginIfNoTarget = (plugin?: UnknownPlugin<M, B>) => {
  1103. // Only these types belong on the Dashboard,
  1104. // we wouldn’t want to try and mount Compressor or Tus, for example.
  1105. const typesAllowed = ['acquirer', 'editor']
  1106. if (plugin && !plugin.opts?.target && typesAllowed.includes(plugin.type)) {
  1107. const pluginAlreadyAdded = this.getPluginState().targets.some(
  1108. (installedPlugin) => plugin.id === installedPlugin.id,
  1109. )
  1110. if (!pluginAlreadyAdded) {
  1111. ;(plugin as any).mount(this, plugin)
  1112. }
  1113. }
  1114. }
  1115. #getStatusBarOpts() {
  1116. const {
  1117. hideUploadButton,
  1118. hideRetryButton,
  1119. hidePauseResumeButton,
  1120. hideCancelButton,
  1121. showProgressDetails,
  1122. hideProgressAfterFinish,
  1123. locale: l,
  1124. doneButtonHandler,
  1125. } = this.opts
  1126. return {
  1127. hideUploadButton,
  1128. hideRetryButton,
  1129. hidePauseResumeButton,
  1130. hideCancelButton,
  1131. showProgressDetails,
  1132. hideAfterFinish: hideProgressAfterFinish,
  1133. locale: l,
  1134. doneButtonHandler,
  1135. }
  1136. }
  1137. #getThumbnailGeneratorOpts() {
  1138. const {
  1139. thumbnailWidth,
  1140. thumbnailHeight,
  1141. thumbnailType,
  1142. waitForThumbnailsBeforeUpload,
  1143. } = this.opts
  1144. return {
  1145. thumbnailWidth,
  1146. thumbnailHeight,
  1147. thumbnailType,
  1148. waitForThumbnailsBeforeUpload,
  1149. // If we don't block on thumbnails, we can lazily generate them
  1150. lazy: !waitForThumbnailsBeforeUpload,
  1151. }
  1152. }
  1153. // eslint-disable-next-line class-methods-use-this
  1154. #getInformerOpts() {
  1155. return {
  1156. // currently no options
  1157. }
  1158. }
  1159. setOptions(opts: Partial<DashboardOptions<M, B>>) {
  1160. super.setOptions(opts)
  1161. this.uppy
  1162. .getPlugin(this.#getStatusBarId())
  1163. ?.setOptions(this.#getStatusBarOpts())
  1164. this.uppy
  1165. .getPlugin(this.#getThumbnailGeneratorId())
  1166. ?.setOptions(this.#getThumbnailGeneratorOpts())
  1167. }
  1168. #getStatusBarId() {
  1169. return `${this.id}:StatusBar`
  1170. }
  1171. #getThumbnailGeneratorId() {
  1172. return `${this.id}:ThumbnailGenerator`
  1173. }
  1174. #getInformerId() {
  1175. return `${this.id}:Informer`
  1176. }
  1177. install = (): void => {
  1178. // Set default state for Dashboard
  1179. this.setPluginState({
  1180. isHidden: true,
  1181. fileCardFor: null,
  1182. activeOverlayType: null,
  1183. showAddFilesPanel: false,
  1184. activePickerPanel: undefined,
  1185. showFileEditor: false,
  1186. metaFields: this.opts.metaFields,
  1187. targets: [],
  1188. // We'll make them visible once .containerWidth is determined
  1189. areInsidesReadyToBeVisible: false,
  1190. isDraggingOver: false,
  1191. })
  1192. const { inline, closeAfterFinish } = this.opts
  1193. if (inline && closeAfterFinish) {
  1194. throw new Error(
  1195. '[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.',
  1196. )
  1197. }
  1198. const { allowMultipleUploads, allowMultipleUploadBatches } = this.uppy.opts
  1199. if (
  1200. (allowMultipleUploads || allowMultipleUploadBatches) &&
  1201. closeAfterFinish
  1202. ) {
  1203. this.uppy.log(
  1204. '[Dashboard] When using `closeAfterFinish`, we recommended setting the `allowMultipleUploadBatches` option to `false` in the Uppy constructor. See https://uppy.io/docs/uppy/#allowMultipleUploads-true',
  1205. 'warning',
  1206. )
  1207. }
  1208. const { target } = this.opts
  1209. if (target) {
  1210. this.mount(target, this)
  1211. }
  1212. if (!this.opts.disableStatusBar) {
  1213. this.uppy.use(StatusBar, {
  1214. id: this.#getStatusBarId(),
  1215. target: this,
  1216. ...this.#getStatusBarOpts(),
  1217. })
  1218. }
  1219. if (!this.opts.disableInformer) {
  1220. this.uppy.use(Informer, {
  1221. id: this.#getInformerId(),
  1222. target: this,
  1223. ...this.#getInformerOpts(),
  1224. })
  1225. }
  1226. if (!this.opts.disableThumbnailGenerator) {
  1227. this.uppy.use(ThumbnailGenerator, {
  1228. id: this.#getThumbnailGeneratorId(),
  1229. ...this.#getThumbnailGeneratorOpts(),
  1230. })
  1231. }
  1232. // Dark Mode / theme
  1233. this.darkModeMediaQuery =
  1234. typeof window !== 'undefined' && window.matchMedia ?
  1235. window.matchMedia('(prefers-color-scheme: dark)')
  1236. : null
  1237. const isDarkModeOnFromTheStart =
  1238. this.darkModeMediaQuery ? this.darkModeMediaQuery.matches : false
  1239. this.uppy.log(
  1240. `[Dashboard] Dark mode is ${isDarkModeOnFromTheStart ? 'on' : 'off'}`,
  1241. )
  1242. this.setDarkModeCapability(isDarkModeOnFromTheStart)
  1243. if (this.opts.theme === 'auto') {
  1244. this.darkModeMediaQuery?.addListener(this.handleSystemDarkModeChange)
  1245. }
  1246. this.#addSpecifiedPluginsFromOptions()
  1247. this.#autoDiscoverPlugins()
  1248. this.initEvents()
  1249. }
  1250. uninstall = (): void => {
  1251. if (!this.opts.disableInformer) {
  1252. const informer = this.uppy.getPlugin(`${this.id}:Informer`)
  1253. // Checking if this plugin exists, in case it was removed by uppy-core
  1254. // before the Dashboard was.
  1255. if (informer) this.uppy.removePlugin(informer)
  1256. }
  1257. if (!this.opts.disableStatusBar) {
  1258. const statusBar = this.uppy.getPlugin(`${this.id}:StatusBar`)
  1259. if (statusBar) this.uppy.removePlugin(statusBar)
  1260. }
  1261. if (!this.opts.disableThumbnailGenerator) {
  1262. const thumbnail = this.uppy.getPlugin(`${this.id}:ThumbnailGenerator`)
  1263. if (thumbnail) this.uppy.removePlugin(thumbnail)
  1264. }
  1265. const plugins = this.opts.plugins || []
  1266. plugins.forEach((pluginID) => {
  1267. const plugin = this.uppy.getPlugin(pluginID)
  1268. if (plugin) (plugin as any).unmount()
  1269. })
  1270. if (this.opts.theme === 'auto') {
  1271. this.darkModeMediaQuery?.removeListener(this.handleSystemDarkModeChange)
  1272. }
  1273. if (this.opts.disablePageScrollWhenModalOpen) {
  1274. document.body.classList.remove('uppy-Dashboard-isFixed')
  1275. }
  1276. this.unmount()
  1277. this.removeEvents()
  1278. }
  1279. }