DragDrop.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { UIPlugin, type Uppy } from '@uppy/core'
  2. import type { DefinePluginOpts } from '@uppy/core/lib/BasePlugin'
  3. import type { UIPluginOptions } from '@uppy/core/lib/UIPlugin'
  4. import type { Body, Meta } from '@uppy/utils/lib/UppyFile'
  5. import type { ChangeEvent } from 'preact/compat'
  6. import toArray from '@uppy/utils/lib/toArray'
  7. import isDragDropSupported from '@uppy/utils/lib/isDragDropSupported'
  8. import getDroppedFiles from '@uppy/utils/lib/getDroppedFiles'
  9. import { h, type ComponentChild } from 'preact'
  10. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  11. // @ts-ignore We don't want TS to generate types for the package.json
  12. import packageJson from '../package.json'
  13. import locale from './locale.ts'
  14. export interface DragDropOptions extends UIPluginOptions {
  15. inputName?: string
  16. allowMultipleFiles?: boolean
  17. width?: string | number
  18. height?: string | number
  19. note?: string
  20. onDragOver?: (event: DragEvent) => void
  21. onDragLeave?: (event: DragEvent) => void
  22. onDrop?: (event: DragEvent) => void
  23. }
  24. // Default options, must be kept in sync with @uppy/react/src/DragDrop.js.
  25. const defaultOptions = {
  26. inputName: 'files[]',
  27. width: '100%',
  28. height: '100%',
  29. } satisfies Partial<DragDropOptions>
  30. /**
  31. * Drag & Drop plugin
  32. *
  33. */
  34. export default class DragDrop<M extends Meta, B extends Body> extends UIPlugin<
  35. DefinePluginOpts<DragDropOptions, keyof typeof defaultOptions>,
  36. M,
  37. B
  38. > {
  39. static VERSION = packageJson.version
  40. // Check for browser dragDrop support
  41. private isDragDropSupported = isDragDropSupported()
  42. private removeDragOverClassTimeout!: ReturnType<typeof setTimeout>
  43. private fileInputRef!: HTMLInputElement
  44. constructor(uppy: Uppy<M, B>, opts?: DragDropOptions) {
  45. super(uppy, {
  46. ...defaultOptions,
  47. ...opts,
  48. })
  49. this.type = 'acquirer'
  50. this.id = this.opts.id || 'DragDrop'
  51. this.title = 'Drag & Drop'
  52. this.defaultLocale = locale
  53. this.i18nInit()
  54. }
  55. private addFiles = (files: File[]) => {
  56. const descriptors = files.map((file) => ({
  57. source: this.id,
  58. name: file.name,
  59. type: file.type,
  60. data: file,
  61. meta: {
  62. // path of the file relative to the ancestor directory the user selected.
  63. // e.g. 'docs/Old Prague/airbnb.pdf'
  64. relativePath: (file as any).relativePath || null,
  65. } as any as M,
  66. }))
  67. try {
  68. this.uppy.addFiles(descriptors)
  69. } catch (err) {
  70. this.uppy.log(err as any)
  71. }
  72. }
  73. private onInputChange = (event: ChangeEvent) => {
  74. const files = toArray((event.target as HTMLInputElement).files!)
  75. if (files.length > 0) {
  76. this.uppy.log('[DragDrop] Files selected through input')
  77. this.addFiles(files)
  78. }
  79. // We clear the input after a file is selected, because otherwise
  80. // change event is not fired in Chrome and Safari when a file
  81. // with the same name is selected.
  82. // ___Why not use value="" on <input/> instead?
  83. // Because if we use that method of clearing the input,
  84. // Chrome will not trigger change if we drop the same file twice (Issue #768).
  85. // @ts-expect-error TS freaks out, but this is fine
  86. // eslint-disable-next-line no-param-reassign
  87. event.target.value = null
  88. }
  89. private handleDragOver = (event: DragEvent) => {
  90. event.preventDefault()
  91. event.stopPropagation()
  92. // Check if the "type" of the datatransfer object includes files. If not, deny drop.
  93. const { types } = event.dataTransfer!
  94. const hasFiles = types.some((type) => type === 'Files')
  95. const { allowNewUpload } = this.uppy.getState()
  96. if (!hasFiles || !allowNewUpload) {
  97. // eslint-disable-next-line no-param-reassign
  98. event.dataTransfer!.dropEffect = 'none'
  99. clearTimeout(this.removeDragOverClassTimeout)
  100. return
  101. }
  102. // Add a small (+) icon on drop
  103. // (and prevent browsers from interpreting this as files being _moved_ into the browser
  104. // https://github.com/transloadit/uppy/issues/1978)
  105. //
  106. // eslint-disable-next-line no-param-reassign
  107. event.dataTransfer!.dropEffect = 'copy'
  108. clearTimeout(this.removeDragOverClassTimeout)
  109. this.setPluginState({ isDraggingOver: true })
  110. this.opts.onDragOver?.(event)
  111. }
  112. private handleDragLeave = (event: DragEvent) => {
  113. event.preventDefault()
  114. event.stopPropagation()
  115. clearTimeout(this.removeDragOverClassTimeout)
  116. // Timeout against flickering, this solution is taken from drag-drop library.
  117. // Solution with 'pointer-events: none' didn't work across browsers.
  118. this.removeDragOverClassTimeout = setTimeout(() => {
  119. this.setPluginState({ isDraggingOver: false })
  120. }, 50)
  121. this.opts.onDragLeave?.(event)
  122. }
  123. private handleDrop = async (event: DragEvent) => {
  124. event.preventDefault()
  125. event.stopPropagation()
  126. clearTimeout(this.removeDragOverClassTimeout)
  127. // Remove dragover class
  128. this.setPluginState({ isDraggingOver: false })
  129. const logDropError = (error: any) => {
  130. this.uppy.log(error, 'error')
  131. }
  132. // Add all dropped files
  133. const files = await getDroppedFiles(event.dataTransfer!, { logDropError })
  134. if (files.length > 0) {
  135. this.uppy.log('[DragDrop] Files dropped')
  136. this.addFiles(files)
  137. }
  138. this.opts.onDrop?.(event)
  139. }
  140. private renderHiddenFileInput() {
  141. const { restrictions } = this.uppy.opts
  142. return (
  143. <input
  144. className="uppy-DragDrop-input"
  145. type="file"
  146. hidden
  147. ref={(ref) => {
  148. this.fileInputRef = ref!
  149. }}
  150. name={this.opts.inputName}
  151. multiple={restrictions.maxNumberOfFiles !== 1}
  152. // @ts-expect-error We actually want to coerce the array to a string (or keep it as null/undefined)
  153. accept={restrictions.allowedFileTypes}
  154. onChange={this.onInputChange}
  155. />
  156. )
  157. }
  158. private static renderArrowSvg() {
  159. return (
  160. <svg
  161. aria-hidden="true"
  162. focusable="false"
  163. className="uppy-c-icon uppy-DragDrop-arrow"
  164. width="16"
  165. height="16"
  166. viewBox="0 0 16 16"
  167. >
  168. <path d="M11 10V0H5v10H2l6 6 6-6h-3zm0 0" fillRule="evenodd" />
  169. </svg>
  170. )
  171. }
  172. private renderLabel() {
  173. return (
  174. <div className="uppy-DragDrop-label">
  175. {this.i18nArray('dropHereOr', {
  176. browse: (
  177. <span className="uppy-DragDrop-browse">{this.i18n('browse')}</span>
  178. ) as any,
  179. })}
  180. </div>
  181. )
  182. }
  183. private renderNote() {
  184. return <span className="uppy-DragDrop-note">{this.opts.note}</span>
  185. }
  186. render(): ComponentChild {
  187. const dragDropClass = `uppy-u-reset
  188. uppy-DragDrop-container
  189. ${this.isDragDropSupported ? 'uppy-DragDrop--isDragDropSupported' : ''}
  190. ${this.getPluginState().isDraggingOver ? 'uppy-DragDrop--isDraggingOver' : ''}
  191. `
  192. const dragDropStyle = {
  193. width: this.opts.width,
  194. height: this.opts.height,
  195. }
  196. return (
  197. <button
  198. type="button"
  199. className={dragDropClass}
  200. style={dragDropStyle}
  201. onClick={() => this.fileInputRef.click()}
  202. onDragOver={this.handleDragOver}
  203. onDragLeave={this.handleDragLeave}
  204. onDrop={this.handleDrop}
  205. >
  206. {this.renderHiddenFileInput()}
  207. <div className="uppy-DragDrop-inner">
  208. {DragDrop.renderArrowSvg()}
  209. {this.renderLabel()}
  210. {this.renderNote()}
  211. </div>
  212. </button>
  213. )
  214. }
  215. install(): void {
  216. const { target } = this.opts
  217. this.setPluginState({
  218. isDraggingOver: false,
  219. })
  220. if (target) {
  221. this.mount(target, this)
  222. }
  223. }
  224. uninstall(): void {
  225. this.unmount()
  226. }
  227. }