1. /**
  2. * SelectFileInputWidgets allow for selecting files, using <input type="file">. These
  3. * widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
  4. * OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
  5. * Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
  6. *
  7. * SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
  8. *
  9. * [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
  10. *
  11. * @example
  12. * // A file select input widget.
  13. * const selectFile = new OO.ui.SelectFileInputWidget();
  14. * $( document.body ).append( selectFile.$element );
  15. *
  16. * @class
  17. * @extends OO.ui.InputWidget
  18. * @mixes OO.ui.mixin.RequiredElement
  19. * @mixes OO.ui.mixin.PendingElement
  20. *
  21. * @constructor
  22. * @param {Object} [config] Configuration options
  23. * @param {string[]|null} [config.accept=null] MIME types to accept. null accepts all types.
  24. * @param {boolean} [config.multiple=false] Allow multiple files to be selected.
  25. * @param {string} [config.placeholder] Text to display when no file is selected.
  26. * @param {Object} [config.button] Config to pass to select file button.
  27. * @param {Object|string|null} [config.icon=null] Icon to show next to file info
  28. * @param {boolean} [config.droppable=true] Whether to accept files by drag and drop.
  29. * @param {boolean} [config.buttonOnly=false] Show only the select file button, no info field.
  30. * Requires showDropTarget to be false.
  31. * @param {boolean} [config.showDropTarget=false] Whether to show a drop target. Requires droppable
  32. * to be true.
  33. * @param {number} [config.thumbnailSizeLimit=20] File size limit in MiB above which to not try and
  34. * show a preview (for performance).
  35. */
  36. OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
  37. config = config || {};
  38. // Construct buttons before parent method is called (calling setDisabled)
  39. this.selectButton = new OO.ui.ButtonWidget( Object.assign( {
  40. $element: $( '<label>' ),
  41. classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
  42. label: OO.ui.msg(
  43. config.multiple ?
  44. 'ooui-selectfile-button-select-multiple' :
  45. 'ooui-selectfile-button-select'
  46. )
  47. }, config.button ) );
  48. // Configuration initialization
  49. config = Object.assign( {
  50. accept: null,
  51. placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
  52. $tabIndexed: this.selectButton.$tabIndexed,
  53. droppable: true,
  54. buttonOnly: false,
  55. showDropTarget: false,
  56. thumbnailSizeLimit: 20
  57. }, config );
  58. this.canSetFiles = true;
  59. // Support: Safari < 14
  60. try {
  61. // eslint-disable-next-line no-new
  62. new DataTransfer();
  63. } catch ( e ) {
  64. this.canSetFiles = false;
  65. config.droppable = false;
  66. }
  67. this.info = new OO.ui.SearchInputWidget( {
  68. classes: [ 'oo-ui-selectFileInputWidget-info' ],
  69. placeholder: config.placeholder,
  70. // Pass an empty collection so that .focus() always does nothing
  71. $tabIndexed: $( [] )
  72. } ).setIcon( config.icon );
  73. // Set tabindex manually on $input as $tabIndexed has been overridden.
  74. // Prevents field from becoming focused while tabbing.
  75. // We will also set the disabled attribute on $input, but that is done in #setDisabled.
  76. this.info.$input.attr( 'tabindex', -1 );
  77. // This indicator serves as the only way to clear the file, so it must be keyboard-accessible
  78. this.info.$indicator.attr( 'tabindex', 0 );
  79. // Parent constructor
  80. OO.ui.SelectFileInputWidget.super.call( this, config );
  81. // Mixin constructors
  82. OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
  83. // TODO: Display the required indicator somewhere
  84. indicatorElement: null
  85. }, config ) );
  86. OO.ui.mixin.PendingElement.call( this );
  87. // Properties
  88. this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
  89. if ( Array.isArray( config.accept ) ) {
  90. this.accept = config.accept;
  91. } else {
  92. this.accept = null;
  93. }
  94. this.multiple = !!config.multiple;
  95. this.showDropTarget = config.droppable && config.showDropTarget;
  96. this.thumbnailSizeLimit = config.thumbnailSizeLimit;
  97. // Initialization
  98. this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
  99. this.$input
  100. .attr( {
  101. type: 'file',
  102. // this.selectButton is tabindexed
  103. tabindex: -1,
  104. // Infused input may have previously by
  105. // TabIndexed, so remove aria-disabled attr.
  106. 'aria-disabled': null
  107. } );
  108. if ( this.accept ) {
  109. this.$input.attr( 'accept', this.accept.join( ', ' ) );
  110. }
  111. if ( this.multiple ) {
  112. this.$input.attr( 'multiple', '' );
  113. }
  114. this.selectButton.$button.append( this.$input );
  115. this.$element
  116. .addClass( 'oo-ui-selectFileInputWidget oo-ui-selectFileWidget' )
  117. .append( this.fieldLayout.$element );
  118. if ( this.showDropTarget ) {
  119. this.selectButton.setIcon( 'upload' );
  120. this.$element
  121. .addClass( 'oo-ui-selectFileInputWidget-dropTarget oo-ui-selectFileWidget-dropTarget' )
  122. .on( {
  123. click: this.onDropTargetClick.bind( this )
  124. } )
  125. .append(
  126. this.info.$element,
  127. this.selectButton.$element,
  128. $( '<span>' )
  129. .addClass( 'oo-ui-selectFileInputWidget-dropLabel oo-ui-selectFileWidget-dropLabel' )
  130. .text( OO.ui.msg(
  131. this.multiple ?
  132. 'ooui-selectfile-dragdrop-placeholder-multiple' :
  133. 'ooui-selectfile-dragdrop-placeholder'
  134. ) )
  135. );
  136. if ( !this.multiple ) {
  137. this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileInputWidget-thumbnail oo-ui-selectFileWidget-thumbnail' );
  138. this.setPendingElement( this.$thumbnail );
  139. this.$element
  140. .addClass( 'oo-ui-selectFileInputWidget-withThumbnail oo-ui-selectFileWidget-withThumbnail' )
  141. .prepend( this.$thumbnail );
  142. }
  143. this.fieldLayout.$element.remove();
  144. } else if ( config.buttonOnly ) {
  145. // Copy over any classes that may have been added already.
  146. // Ensure no events are bound to this.$element before here.
  147. this.selectButton.$element
  148. .addClass( this.$element.attr( 'class' ) )
  149. .addClass( 'oo-ui-selectFileInputWidget-buttonOnly oo-ui-selectFileWidget-buttonOnly' );
  150. // Set this.$element to just be the button
  151. this.$element = this.selectButton.$element;
  152. }
  153. // Events
  154. this.info.connect( this, { change: 'onInfoChange' } );
  155. this.selectButton.$button.on( {
  156. keypress: this.onKeyPress.bind( this )
  157. } );
  158. this.$input.on( {
  159. change: this.onFileSelected.bind( this ),
  160. click: function ( e ) {
  161. // Prevents dropTarget getting clicked which calls
  162. // a click on this input
  163. e.stopPropagation();
  164. }
  165. } );
  166. this.connect( this, { change: 'updateUI' } );
  167. if ( config.droppable ) {
  168. const dragHandler = this.onDragEnterOrOver.bind( this );
  169. this.$element.on( {
  170. dragenter: dragHandler,
  171. dragover: dragHandler,
  172. dragleave: this.onDragLeave.bind( this ),
  173. drop: this.onDrop.bind( this )
  174. } );
  175. }
  176. this.updateUI();
  177. };
  178. /* Setup */
  179. OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
  180. OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.RequiredElement );
  181. OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.PendingElement );
  182. /* Events */
  183. /**
  184. * A change event is emitted when the currently selected files change
  185. *
  186. * @event OO.ui.SelectFileInputWidget#change
  187. * @param {File[]} currentFiles Current file list
  188. */
  189. /* Static Properties */
  190. // Set empty title so that browser default tooltips like "No file chosen" don't appear.
  191. OO.ui.SelectFileInputWidget.static.title = '';
  192. /* Methods */
  193. /**
  194. * Get the current value of the field
  195. *
  196. * For single file widgets returns a File or null.
  197. * For multiple file widgets returns a list of Files.
  198. *
  199. * @return {File|File[]|null}
  200. */
  201. OO.ui.SelectFileInputWidget.prototype.getValue = function () {
  202. return this.multiple ? this.currentFiles : this.currentFiles[ 0 ];
  203. };
  204. /**
  205. * Set the current file list
  206. *
  207. * Can only be set to a non-null/non-empty value if this.canSetFiles is true,
  208. * or if the widget has been set natively and we are just updating the internal
  209. * state.
  210. *
  211. * @param {File[]|null} files Files to select
  212. * @chainable
  213. * @return {OO.ui.SelectFileInputWidget} The widget, for chaining
  214. */
  215. OO.ui.SelectFileInputWidget.prototype.setValue = function ( files ) {
  216. if ( files === undefined || typeof files === 'string' ) {
  217. // Called during init, don't replace value if just infusing.
  218. return this;
  219. }
  220. if ( files && !this.multiple ) {
  221. files = files.slice( 0, 1 );
  222. }
  223. function comparableFile( file ) {
  224. // Use extend to convert to plain objects so they can be compared.
  225. // File objects contains name, size, timestamp and mime type which
  226. // should be unique.
  227. return Object.assign( {}, file );
  228. }
  229. if ( !OO.compare(
  230. files && files.map( comparableFile ),
  231. this.currentFiles && this.currentFiles.map( comparableFile )
  232. ) ) {
  233. this.currentFiles = files || [];
  234. this.emit( 'change', this.currentFiles );
  235. }
  236. if ( this.canSetFiles ) {
  237. // Convert File[] array back to FileList for setting DOM value
  238. const dataTransfer = new DataTransfer();
  239. Array.prototype.forEach.call( this.currentFiles || [], ( file ) => {
  240. dataTransfer.items.add( file );
  241. } );
  242. this.$input[ 0 ].files = dataTransfer.files;
  243. } else {
  244. if ( !files || !files.length ) {
  245. // We're allowed to set the input value to empty string
  246. // to clear.
  247. OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
  248. }
  249. // Otherwise we assume the caller was just calling setValue with the
  250. // current state of .files in the DOM.
  251. }
  252. return this;
  253. };
  254. /**
  255. * Get the filename of the currently selected file.
  256. *
  257. * @return {string} Filename
  258. */
  259. OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
  260. return this.currentFiles.map( ( file ) => file.name ).join( ', ' );
  261. };
  262. /**
  263. * Handle file selection from the input.
  264. *
  265. * @protected
  266. * @param {jQuery.Event} e
  267. */
  268. OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
  269. const files = this.filterFiles( e.target.files || [] );
  270. this.setValue( files );
  271. };
  272. /**
  273. * Disable InputWidget#onEdit listener, onFileSelected is used instead.
  274. *
  275. * @inheritdoc
  276. */
  277. OO.ui.SelectFileInputWidget.prototype.onEdit = function () {};
  278. /**
  279. * Update the user interface when a file is selected or unselected.
  280. *
  281. * @protected
  282. */
  283. OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
  284. // Too early
  285. if ( !this.selectButton ) {
  286. return;
  287. }
  288. this.info.setValue( this.getFilename() );
  289. if ( this.currentFiles.length ) {
  290. this.$element.removeClass( 'oo-ui-selectFileInputWidget-empty' );
  291. if ( this.showDropTarget ) {
  292. if ( !this.multiple ) {
  293. this.pushPending();
  294. this.loadAndGetImageUrl( this.currentFiles[ 0 ] ).done( ( url ) => {
  295. this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
  296. } ).fail( () => {
  297. this.$thumbnail.append(
  298. new OO.ui.IconWidget( {
  299. icon: 'attachment',
  300. classes: [ 'oo-ui-selectFileInputWidget-noThumbnail-icon oo-ui-selectFileWidget-noThumbnail-icon' ]
  301. } ).$element
  302. );
  303. } ).always( () => {
  304. this.popPending();
  305. } );
  306. }
  307. this.$element.off( 'click' );
  308. }
  309. } else {
  310. if ( this.showDropTarget ) {
  311. this.$element.off( 'click' );
  312. this.$element.on( {
  313. click: this.onDropTargetClick.bind( this )
  314. } );
  315. if ( !this.multiple ) {
  316. this.$thumbnail
  317. .empty()
  318. .css( 'background-image', '' );
  319. }
  320. }
  321. this.$element.addClass( 'oo-ui-selectFileInputWidget-empty' );
  322. }
  323. };
  324. /**
  325. * If the selected file is an image, get its URL and load it.
  326. *
  327. * @param {File} file File
  328. * @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
  329. */
  330. OO.ui.SelectFileInputWidget.prototype.loadAndGetImageUrl = function ( file ) {
  331. const deferred = $.Deferred(),
  332. reader = new FileReader();
  333. if (
  334. ( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
  335. file.size < this.thumbnailSizeLimit * 1024 * 1024
  336. ) {
  337. reader.onload = function ( event ) {
  338. const img = document.createElement( 'img' );
  339. img.addEventListener( 'load', () => {
  340. if (
  341. img.naturalWidth === 0 ||
  342. img.naturalHeight === 0 ||
  343. img.complete === false
  344. ) {
  345. deferred.reject();
  346. } else {
  347. deferred.resolve( event.target.result );
  348. }
  349. } );
  350. img.src = event.target.result;
  351. };
  352. reader.readAsDataURL( file );
  353. } else {
  354. deferred.reject();
  355. }
  356. return deferred.promise();
  357. };
  358. /**
  359. * Determine if we should accept this file.
  360. *
  361. * @private
  362. * @param {FileList|File[]} files Files to filter
  363. * @return {File[]} Filter files
  364. */
  365. OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
  366. const accept = this.accept;
  367. function mimeAllowed( file ) {
  368. const mimeType = file.type;
  369. if ( !accept || !mimeType ) {
  370. return true;
  371. }
  372. return accept.some( ( acceptedType ) => {
  373. if ( acceptedType === mimeType ) {
  374. return true;
  375. } else if ( acceptedType.slice( -2 ) === '/*' ) {
  376. // e.g. 'image/*'
  377. if ( mimeType.startsWith( acceptedType.slice( 0, -1 ) ) ) {
  378. return true;
  379. }
  380. }
  381. return false;
  382. } );
  383. }
  384. return Array.prototype.filter.call( files, mimeAllowed );
  385. };
  386. /**
  387. * Handle info input change events
  388. *
  389. * The info widget can only be changed by the user
  390. * with the clear button.
  391. *
  392. * @private
  393. * @param {string} value
  394. */
  395. OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
  396. if ( value === '' ) {
  397. this.setValue( null );
  398. }
  399. };
  400. /**
  401. * Handle key press events.
  402. *
  403. * @private
  404. * @param {jQuery.Event} e Key press event
  405. * @return {undefined|boolean} False to prevent default if event is handled
  406. */
  407. OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
  408. if ( !this.isDisabled() && this.$input &&
  409. ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
  410. ) {
  411. // Emit a click to open the file selector.
  412. this.$input.trigger( 'click' );
  413. // Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
  414. this.selectButton.onDocumentKeyUp( e );
  415. return false;
  416. }
  417. };
  418. /**
  419. * @inheritdoc
  420. */
  421. OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
  422. // Parent method
  423. OO.ui.SelectFileInputWidget.super.prototype.setDisabled.call( this, disabled );
  424. this.selectButton.setDisabled( disabled );
  425. this.info.setDisabled( disabled );
  426. // Always make the input element disabled, so that it can't be found and focused,
  427. // e.g. by OO.ui.findFocusable.
  428. // The SearchInputWidget can otherwise be enabled normally.
  429. this.info.$input.attr( 'disabled', true );
  430. return this;
  431. };
  432. /**
  433. * Handle drop target click events.
  434. *
  435. * @private
  436. * @param {jQuery.Event} e Key press event
  437. * @return {undefined|boolean} False to prevent default if event is handled
  438. */
  439. OO.ui.SelectFileInputWidget.prototype.onDropTargetClick = function () {
  440. if ( !this.isDisabled() && this.$input ) {
  441. this.$input.trigger( 'click' );
  442. return false;
  443. }
  444. };
  445. /**
  446. * Handle drag enter and over events
  447. *
  448. * @private
  449. * @param {jQuery.Event} e Drag event
  450. * @return {undefined|boolean} False to prevent default if event is handled
  451. */
  452. OO.ui.SelectFileInputWidget.prototype.onDragEnterOrOver = function ( e ) {
  453. let hasDroppableFile = false;
  454. const dt = e.originalEvent.dataTransfer;
  455. e.preventDefault();
  456. e.stopPropagation();
  457. if ( this.isDisabled() ) {
  458. this.$element.removeClass( [
  459. 'oo-ui-selectFileInputWidget-canDrop',
  460. 'oo-ui-selectFileWidget-canDrop',
  461. 'oo-ui-selectFileInputWidget-cantDrop'
  462. ] );
  463. dt.dropEffect = 'none';
  464. return false;
  465. }
  466. // DataTransferItem and File both have a type property, but in Chrome files
  467. // have no information at this point.
  468. const itemsOrFiles = dt.items || dt.files;
  469. const hasFiles = !!( itemsOrFiles && itemsOrFiles.length );
  470. if ( hasFiles ) {
  471. if ( this.filterFiles( itemsOrFiles ).length ) {
  472. hasDroppableFile = true;
  473. }
  474. // dt.types is Array-like, but not an Array
  475. } else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
  476. // File information is not available at this point for security so just assume
  477. // it is acceptable for now.
  478. // https://bugzilla.mozilla.org/show_bug.cgi?id=640534
  479. hasDroppableFile = true;
  480. }
  481. this.$element.toggleClass( 'oo-ui-selectFileInputWidget-canDrop oo-ui-selectFileWidget-canDrop', hasDroppableFile );
  482. this.$element.toggleClass( 'oo-ui-selectFileInputWidget-cantDrop', !hasDroppableFile && hasFiles );
  483. if ( !hasDroppableFile ) {
  484. dt.dropEffect = 'none';
  485. }
  486. return false;
  487. };
  488. /**
  489. * Handle drag leave events
  490. *
  491. * @private
  492. * @param {jQuery.Event} e Drag event
  493. */
  494. OO.ui.SelectFileInputWidget.prototype.onDragLeave = function () {
  495. this.$element.removeClass( [
  496. 'oo-ui-selectFileInputWidget-canDrop',
  497. 'oo-ui-selectFileWidget-canDrop',
  498. 'oo-ui-selectFileInputWidget-cantDrop'
  499. ] );
  500. };
  501. /**
  502. * Handle drop events
  503. *
  504. * @private
  505. * @param {jQuery.Event} e Drop event
  506. * @return {undefined|boolean} False to prevent default if event is handled
  507. */
  508. OO.ui.SelectFileInputWidget.prototype.onDrop = function ( e ) {
  509. const dt = e.originalEvent.dataTransfer;
  510. e.preventDefault();
  511. e.stopPropagation();
  512. this.$element.removeClass( [
  513. 'oo-ui-selectFileInputWidget-canDrop',
  514. 'oo-ui-selectFileWidget-canDrop',
  515. 'oo-ui-selectFileInputWidget-cantDrop'
  516. ] );
  517. if ( this.isDisabled() ) {
  518. return false;
  519. }
  520. const files = this.filterFiles( dt.files || [] );
  521. this.setValue( files );
  522. return false;
  523. };
  524. // Deprecated alias
  525. OO.ui.SelectFileWidget = function OoUiSelectFileWidget() {
  526. OO.ui.warnDeprecation( 'SelectFileWidget: Deprecated alias, use SelectFileInputWidget instead.' );
  527. OO.ui.SelectFileWidget.super.apply( this, arguments );
  528. };
  529. OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.SelectFileInputWidget );