View.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import type {
  2. UnknownProviderPlugin,
  3. UnknownSearchProviderPlugin,
  4. } from '@uppy/core/lib/Uppy'
  5. import type { Body, Meta, TagFile } from '@uppy/utils/lib/UppyFile'
  6. import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
  7. import getFileType from '@uppy/utils/lib/getFileType'
  8. import isPreviewSupported from '@uppy/utils/lib/isPreviewSupported'
  9. import remoteFileObjToLocal from '@uppy/utils/lib/remoteFileObjToLocal'
  10. type PluginType = 'Provider' | 'SearchProvider'
  11. // Conditional type for selecting the plugin
  12. type SelectedPlugin<M extends Meta, B extends Body, T extends PluginType> =
  13. T extends 'Provider' ? UnknownProviderPlugin<M, B>
  14. : T extends 'SearchProvider' ? UnknownSearchProviderPlugin<M, B>
  15. : never
  16. // Conditional type for selecting the provider from the selected plugin
  17. type SelectedProvider<
  18. M extends Meta,
  19. B extends Body,
  20. T extends PluginType,
  21. > = SelectedPlugin<M, B, T>['provider']
  22. export interface ViewOptions<
  23. M extends Meta,
  24. B extends Body,
  25. T extends PluginType,
  26. > {
  27. provider: SelectedProvider<M, B, T>
  28. viewType?: string
  29. showTitles?: boolean
  30. showFilter?: boolean
  31. showBreadcrumbs?: boolean
  32. loadAllFiles?: boolean
  33. }
  34. export default class View<
  35. M extends Meta,
  36. B extends Body,
  37. T extends PluginType,
  38. O extends ViewOptions<M, B, T>,
  39. > {
  40. plugin: SelectedPlugin<M, B, T>
  41. provider: SelectedProvider<M, B, T>
  42. isHandlingScroll: boolean
  43. requestClientId: string
  44. isShiftKeyPressed: boolean
  45. lastCheckbox: CompanionFile | undefined
  46. protected opts: O
  47. constructor(plugin: SelectedPlugin<M, B, T>, opts: O) {
  48. this.plugin = plugin
  49. this.provider = opts.provider
  50. this.opts = opts
  51. this.isHandlingScroll = false
  52. this.handleError = this.handleError.bind(this)
  53. this.clearSelection = this.clearSelection.bind(this)
  54. this.cancelPicking = this.cancelPicking.bind(this)
  55. }
  56. shouldHandleScroll(event: Event): boolean {
  57. const { scrollHeight, scrollTop, offsetHeight } =
  58. event.target as HTMLElement
  59. const scrollPosition = scrollHeight - (scrollTop + offsetHeight)
  60. return scrollPosition < 50 && !this.isHandlingScroll
  61. }
  62. clearSelection(): void {
  63. this.plugin.setPluginState({ currentSelection: [], filterInput: '' })
  64. }
  65. cancelPicking(): void {
  66. this.clearSelection()
  67. const dashboard = this.plugin.uppy.getPlugin('Dashboard')
  68. if (dashboard) {
  69. // @ts-expect-error impossible to type this correctly without adding dashboard
  70. // as a dependency to this package.
  71. dashboard.hideAllPanels()
  72. }
  73. }
  74. handleError(error: Error): void {
  75. const { uppy } = this.plugin
  76. const message = uppy.i18n('companionError')
  77. uppy.log(error.toString())
  78. if (
  79. (error as any).isAuthError ||
  80. (error.cause as Error)?.name === 'AbortError'
  81. ) {
  82. // authError just means we're not authenticated, don't show to user
  83. // AbortError means the user has clicked "cancel" on an operation
  84. return
  85. }
  86. uppy.info({ message, details: error.toString() }, 'error', 5000)
  87. }
  88. registerRequestClient(): void {
  89. this.requestClientId = this.provider.provider
  90. this.plugin.uppy.registerRequestClient(this.requestClientId, this.provider)
  91. }
  92. // TODO: document what is a "tagFile" or get rid of this concept
  93. getTagFile(file: CompanionFile): TagFile<M> {
  94. const tagFile: TagFile<M> = {
  95. id: file.id,
  96. source: this.plugin.id,
  97. name: file.name || file.id,
  98. type: file.mimeType,
  99. isRemote: true,
  100. data: file,
  101. // @ts-expect-error meta is filled conditionally below
  102. meta: {},
  103. body: {
  104. fileId: file.id,
  105. },
  106. remote: {
  107. companionUrl: this.plugin.opts.companionUrl,
  108. // @ts-expect-error untyped for now
  109. url: `${this.provider.fileUrl(file.requestPath)}`,
  110. body: {
  111. fileId: file.id,
  112. },
  113. providerName: this.provider.name,
  114. provider: this.provider.provider,
  115. requestClientId: this.requestClientId,
  116. },
  117. }
  118. const fileType = getFileType(tagFile)
  119. // TODO Should we just always use the thumbnail URL if it exists?
  120. if (fileType && isPreviewSupported(fileType)) {
  121. tagFile.preview = file.thumbnail
  122. }
  123. if (file.author) {
  124. if (file.author.name != null)
  125. tagFile.meta!.authorName = String(file.author.name)
  126. if (file.author.url) tagFile.meta!.authorUrl = file.author.url
  127. }
  128. // add relativePath similar to non-remote files: https://github.com/transloadit/uppy/pull/4486#issuecomment-1579203717
  129. if (file.relDirPath != null)
  130. tagFile.meta!.relativePath =
  131. file.relDirPath ? `${file.relDirPath}/${tagFile.name}` : null
  132. // and absolutePath (with leading slash) https://github.com/transloadit/uppy/pull/4537#issuecomment-1614236655
  133. if (file.absDirPath != null)
  134. tagFile.meta!.absolutePath =
  135. file.absDirPath ?
  136. `/${file.absDirPath}/${tagFile.name}`
  137. : `/${tagFile.name}`
  138. return tagFile
  139. }
  140. filterItems = (items: CompanionFile[]): CompanionFile[] => {
  141. const state = this.plugin.getPluginState()
  142. if (!state.filterInput || state.filterInput === '') {
  143. return items
  144. }
  145. return items.filter((folder) => {
  146. return (
  147. folder.name.toLowerCase().indexOf(state.filterInput.toLowerCase()) !==
  148. -1
  149. )
  150. })
  151. }
  152. recordShiftKeyPress = (e: KeyboardEvent | MouseEvent): void => {
  153. this.isShiftKeyPressed = e.shiftKey
  154. }
  155. /**
  156. * Toggles file/folder checkbox to on/off state while updating files list.
  157. *
  158. * Note that some extra complexity comes from supporting shift+click to
  159. * toggle multiple checkboxes at once, which is done by getting all files
  160. * in between last checked file and current one.
  161. */
  162. toggleCheckbox(e: Event, file: CompanionFile): void {
  163. e.stopPropagation()
  164. e.preventDefault()
  165. ;(e.currentTarget as HTMLInputElement).focus()
  166. const { folders, files } = this.plugin.getPluginState()
  167. const items = this.filterItems(folders.concat(files))
  168. // Shift-clicking selects a single consecutive list of items
  169. // starting at the previous click.
  170. if (this.lastCheckbox && this.isShiftKeyPressed) {
  171. const { currentSelection } = this.plugin.getPluginState()
  172. const prevIndex = items.indexOf(this.lastCheckbox)
  173. const currentIndex = items.indexOf(file)
  174. const newSelection =
  175. prevIndex < currentIndex ?
  176. items.slice(prevIndex, currentIndex + 1)
  177. : items.slice(currentIndex, prevIndex + 1)
  178. const reducedNewSelection: CompanionFile[] = []
  179. // Check restrictions on each file in currentSelection,
  180. // reduce it to only contain files that pass restrictions
  181. for (const item of newSelection) {
  182. const { uppy } = this.plugin
  183. const restrictionError = uppy.validateRestrictions(
  184. remoteFileObjToLocal(item),
  185. [...uppy.getFiles(), ...reducedNewSelection],
  186. )
  187. if (!restrictionError) {
  188. reducedNewSelection.push(item)
  189. } else {
  190. uppy.info(
  191. { message: restrictionError.message },
  192. 'error',
  193. uppy.opts.infoTimeout,
  194. )
  195. }
  196. }
  197. this.plugin.setPluginState({
  198. currentSelection: [
  199. ...new Set([...currentSelection, ...reducedNewSelection]),
  200. ],
  201. })
  202. return
  203. }
  204. this.lastCheckbox = file
  205. const { currentSelection } = this.plugin.getPluginState()
  206. if (this.isChecked(file)) {
  207. this.plugin.setPluginState({
  208. currentSelection: currentSelection.filter(
  209. (item) => item.id !== file.id,
  210. ),
  211. })
  212. } else {
  213. this.plugin.setPluginState({
  214. currentSelection: currentSelection.concat([file]),
  215. })
  216. }
  217. }
  218. isChecked = (file: CompanionFile): boolean => {
  219. const { currentSelection } = this.plugin.getPluginState()
  220. // comparing id instead of the file object, because the reference to the object
  221. // changes when we switch folders, and the file list is updated
  222. return currentSelection.some((item) => item.id === file.id)
  223. }
  224. setLoading(loading: boolean | string): void {
  225. this.plugin.setPluginState({ loading })
  226. }
  227. }