Dashboard.tsx 46 KB

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