/**
* SelectFileInputWidgets allow for selecting files, using <input type="file">. These
* widgets can be configured with {@link OO.ui.mixin.IconElement icons}, {@link
* OO.ui.mixin.IndicatorElement indicators} and {@link OO.ui.mixin.TitledElement titles}.
* Please see the [OOUI documentation on MediaWiki][1] for more information and examples.
*
* SelectFileInputWidgets must be used in HTML forms, as getValue only returns the filename.
*
* [1]: https://www.mediawiki.org/wiki/OOUI/Widgets
*
* @example
* // A file select input widget.
* const selectFile = new OO.ui.SelectFileInputWidget();
* $( document.body ).append( selectFile.$element );
*
* @class
* @extends OO.ui.InputWidget
* @mixes OO.ui.mixin.RequiredElement
* @mixes OO.ui.mixin.PendingElement
*
* @constructor
* @param {Object} [config] Configuration options
* @param {string[]|null} [config.accept=null] MIME types to accept. null accepts all types.
* @param {boolean} [config.multiple=false] Allow multiple files to be selected.
* @param {string} [config.placeholder] Text to display when no file is selected.
* @param {Object} [config.button] Config to pass to select file button.
* @param {Object|string|null} [config.icon=null] Icon to show next to file info
* @param {boolean} [config.droppable=true] Whether to accept files by drag and drop.
* @param {boolean} [config.buttonOnly=false] Show only the select file button, no info field.
* Requires showDropTarget to be false.
* @param {boolean} [config.showDropTarget=false] Whether to show a drop target. Requires droppable
* to be true.
* @param {number} [config.thumbnailSizeLimit=20] File size limit in MiB above which to not try and
* show a preview (for performance).
*/
OO.ui.SelectFileInputWidget = function OoUiSelectFileInputWidget( config ) {
config = config || {};
// Construct buttons before parent method is called (calling setDisabled)
this.selectButton = new OO.ui.ButtonWidget( Object.assign( {
$element: $( '<label>' ),
classes: [ 'oo-ui-selectFileInputWidget-selectButton' ],
label: OO.ui.msg(
config.multiple ?
'ooui-selectfile-button-select-multiple' :
'ooui-selectfile-button-select'
)
}, config.button ) );
// Configuration initialization
config = Object.assign( {
accept: null,
placeholder: OO.ui.msg( 'ooui-selectfile-placeholder' ),
$tabIndexed: this.selectButton.$tabIndexed,
droppable: true,
buttonOnly: false,
showDropTarget: false,
thumbnailSizeLimit: 20
}, config );
this.canSetFiles = true;
// Support: Safari < 14
try {
// eslint-disable-next-line no-new
new DataTransfer();
} catch ( e ) {
this.canSetFiles = false;
config.droppable = false;
}
this.info = new OO.ui.SearchInputWidget( {
classes: [ 'oo-ui-selectFileInputWidget-info' ],
placeholder: config.placeholder,
// Pass an empty collection so that .focus() always does nothing
$tabIndexed: $( [] )
} ).setIcon( config.icon );
// Set tabindex manually on $input as $tabIndexed has been overridden.
// Prevents field from becoming focused while tabbing.
// We will also set the disabled attribute on $input, but that is done in #setDisabled.
this.info.$input.attr( 'tabindex', -1 );
// This indicator serves as the only way to clear the file, so it must be keyboard-accessible
this.info.$indicator.attr( 'tabindex', 0 );
// Parent constructor
OO.ui.SelectFileInputWidget.super.call( this, config );
// Mixin constructors
OO.ui.mixin.RequiredElement.call( this, Object.assign( {}, {
// TODO: Display the required indicator somewhere
indicatorElement: null
}, config ) );
OO.ui.mixin.PendingElement.call( this );
// Properties
this.currentFiles = this.filterFiles( this.$input[ 0 ].files || [] );
if ( Array.isArray( config.accept ) ) {
this.accept = config.accept;
} else {
this.accept = null;
}
this.multiple = !!config.multiple;
this.showDropTarget = config.droppable && config.showDropTarget;
this.thumbnailSizeLimit = config.thumbnailSizeLimit;
// Initialization
this.fieldLayout = new OO.ui.ActionFieldLayout( this.info, this.selectButton, { align: 'top' } );
this.$input
.attr( {
type: 'file',
// this.selectButton is tabindexed
tabindex: -1,
// Infused input may have previously by
// TabIndexed, so remove aria-disabled attr.
'aria-disabled': null
} );
if ( this.accept ) {
this.$input.attr( 'accept', this.accept.join( ', ' ) );
}
if ( this.multiple ) {
this.$input.attr( 'multiple', '' );
}
this.selectButton.$button.append( this.$input );
this.$element
.addClass( 'oo-ui-selectFileInputWidget oo-ui-selectFileWidget' )
.append( this.fieldLayout.$element );
if ( this.showDropTarget ) {
this.selectButton.setIcon( 'upload' );
this.$element
.addClass( 'oo-ui-selectFileInputWidget-dropTarget oo-ui-selectFileWidget-dropTarget' )
.on( {
click: this.onDropTargetClick.bind( this )
} )
.append(
this.info.$element,
this.selectButton.$element,
$( '<span>' )
.addClass( 'oo-ui-selectFileInputWidget-dropLabel oo-ui-selectFileWidget-dropLabel' )
.text( OO.ui.msg(
this.multiple ?
'ooui-selectfile-dragdrop-placeholder-multiple' :
'ooui-selectfile-dragdrop-placeholder'
) )
);
if ( !this.multiple ) {
this.$thumbnail = $( '<div>' ).addClass( 'oo-ui-selectFileInputWidget-thumbnail oo-ui-selectFileWidget-thumbnail' );
this.setPendingElement( this.$thumbnail );
this.$element
.addClass( 'oo-ui-selectFileInputWidget-withThumbnail oo-ui-selectFileWidget-withThumbnail' )
.prepend( this.$thumbnail );
}
this.fieldLayout.$element.remove();
} else if ( config.buttonOnly ) {
// Copy over any classes that may have been added already.
// Ensure no events are bound to this.$element before here.
this.selectButton.$element
.addClass( this.$element.attr( 'class' ) )
.addClass( 'oo-ui-selectFileInputWidget-buttonOnly oo-ui-selectFileWidget-buttonOnly' );
// Set this.$element to just be the button
this.$element = this.selectButton.$element;
}
// Events
this.info.connect( this, { change: 'onInfoChange' } );
this.selectButton.$button.on( {
keypress: this.onKeyPress.bind( this )
} );
this.$input.on( {
change: this.onFileSelected.bind( this ),
click: function ( e ) {
// Prevents dropTarget getting clicked which calls
// a click on this input
e.stopPropagation();
}
} );
this.connect( this, { change: 'updateUI' } );
if ( config.droppable ) {
const dragHandler = this.onDragEnterOrOver.bind( this );
this.$element.on( {
dragenter: dragHandler,
dragover: dragHandler,
dragleave: this.onDragLeave.bind( this ),
drop: this.onDrop.bind( this )
} );
}
this.updateUI();
};
/* Setup */
OO.inheritClass( OO.ui.SelectFileInputWidget, OO.ui.InputWidget );
OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.RequiredElement );
OO.mixinClass( OO.ui.SelectFileInputWidget, OO.ui.mixin.PendingElement );
/* Events */
/**
* A change event is emitted when the currently selected files change
*
* @event OO.ui.SelectFileInputWidget#change
* @param {File[]} currentFiles Current file list
*/
/* Static Properties */
// Set empty title so that browser default tooltips like "No file chosen" don't appear.
OO.ui.SelectFileInputWidget.static.title = '';
/* Methods */
/**
* Get the current value of the field
*
* For single file widgets returns a File or null.
* For multiple file widgets returns a list of Files.
*
* @return {File|File[]|null}
*/
OO.ui.SelectFileInputWidget.prototype.getValue = function () {
return this.multiple ? this.currentFiles : this.currentFiles[ 0 ];
};
/**
* Set the current file list
*
* Can only be set to a non-null/non-empty value if this.canSetFiles is true,
* or if the widget has been set natively and we are just updating the internal
* state.
*
* @param {File[]|null} files Files to select
* @chainable
* @return {OO.ui.SelectFileInputWidget} The widget, for chaining
*/
OO.ui.SelectFileInputWidget.prototype.setValue = function ( files ) {
if ( files === undefined || typeof files === 'string' ) {
// Called during init, don't replace value if just infusing.
return this;
}
if ( files && !this.multiple ) {
files = files.slice( 0, 1 );
}
function comparableFile( file ) {
// Use extend to convert to plain objects so they can be compared.
// File objects contains name, size, timestamp and mime type which
// should be unique.
return Object.assign( {}, file );
}
if ( !OO.compare(
files && files.map( comparableFile ),
this.currentFiles && this.currentFiles.map( comparableFile )
) ) {
this.currentFiles = files || [];
this.emit( 'change', this.currentFiles );
}
if ( this.canSetFiles ) {
// Convert File[] array back to FileList for setting DOM value
const dataTransfer = new DataTransfer();
Array.prototype.forEach.call( this.currentFiles || [], ( file ) => {
dataTransfer.items.add( file );
} );
this.$input[ 0 ].files = dataTransfer.files;
} else {
if ( !files || !files.length ) {
// We're allowed to set the input value to empty string
// to clear.
OO.ui.SelectFileInputWidget.super.prototype.setValue.call( this, '' );
}
// Otherwise we assume the caller was just calling setValue with the
// current state of .files in the DOM.
}
return this;
};
/**
* Get the filename of the currently selected file.
*
* @return {string} Filename
*/
OO.ui.SelectFileInputWidget.prototype.getFilename = function () {
return this.currentFiles.map( ( file ) => file.name ).join( ', ' );
};
/**
* Handle file selection from the input.
*
* @protected
* @param {jQuery.Event} e
*/
OO.ui.SelectFileInputWidget.prototype.onFileSelected = function ( e ) {
const files = this.filterFiles( e.target.files || [] );
this.setValue( files );
};
/**
* Disable InputWidget#onEdit listener, onFileSelected is used instead.
*
* @inheritdoc
*/
OO.ui.SelectFileInputWidget.prototype.onEdit = function () {};
/**
* Update the user interface when a file is selected or unselected.
*
* @protected
*/
OO.ui.SelectFileInputWidget.prototype.updateUI = function () {
// Too early
if ( !this.selectButton ) {
return;
}
this.info.setValue( this.getFilename() );
if ( this.currentFiles.length ) {
this.$element.removeClass( 'oo-ui-selectFileInputWidget-empty' );
if ( this.showDropTarget ) {
if ( !this.multiple ) {
this.pushPending();
this.loadAndGetImageUrl( this.currentFiles[ 0 ] ).done( ( url ) => {
this.$thumbnail.css( 'background-image', 'url( ' + url + ' )' );
} ).fail( () => {
this.$thumbnail.append(
new OO.ui.IconWidget( {
icon: 'attachment',
classes: [ 'oo-ui-selectFileInputWidget-noThumbnail-icon oo-ui-selectFileWidget-noThumbnail-icon' ]
} ).$element
);
} ).always( () => {
this.popPending();
} );
}
this.$element.off( 'click' );
}
} else {
if ( this.showDropTarget ) {
this.$element.off( 'click' );
this.$element.on( {
click: this.onDropTargetClick.bind( this )
} );
if ( !this.multiple ) {
this.$thumbnail
.empty()
.css( 'background-image', '' );
}
}
this.$element.addClass( 'oo-ui-selectFileInputWidget-empty' );
}
};
/**
* If the selected file is an image, get its URL and load it.
*
* @param {File} file File
* @return {jQuery.Promise} Promise resolves with the image URL after it has loaded
*/
OO.ui.SelectFileInputWidget.prototype.loadAndGetImageUrl = function ( file ) {
const deferred = $.Deferred(),
reader = new FileReader();
if (
( OO.getProp( file, 'type' ) || '' ).indexOf( 'image/' ) === 0 &&
file.size < this.thumbnailSizeLimit * 1024 * 1024
) {
reader.onload = function ( event ) {
const img = document.createElement( 'img' );
img.addEventListener( 'load', () => {
if (
img.naturalWidth === 0 ||
img.naturalHeight === 0 ||
img.complete === false
) {
deferred.reject();
} else {
deferred.resolve( event.target.result );
}
} );
img.src = event.target.result;
};
reader.readAsDataURL( file );
} else {
deferred.reject();
}
return deferred.promise();
};
/**
* Determine if we should accept this file.
*
* @private
* @param {FileList|File[]} files Files to filter
* @return {File[]} Filter files
*/
OO.ui.SelectFileInputWidget.prototype.filterFiles = function ( files ) {
const accept = this.accept;
function mimeAllowed( file ) {
const mimeType = file.type;
if ( !accept || !mimeType ) {
return true;
}
for ( let i = 0; i < accept.length; i++ ) {
let mimeTest = accept[ i ];
if ( mimeTest === mimeType ) {
return true;
} else if ( mimeTest.slice( -2 ) === '/*' ) {
mimeTest = mimeTest.slice( 0, mimeTest.length - 1 );
if ( mimeType.slice( 0, mimeTest.length ) === mimeTest ) {
return true;
}
}
}
return false;
}
return Array.prototype.filter.call( files, mimeAllowed );
};
/**
* Handle info input change events
*
* The info widget can only be changed by the user
* with the clear button.
*
* @private
* @param {string} value
*/
OO.ui.SelectFileInputWidget.prototype.onInfoChange = function ( value ) {
if ( value === '' ) {
this.setValue( null );
}
};
/**
* Handle key press events.
*
* @private
* @param {jQuery.Event} e Key press event
* @return {undefined|boolean} False to prevent default if event is handled
*/
OO.ui.SelectFileInputWidget.prototype.onKeyPress = function ( e ) {
if ( !this.isDisabled() && this.$input &&
( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER )
) {
// Emit a click to open the file selector.
this.$input.trigger( 'click' );
// Taking focus from the selectButton means keyUp isn't fired, so fire it manually.
this.selectButton.onDocumentKeyUp( e );
return false;
}
};
/**
* @inheritdoc
*/
OO.ui.SelectFileInputWidget.prototype.setDisabled = function ( disabled ) {
// Parent method
OO.ui.SelectFileInputWidget.super.prototype.setDisabled.call( this, disabled );
this.selectButton.setDisabled( disabled );
this.info.setDisabled( disabled );
// Always make the input element disabled, so that it can't be found and focused,
// e.g. by OO.ui.findFocusable.
// The SearchInputWidget can otherwise be enabled normally.
this.info.$input.attr( 'disabled', true );
return this;
};
/**
* Handle drop target click events.
*
* @private
* @param {jQuery.Event} e Key press event
* @return {undefined|boolean} False to prevent default if event is handled
*/
OO.ui.SelectFileInputWidget.prototype.onDropTargetClick = function () {
if ( !this.isDisabled() && this.$input ) {
this.$input.trigger( 'click' );
return false;
}
};
/**
* Handle drag enter and over events
*
* @private
* @param {jQuery.Event} e Drag event
* @return {undefined|boolean} False to prevent default if event is handled
*/
OO.ui.SelectFileInputWidget.prototype.onDragEnterOrOver = function ( e ) {
let hasDroppableFile = false;
const dt = e.originalEvent.dataTransfer;
e.preventDefault();
e.stopPropagation();
if ( this.isDisabled() ) {
this.$element.removeClass( [
'oo-ui-selectFileInputWidget-canDrop',
'oo-ui-selectFileWidget-canDrop',
'oo-ui-selectFileInputWidget-cantDrop'
] );
dt.dropEffect = 'none';
return false;
}
// DataTransferItem and File both have a type property, but in Chrome files
// have no information at this point.
const itemsOrFiles = dt.items || dt.files;
const hasFiles = !!( itemsOrFiles && itemsOrFiles.length );
if ( hasFiles ) {
if ( this.filterFiles( itemsOrFiles ).length ) {
hasDroppableFile = true;
}
// dt.types is Array-like, but not an Array
} else if ( Array.prototype.indexOf.call( OO.getProp( dt, 'types' ) || [], 'Files' ) !== -1 ) {
// File information is not available at this point for security so just assume
// it is acceptable for now.
// https://bugzilla.mozilla.org/show_bug.cgi?id=640534
hasDroppableFile = true;
}
this.$element.toggleClass( 'oo-ui-selectFileInputWidget-canDrop oo-ui-selectFileWidget-canDrop', hasDroppableFile );
this.$element.toggleClass( 'oo-ui-selectFileInputWidget-cantDrop', !hasDroppableFile && hasFiles );
if ( !hasDroppableFile ) {
dt.dropEffect = 'none';
}
return false;
};
/**
* Handle drag leave events
*
* @private
* @param {jQuery.Event} e Drag event
*/
OO.ui.SelectFileInputWidget.prototype.onDragLeave = function () {
this.$element.removeClass( [
'oo-ui-selectFileInputWidget-canDrop',
'oo-ui-selectFileWidget-canDrop',
'oo-ui-selectFileInputWidget-cantDrop'
] );
};
/**
* Handle drop events
*
* @private
* @param {jQuery.Event} e Drop event
* @return {undefined|boolean} False to prevent default if event is handled
*/
OO.ui.SelectFileInputWidget.prototype.onDrop = function ( e ) {
const dt = e.originalEvent.dataTransfer;
e.preventDefault();
e.stopPropagation();
this.$element.removeClass( [
'oo-ui-selectFileInputWidget-canDrop',
'oo-ui-selectFileWidget-canDrop',
'oo-ui-selectFileInputWidget-cantDrop'
] );
if ( this.isDisabled() ) {
return false;
}
const files = this.filterFiles( dt.files || [] );
this.setValue( files );
return false;
};
// Deprecated alias
OO.ui.SelectFileWidget = function OoUiSelectFileWidget() {
OO.ui.warnDeprecation( 'SelectFileWidget: Deprecated alias, use SelectFileInputWidget instead.' );
OO.ui.SelectFileWidget.super.apply( this, arguments );
};
OO.inheritClass( OO.ui.SelectFileWidget, OO.ui.SelectFileInputWidget );