Bring in MediaSearchWidget from VE
authorEd Sanders <esanders@wikimedia.org>
Thu, 17 Nov 2016 15:32:06 +0000 (15:32 +0000)
committerEd Sanders <esanders@wikimedia.org>
Tue, 29 Nov 2016 17:10:02 +0000 (17:10 +0000)
Bug: T140166
Change-Id: If53ef7f4b62c7a5d4da565c14dd2a353778694e5

14 files changed:
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.widgets/MediaSearch/broken-image.png [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsProvider.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsQueue.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceProvider.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceQueue.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.css [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchProvider.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchQueue.js [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.css [new file with mode: 0644]
resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.js [new file with mode: 0644]

index 9aa0f46..3a8a7ae 100644 (file)
        "mw-widgets-dateinput-no-date": "No date selected",
        "mw-widgets-dateinput-placeholder-day": "YYYY-MM-DD",
        "mw-widgets-dateinput-placeholder-month": "YYYY-MM",
+       "mw-widgets-mediasearch-input-placeholder": "Search for media",
+       "mw-widgets-mediasearch-noresults": "No results found.",
        "mw-widgets-titleinput-description-new-page": "page does not exist yet",
        "mw-widgets-titleinput-description-redirect": "redirect to $1",
        "mw-widgets-categoryselector-add-category-placeholder": "Add a category...",
index 7327012..d175c86 100644 (file)
        "mw-widgets-dateinput-no-date": "Label of a date input field when no date has been selected.",
        "mw-widgets-dateinput-placeholder-day": "[[File:DateInputWidget active, empty.png|frame|Screenshot]]\nPlaceholder displayed in a date input field when it's empty, representing a date format with 4 digits for year, 2 digits for month, and 2 digits for day, separated with hyphens. This should be uppercase, if possible, and must not include any additional explanations. If there is no good way to translate it, make this message blank.",
        "mw-widgets-dateinput-placeholder-month": "Placeholder displayed in a date input field when it's empty, representing a date format with 4 digits for year and 2 digits for month, separated with hyphens (without a day). This should be uppercase, if possible, and must not include any additional explanations. If there is no good way to translate it, make this message blank.",
+       "mw-widgets-mediasearch-input-placeholder": "Place holder text for media search input",
+       "mw-widgets-mediasearch-noresults": "Label notifying the user no results were found for the media search.",
        "mw-widgets-titleinput-description-new-page": "Description label for a new page in the title input widget.",
        "mw-widgets-titleinput-description-redirect": "Description label for a redirect in the title input widget.",
        "mw-widgets-categoryselector-add-category-placeholder": "Placeholder displayed in the category selector widget after the capsules of already added categories.",
index 587a84d..b37febd 100644 (file)
@@ -2289,6 +2289,32 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.widgets.MediaSearch' => [
+               'scripts' => [
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsProvider.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsQueue.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceProvider.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchProvider.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceQueue.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchQueue.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.js',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.js',
+               ],
+               'styles' => [
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.css',
+                       'resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.css',
+               ],
+               'dependencies' => [
+                       'oojs-ui-widgets',
+                       'mediawiki.ForeignApi',
+                       'mediawiki.Title',
+               ],
+               'messages' => [
+                       'mw-widgets-mediasearch-noresults',
+                       'mw-widgets-mediasearch-input-placeholder',
+               ],
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
        'mediawiki.widgets.UserInputWidget' => [
                'scripts' => [
                        'resources/src/mediawiki.widgets/mw.widgets.UserInputWidget.js',
diff --git a/resources/src/mediawiki.widgets/MediaSearch/broken-image.png b/resources/src/mediawiki.widgets/MediaSearch/broken-image.png
new file mode 100644 (file)
index 0000000..f5be958
Binary files /dev/null and b/resources/src/mediawiki.widgets/MediaSearch/broken-image.png differ
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsProvider.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsProvider.js
new file mode 100644 (file)
index 0000000..dd07b92
--- /dev/null
@@ -0,0 +1,229 @@
+/*!
+ * MediaWiki Widgets - APIResultsProvider class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org
+ */
+( function ( $, mw ) {
+
+       /**
+        * API Results Provider object.
+        *
+        * @class
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {string} apiurl The URL to the api
+        * @param {Object} [config] Configuration options
+        * @cfg {number} fetchLimit The default number of results to fetch
+        * @cfg {string} lang The language of the API
+        * @cfg {number} offset Initial offset, if relevant, to call results from
+        * @cfg {Object} ajaxSettings The settings for the ajax call
+        * @cfg {Object} staticParams The data parameters that are static and should
+        *  always be sent to the API request, as opposed to user parameters.
+        * @cfg {Object} userParams Initial user parameters to be sent as data to
+        *  the API request. These can change per request, like the search query term
+        *  or sizing parameters for images, etc.
+        */
+       mw.widgets.APIResultsProvider = function MwWidgetsAPIResultsProvider( apiurl, config ) {
+               config = config || {};
+
+               this.setAPIurl( apiurl );
+               this.setDefaultFetchLimit( config.fetchLimit || 30 );
+               this.setLang( config.lang );
+               this.setOffset( config.offset || 0 );
+               this.setAjaxSettings( config.ajaxSettings || {} );
+
+               this.staticParams = config.staticParams || {};
+               this.userParams = config.userParams || {};
+
+               this.toggleDepleted( false );
+
+               // Mixin constructors
+               OO.EventEmitter.call( this );
+       };
+
+       /* Setup */
+       OO.mixinClass( mw.widgets.APIResultsProvider, OO.EventEmitter );
+
+       /* Methods */
+
+       /**
+        * Get results from the source
+        *
+        * @param {number} howMany Number of results to ask for
+        * @return {jQuery.Promise} Promise that is resolved into an array
+        * of available results, or is rejected if no results are available.
+        */
+       mw.widgets.APIResultsProvider.prototype.getResults = function () {
+               var xhr,
+                       deferred = $.Deferred(),
+                       allParams = $.extend( {}, this.getStaticParams(), this.getUserParams() );
+
+               xhr = $.getJSON( this.getAPIurl(), allParams )
+                       .done( function ( data ) {
+                               if (
+                                       $.type( data ) !== 'array' ||
+                                       (
+                                               $.type( data ) === 'array' &&
+                                               data.length === 0
+                                       )
+                               ) {
+                                       deferred.resolve();
+                               } else {
+                                       deferred.resolve( data );
+                               }
+                       } );
+               return deferred.promise( { abort: xhr.abort } );
+       };
+
+       /**
+        * Set API url
+        *
+        * @param {string} apiurl API url
+        */
+       mw.widgets.APIResultsProvider.prototype.setAPIurl = function ( apiurl ) {
+               this.apiurl = apiurl;
+       };
+
+       /**
+        * Set api url
+        *
+        * @return {string} API url
+        */
+       mw.widgets.APIResultsProvider.prototype.getAPIurl = function () {
+               return this.apiurl;
+       };
+
+       /**
+        * Get the static, non-changing data parameters sent to the API
+        *
+        * @return {Object} Data parameters
+        */
+       mw.widgets.APIResultsProvider.prototype.getStaticParams = function () {
+               return this.staticParams;
+       };
+
+       /**
+        * Get the user-inputted dynamic data parameters sent to the API
+        *
+        * @return {Object} Data parameters
+        */
+       mw.widgets.APIResultsProvider.prototype.getUserParams = function () {
+               return this.userParams;
+       };
+
+       /**
+        * Set the data parameters sent to the API
+        *
+        * @param {Object} params User defined data parameters
+        */
+       mw.widgets.APIResultsProvider.prototype.setUserParams = function ( params ) {
+               // Asymmetrically compare (params is subset of this.userParams)
+               if ( !OO.compare( params, this.userParams, true ) ) {
+                       this.userParams = $.extend( {}, this.userParams, params );
+                       this.reset();
+               }
+       };
+
+       /**
+        * Reset the provider
+        */
+       mw.widgets.APIResultsProvider.prototype.reset = function () {
+               // Reset offset
+               this.setOffset( 0 );
+               // Reset depleted status
+               this.toggleDepleted( false );
+       };
+
+       /**
+        * Get fetch limit or 'page' size. This is the number
+        * of results per request.
+        *
+        * @return {number} limit
+        */
+       mw.widgets.APIResultsProvider.prototype.getDefaultFetchLimit = function () {
+               return this.limit;
+       };
+
+       /**
+        * Set limit
+        *
+        * @param {number} limit Default number of results to fetch from the API
+        */
+       mw.widgets.APIResultsProvider.prototype.setDefaultFetchLimit = function ( limit ) {
+               this.limit = limit;
+       };
+
+       /**
+        * Get provider API language
+        *
+        * @return {string} Provider API language
+        */
+       mw.widgets.APIResultsProvider.prototype.getLang = function () {
+               return this.lang;
+       };
+
+       /**
+        * Set provider API language
+        *
+        * @param {string} lang Provider API language
+        */
+       mw.widgets.APIResultsProvider.prototype.setLang = function ( lang ) {
+               this.lang = lang;
+       };
+
+       /**
+        * Get result offset
+        *
+        * @return {number} Offset Results offset for the upcoming request
+        */
+       mw.widgets.APIResultsProvider.prototype.getOffset = function () {
+               return this.offset;
+       };
+
+       /**
+        * Set result offset
+        *
+        * @param {number} offset Results offset for the upcoming request
+        */
+       mw.widgets.APIResultsProvider.prototype.setOffset = function ( offset ) {
+               this.offset = offset;
+       };
+
+       /**
+        * Check whether the provider is depleted and has no more results
+        * to hand off.
+        *
+        * @return {boolean} The provider is depleted
+        */
+       mw.widgets.APIResultsProvider.prototype.isDepleted = function () {
+               return this.depleted;
+       };
+
+       /**
+        * Toggle depleted state
+        *
+        * @param {boolean} isDepleted The provider is depleted
+        */
+       mw.widgets.APIResultsProvider.prototype.toggleDepleted = function ( isDepleted ) {
+               this.depleted = isDepleted !== undefined ? isDepleted : !this.depleted;
+       };
+
+       /**
+        * Get the default ajax settings
+        *
+        * @return {Object} Ajax settings
+        */
+       mw.widgets.APIResultsProvider.prototype.getAjaxSettings = function () {
+               return this.ajaxSettings;
+       };
+
+       /**
+        * Get the default ajax settings
+        *
+        * @param {Object} settings Ajax settings
+        */
+       mw.widgets.APIResultsProvider.prototype.setAjaxSettings = function ( settings ) {
+               this.ajaxSettings = settings;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsQueue.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.APIResultsQueue.js
new file mode 100644 (file)
index 0000000..3bc1d51
--- /dev/null
@@ -0,0 +1,224 @@
+/*!
+ * MediaWiki Widgets - APIResultsQueue class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see http://ve.mit-license.org
+ */
+( function ( $, mw ) {
+
+       /**
+        * API Results Queue object.
+        *
+        * @class
+        * @mixins OO.EventEmitter
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {number} limit The default number of results to fetch
+        * @cfg {number} threshold The default number of extra results
+        *  that the queue should always strive to have on top of the
+        *  individual requests for items.
+        */
+       mw.widgets.APIResultsQueue = function MwWidgetsAPIResultsQueue( config ) {
+               config = config || {};
+
+               this.fileRepoPromise = null;
+               this.providers = [];
+               this.providerPromises = [];
+               this.queue = [];
+
+               this.params = {};
+
+               this.limit = config.limit || 20;
+               this.setThreshold( config.threshold || 10 );
+
+               // Mixin constructors
+               OO.EventEmitter.call( this );
+       };
+
+       /* Setup */
+       OO.mixinClass( mw.widgets.APIResultsQueue, OO.EventEmitter );
+
+       /* Methods */
+
+       /**
+        * Set up the queue and its resources.
+        * This should be overridden if there are any setup steps to perform.
+        *
+        * @return {jQuery.Promise} Promise that resolves when the resources
+        *  are set up. Note: The promise must have an .abort() functionality.
+        */
+       mw.widgets.APIResultsQueue.prototype.setup = function () {
+               return $.Deferred().resolve().promise( { abort: $.noop } );
+       };
+
+       /**
+        * Get items from the queue
+        *
+        * @param {number} [howMany] How many items to retrieve. Defaults to the
+        *  default limit supplied on initialization.
+        * @return {jQuery.Promise} Promise that resolves into an array of items.
+        */
+       mw.widgets.APIResultsQueue.prototype.get = function ( howMany ) {
+               var fetchingPromise = null,
+                       me = this;
+
+               howMany = howMany || this.limit;
+
+               // Check if the queue has enough items
+               if ( this.queue.length < howMany + this.threshold ) {
+                       // Call for more results
+                       fetchingPromise = this.queryProviders( howMany + this.threshold )
+                               .then( function ( items ) {
+                                       // Add to the queue
+                                       me.queue = me.queue.concat.apply( me.queue, items );
+                               } );
+               }
+
+               return $.when( fetchingPromise )
+                       .then( function () {
+                               return me.queue.splice( 0, howMany );
+                       } );
+
+       };
+
+       /**
+        * Get results from all providers
+        *
+        * @param {number} [howMany] How many items to retrieve. Defaults to the
+        *  default limit supplied on initialization.
+        * @return {jQuery.Promise} Promise that is resolved into an array
+        *  of fetched items. Note: The promise must have an .abort() functionality.
+        */
+       mw.widgets.APIResultsQueue.prototype.queryProviders = function ( howMany ) {
+               var i, len,
+                       queue = this;
+
+               // Make sure there are resources set up
+               return this.setup()
+                       .then( function () {
+                               // Abort previous requests
+                               for ( i = 0, len = queue.providerPromises.length; i < len; i++ ) {
+                                       queue.providerPromises[ i ].abort();
+                               }
+                               queue.providerPromises = [];
+                               // Set up the query to all providers
+                               for ( i = 0, len = queue.providers.length; i < len; i++ ) {
+                                       if ( !queue.providers[ i ].isDepleted() ) {
+                                               queue.providerPromises.push(
+                                                       queue.providers[ i ].getResults( howMany )
+                                               );
+                                       }
+                               }
+
+                               return $.when.apply( $, queue.providerPromises )
+                                       .then( Array.prototype.concat.bind( [] ) );
+                       } );
+       };
+
+       /**
+        * Set the search query for all the providers.
+        *
+        * This also makes sure to abort any previous promises.
+        *
+        * @param {Object} params API search parameters
+        */
+       mw.widgets.APIResultsQueue.prototype.setParams = function ( params ) {
+               var i, len;
+               if ( !OO.compare( params, this.params, true ) ) {
+                       this.reset();
+                       this.params = $.extend( this.params, params );
+                       // Reset queue
+                       this.queue = [];
+                       // Reset promises
+                       for ( i = 0, len = this.providerPromises.length; i < len; i++ ) {
+                               this.providerPromises[ i ].abort();
+                       }
+                       // Change queries
+                       for ( i = 0, len = this.providers.length; i < len; i++ ) {
+                               this.providers[ i ].setUserParams( this.params );
+                       }
+               }
+       };
+
+       /**
+        * Reset the queue and all its providers
+        */
+       mw.widgets.APIResultsQueue.prototype.reset = function () {
+               var i, len;
+               // Reset queue
+               this.queue = [];
+               // Reset promises
+               for ( i = 0, len = this.providerPromises.length; i < len; i++ ) {
+                       this.providerPromises[ i ].abort();
+               }
+               // Change queries
+               for ( i = 0, len = this.providers.length; i < len; i++ ) {
+                       this.providers[ i ].reset();
+               }
+       };
+
+       /**
+        * Get the data parameters sent to the API
+        *
+        * @return {Object} params API search parameters
+        */
+       mw.widgets.APIResultsQueue.prototype.getParams = function () {
+               return this.params;
+       };
+
+       /**
+        * Set the providers
+        *
+        * @param {mw.widgets.APIResultsProvider[]} providers An array of providers
+        */
+       mw.widgets.APIResultsQueue.prototype.setProviders = function ( providers ) {
+               this.providers = providers;
+       };
+
+       /**
+        * Add a provider to the group
+        *
+        * @param {mw.widgets.APIResultsProvider} provider A provider object
+        */
+       mw.widgets.APIResultsQueue.prototype.addProvider = function ( provider ) {
+               this.providers.push( provider );
+       };
+
+       /**
+        * Set the providers
+        *
+        * @return {mw.widgets.APIResultsProvider[]} providers An array of providers
+        */
+       mw.widgets.APIResultsQueue.prototype.getProviders = function () {
+               return this.providers;
+       };
+
+       /**
+        * Get the queue size
+        *
+        * @return {number} Queue size
+        */
+       mw.widgets.APIResultsQueue.prototype.getQueueSize = function () {
+               return this.queue.length;
+       };
+
+       /**
+        * Set queue threshold
+        *
+        * @param {number} threshold Queue threshold, below which we will
+        *  request more items
+        */
+       mw.widgets.APIResultsQueue.prototype.setThreshold = function ( threshold ) {
+               this.threshold = threshold;
+       };
+
+       /**
+        * Get queue threshold
+        *
+        * @return {number} threshold Queue threshold, below which we will
+        *  request more items
+        */
+       mw.widgets.APIResultsQueue.prototype.getThreshold = function () {
+               return this.threshold;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceProvider.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceProvider.js
new file mode 100644 (file)
index 0000000..d767109
--- /dev/null
@@ -0,0 +1,322 @@
+/*!
+ * MediaWiki Widgets - MediaResourceProvider class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * MediaWiki media resource provider.
+        *
+        * @class
+        * @extends mw.widgets.APIResultsProvider
+        *
+        * @constructor
+        * @param {string} apiurl The API url
+        * @param {Object} [config] Configuration options
+        * @cfg {string} [scriptDirUrl] The url of the API script
+        */
+       mw.widgets.MediaResourceProvider = function MwWidgetsMediaResourceProvider( apiurl, config ) {
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.MediaResourceProvider.super.call( this, apiurl, config );
+
+               // Fetching configuration
+               this.scriptDirUrl = config.scriptDirUrl;
+               this.isLocal = config.local !== undefined;
+
+               if ( this.isLocal ) {
+                       this.setAPIurl( mw.util.wikiScript( 'api' ) );
+               } else {
+                       // If 'apiurl' is set, use that. Otherwise, build the url
+                       // from scriptDirUrl and /api.php suffix
+                       this.setAPIurl( this.getAPIurl() || ( this.scriptDirUrl + '/api.php' ) );
+               }
+
+               this.siteInfoPromise = null;
+               this.thumbSizes = [];
+               this.imageSizes = [];
+       };
+
+       /* Inheritance */
+       OO.inheritClass( mw.widgets.MediaResourceProvider, mw.widgets.APIResultsProvider );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.MediaResourceProvider.prototype.getStaticParams = function () {
+               return $.extend(
+                       {},
+                       // Parent method
+                       mw.widgets.MediaResourceProvider.super.prototype.getStaticParams.call( this ),
+                       {
+                               action: 'query',
+                               iiprop: 'dimensions|url|mediatype|extmetadata|timestamp|user',
+                               iiextmetadatalanguage: this.getLang(),
+                               prop: 'imageinfo'
+                       }
+               );
+       };
+
+       /**
+        * Initialize the source and get the site info.
+        *
+        * Connect to the api url and retrieve the siteinfo parameters
+        * that are required for fetching results.
+        *
+        * @return {jQuery.Promise} Promise that resolves when the class
+        * properties are set.
+        */
+       mw.widgets.MediaResourceProvider.prototype.loadSiteInfo = function () {
+               var provider = this;
+
+               if ( !this.siteInfoPromise ) {
+                       this.siteInfoPromise = new mw.Api().get( {
+                               action: 'query',
+                               meta: 'siteinfo'
+                       } )
+                               .then( function ( data ) {
+                                       provider.setImageSizes( data.query.general.imagelimits || [] );
+                                       provider.setThumbSizes( data.query.general.thumblimits || [] );
+                                       provider.setUserParams( {
+                                               // Standard width per resource
+                                               iiurlwidth: provider.getStandardWidth()
+                                       } );
+                               } );
+               }
+               return this.siteInfoPromise;
+       };
+
+       /**
+        * Override parent method and get results from the source
+        *
+        * @param {number} [howMany] The number of items to pull from the API
+        * @return {jQuery.Promise} Promise that is resolved into an array
+        * of available results, or is rejected if no results are available.
+        */
+       mw.widgets.MediaResourceProvider.prototype.getResults = function ( howMany ) {
+               var xhr,
+                       aborted = false,
+                       provider = this;
+
+               return this.loadSiteInfo()
+                       .then( function () {
+                               if ( aborted ) {
+                                       return $.Deferred().reject();
+                               }
+                               xhr = provider.fetchAPIresults( howMany );
+                               return xhr;
+                       } )
+                       .then(
+                               function ( results ) {
+                                       if ( !results || results.length === 0 ) {
+                                               provider.toggleDepleted( true );
+                                               return [];
+                                       }
+                                       return results;
+                               },
+                               // Process failed, return an empty promise
+                               function () {
+                                       provider.toggleDepleted( true );
+                                       return $.Deferred().resolve( [] );
+                               }
+                       )
+                       .promise( { abort: function () {
+                               aborted = true;
+                               if ( xhr ) {
+                                       xhr.abort();
+                               }
+                       } } );
+       };
+
+       /**
+        * Get continuation API data
+        *
+        * @param {number} howMany The number of results to retrieve
+        * @return {Object} API request data
+        */
+       mw.widgets.MediaResourceProvider.prototype.getContinueData = function () {
+               return {};
+       };
+
+       /**
+        * Set continuation data for the next page
+        *
+        * @param {Object} continueData Continuation data
+        */
+       mw.widgets.MediaResourceProvider.prototype.setContinue = function () {
+       };
+
+       /**
+        * Sort the results
+        *
+        * @param {Object[]} results API results
+        * @return {Object[]} Sorted results
+        */
+       mw.widgets.MediaResourceProvider.prototype.sort = function ( results ) {
+               return results;
+       };
+
+       /**
+        * Call the API for search results.
+        *
+        * @param {number} howMany The number of results to retrieve
+        * @return {jQuery.Promise} Promise that resolves with an array of objects that contain
+        *  the fetched data.
+        */
+       mw.widgets.MediaResourceProvider.prototype.fetchAPIresults = function ( howMany ) {
+               var xhr, api,
+                       provider = this;
+
+               if ( !this.isValid() ) {
+                       return $.Deferred().reject().promise( { abort: $.noop } );
+               }
+
+               api = this.isLocal ? new mw.Api() : new mw.ForeignApi( this.getAPIurl(), { anonymous: true } );
+               xhr = api.get( $.extend( {}, this.getStaticParams(), this.getUserParams(), this.getContinueData( howMany ) ) );
+               return xhr
+                       .then( function ( data ) {
+                               var page, newObj, raw,
+                                       results = [];
+
+                               if ( data.error ) {
+                                       provider.toggleDepleted( true );
+                                       return [];
+                               }
+
+                               if ( data.continue ) {
+                                       // Update the offset for next time
+                                       provider.setContinue( data.continue );
+                               } else {
+                                       // This is the last available set of results. Mark as depleted!
+                                       provider.toggleDepleted( true );
+                               }
+
+                               // If the source returned no results, it will not have a
+                               // query property
+                               if ( data.query ) {
+                                       raw = data.query.pages;
+                                       if ( raw ) {
+                                               // Strip away the page ids
+                                               for ( page in raw ) {
+                                                       if ( !raw[ page ].imageinfo ) {
+                                                               // The search may give us pages that belong to the File:
+                                                               // namespace but have no files in them, either because
+                                                               // they were deleted or imported wrongly, or just started
+                                                               // as pages. In that case, the response will not include
+                                                               // imageinfo. Skip those files.
+                                                               continue;
+                                                       }
+                                                       newObj = raw[ page ].imageinfo[ 0 ];
+                                                       newObj.title = raw[ page ].title;
+                                                       newObj.index = raw[ page ].index;
+                                                       results.push( newObj );
+                                               }
+                                       }
+                               }
+                               return provider.sort( results );
+                       } )
+                       .promise( { abort: xhr.abort } );
+       };
+
+       /**
+        * Set name
+        *
+        * @param {string} name
+        */
+       mw.widgets.MediaResourceProvider.prototype.setName = function ( name ) {
+               this.name = name;
+       };
+
+       /**
+        * Get name
+        *
+        * @return {string} name
+        */
+       mw.widgets.MediaResourceProvider.prototype.getName = function () {
+               return this.name;
+       };
+
+       /**
+        * Get standard width, based on the provider source's thumb sizes.
+        *
+        * @return {number|undefined} fetchWidth
+        */
+       mw.widgets.MediaResourceProvider.prototype.getStandardWidth = function () {
+               return ( this.thumbSizes && this.thumbSizes[ this.thumbSizes.length - 1 ] ) ||
+                       ( this.imageSizes && this.imageSizes[ 0 ] ) ||
+                       // Fall back on a number
+                       300;
+       };
+
+       /**
+        * Get prop
+        *
+        * @return {string} prop
+        */
+       mw.widgets.MediaResourceProvider.prototype.getFetchProp = function () {
+               return this.fetchProp;
+       };
+
+       /**
+        * Set prop
+        *
+        * @param {string} prop
+        */
+       mw.widgets.MediaResourceProvider.prototype.setFetchProp = function ( prop ) {
+               this.fetchProp = prop;
+       };
+
+       /**
+        * Set thumb sizes
+        *
+        * @param {number[]} sizes Available thumbnail sizes
+        */
+       mw.widgets.MediaResourceProvider.prototype.setThumbSizes = function ( sizes ) {
+               this.thumbSizes = sizes;
+       };
+
+       /**
+        * Set image sizes
+        *
+        * @param {number[]} sizes Available image sizes
+        */
+       mw.widgets.MediaResourceProvider.prototype.setImageSizes = function ( sizes ) {
+               this.imageSizes = sizes;
+       };
+
+       /**
+        * Get thumb sizes
+        *
+        * @return {number[]} sizes Available thumbnail sizes
+        */
+       mw.widgets.MediaResourceProvider.prototype.getThumbSizes = function () {
+               return this.thumbSizes;
+       };
+
+       /**
+        * Get image sizes
+        *
+        * @return {number[]} sizes Available image sizes
+        */
+       mw.widgets.MediaResourceProvider.prototype.getImageSizes = function () {
+               return this.imageSizes;
+       };
+
+       /**
+        * Check if this source is valid.
+        *
+        * @return {boolean} Source is valid
+        */
+       mw.widgets.MediaResourceProvider.prototype.isValid = function () {
+               return this.isLocal ||
+                       // If we don't have either 'apiurl' or 'scriptDirUrl'
+                       // the source is invalid, and we will skip it
+                       this.apiurl !== undefined ||
+                       this.scriptDirUrl !== undefined;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceQueue.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResourceQueue.js
new file mode 100644 (file)
index 0000000..34fa44b
--- /dev/null
@@ -0,0 +1,68 @@
+/*!
+ * MediaWiki Widgets - MediaResourceQueue class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * MediaWiki media resource queue.
+        *
+        * @class
+        * @extends mw.widgets.APIResultsQueue
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {number} maxHeight The maximum height of the media, used in the
+        *  search call to the API.
+        */
+       mw.widgets.MediaResourceQueue = function MwWidgetsMediaResourceQueue( config ) {
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.MediaResourceQueue.super.call( this, config );
+
+               this.maxHeight = config.maxHeight || 200;
+       };
+
+       /* Inheritance */
+       OO.inheritClass( mw.widgets.MediaResourceQueue, mw.widgets.APIResultsQueue );
+
+       /**
+        * Fetch the file repos.
+        *
+        * @return {jQuery.Promise} Promise that resolves when the resources are set up
+        */
+       mw.widgets.MediaResourceQueue.prototype.getFileRepos = function () {
+               var defaultSource = [ {
+                       url: mw.util.wikiScript( 'api' ),
+                       local: ''
+               } ];
+
+               if ( !this.fileRepoPromise ) {
+                       this.fileRepoPromise = new mw.Api().get( {
+                               action: 'query',
+                               meta: 'filerepoinfo'
+                       } ).then(
+                               function ( resp ) {
+                                       return resp.query && resp.query.repos || defaultSource;
+                               },
+                               function () {
+                                       return $.Deferred().resolve( defaultSource );
+                               }
+                       );
+               }
+
+               return this.fileRepoPromise;
+       };
+
+       /**
+        * Get image maximum height
+        *
+        * @return {string} Image max height
+        */
+       mw.widgets.MediaResourceQueue.prototype.getMaxHeight = function () {
+               return this.maxHeight;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.css b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.css
new file mode 100644 (file)
index 0000000..bc752b5
--- /dev/null
@@ -0,0 +1,89 @@
+/*!
+ * MediaWiki Widgets - MediaResultWidget styles.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.mw-widget-mediaResultWidget {
+       display: inline-block;
+       position: relative;
+       padding: 0;
+       margin: 2px;
+       overflow: hidden;
+       box-sizing: border-box;
+       text-align: center;
+}
+
+.mw-widget-mediaResultWidget-error {
+       background-color: #f3f3f3;
+}
+
+.mw-widget-mediaResultWidget-thumbnail {
+       opacity: 0;
+       display: inline-block;
+       /* stylelint-disable no-unsupported-browser-features */
+       -webkit-transition: opacity 400ms;
+       -moz-transition: opacity 400ms;
+       transition: opacity 400ms;
+       /* stylelint-enable no-unsupported-browser-features */
+}
+
+.mw-widget-mediaResultWidget-done .mw-widget-mediaResultWidget-thumbnail,
+.mw-widget-mediaResultWidget-error .mw-widget-mediaResultWidget-thumbnail {
+       opacity: 1;
+}
+
+.mw-widget-mediaResultWidget-crop {
+       background-size: cover; /* stylelint-disable-line no-unsupported-browser-features */
+       background-position: center center;
+}
+
+.mw-widget-mediaResultWidget-overlay {
+       position: absolute;
+       top: 0;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       box-shadow: inset 0 0 0 1px #ccc;
+}
+
+.mw-widget-mediaResultWidget.oo-ui-optionWidget-highlighted,
+.mw-widget-mediaResultWidget.oo-ui-optionWidget-selected {
+       box-shadow: 0 0 0.3em #a7dcff, 0 0 0 #fff;
+}
+
+.mw-widget-mediaResultWidget-error .mw-widget-mediaResultWidget-thumbnail {
+       /* @embed */
+       background-image: url( broken-image.png );
+       background-size: auto; /* stylelint-disable-line no-unsupported-browser-features */
+       background-position: center center;
+       background-repeat: no-repeat;
+}
+
+.mw-widget-mediaResultWidget .mw-widget-mediaResultWidget-nameLabel {
+       position: absolute;
+       bottom: 0;
+       left: 0;
+       right: 0;
+       overflow: hidden;
+       padding: 0.5em;
+       color: #fff;
+       text-shadow: 1px 1px #000; /* stylelint-disable-line no-unsupported-browser-features */
+       line-height: 1.125em;
+       background-color: rgba( 0, 0, 0, 0.5 );
+       text-overflow: ellipsis;
+       text-align: left;
+}
+
+.mw-widget-mediaResultWidget.oo-ui-optionWidget-highlighted .mw-widget-mediaResultWidget-nameLabel {
+       background-color: rgba( 0, 0, 0, 0.75 );
+}
+
+.mw-widget-mediaResultWidget.oo-ui-optionWidget-selected .mw-widget-mediaResultWidget-nameLabel {
+       background-color: #000;
+}
+
+.mw-widget-mediaSearchWidget-noresults {
+       padding-top: 1em;
+}
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaResultWidget.js
new file mode 100644 (file)
index 0000000..7607e84
--- /dev/null
@@ -0,0 +1,274 @@
+/*!
+ * MediaWiki Widgets - MediaResultWidget class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * Creates an mw.widgets.MediaResultWidget object.
+        *
+        * @class
+        * @extends OO.ui.OptionWidget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {number} [rowHeight] Height of the row this result is part of
+        * @cfg {number} [maxRowWidth] A limit for the width of the row this
+        *  result is a part of.
+        * @cfg {number} [minWidth] Minimum width for the result
+        * @cfg {number} [maxWidth] Maximum width for the result
+        */
+       mw.widgets.MediaResultWidget = function MwWidgetsMediaResultWidget( config ) {
+               // Configuration initialization
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.MediaResultWidget.super.call( this, config );
+
+               // Properties
+               this.setRowHeight( config.rowHeight || 150 );
+               this.maxRowWidth = config.maxRowWidth || 500;
+               this.minWidth = config.minWidth || this.maxRowWidth / 5;
+               this.maxWidth = config.maxWidth || this.maxRowWidth * 2 / 3;
+
+               this.imageDimensions = {};
+
+               this.isAudio = this.data.mediatype === 'AUDIO';
+
+               // Store the thumbnail url
+               this.thumbUrl = this.data.thumburl;
+               this.src = null;
+               this.row = null;
+
+               this.$thumb = $( '<img>' )
+                       .addClass( 'mw-widget-mediaResultWidget-thumbnail' )
+                       .on( {
+                               load: this.onThumbnailLoad.bind( this ),
+                               error: this.onThumbnailError.bind( this )
+                       } );
+               this.$overlay = $( '<div>' )
+                       .addClass( 'mw-widget-mediaResultWidget-overlay' );
+
+               this.calculateSizing( this.data );
+
+               // Get wiki default thumbnail size
+               this.defaultThumbSize = mw.config.get( 'wgVisualEditorConfig' )
+                       .defaultUserOptions.defaultthumbsize;
+
+               // Initialization
+               this.setLabel( new mw.Title( this.data.title ).getNameText() );
+               this.$label.addClass( 'mw-widget-mediaResultWidget-nameLabel' );
+
+               this.$element
+                       .addClass( 'mw-widget-mediaResultWidget ve-ui-texture-pending' )
+                       .prepend( this.$thumb, this.$overlay );
+       };
+
+       /* Inheritance */
+
+       OO.inheritClass( mw.widgets.MediaResultWidget, OO.ui.OptionWidget );
+
+       /* Static methods */
+
+       // Copied from ve.dm.MWImageNode
+       mw.widgets.MediaResultWidget.static.resizeToBoundingBox = function ( imageDimensions, boundingBox ) {
+               var newDimensions = OO.copy( imageDimensions ),
+                       scale = Math.min(
+                               boundingBox.height / imageDimensions.height,
+                               boundingBox.width / imageDimensions.width
+                       );
+
+               if ( scale < 1 ) {
+                       // Scale down
+                       newDimensions = {
+                               width: Math.floor( newDimensions.width * scale ),
+                               height: Math.floor( newDimensions.height * scale )
+                       };
+               }
+               return newDimensions;
+       };
+
+       /* Methods */
+       /** */
+       mw.widgets.MediaResultWidget.prototype.onThumbnailLoad = function () {
+               this.$thumb.first().addClass( 've-ui-texture-transparency' );
+               this.$element
+                       .addClass( 'mw-widget-mediaResultWidget-done' )
+                       .removeClass( 've-ui-texture-pending' );
+       };
+
+       /** */
+       mw.widgets.MediaResultWidget.prototype.onThumbnailError = function () {
+               this.$thumb.last()
+                       .css( 'background-image', '' )
+                       .addClass( 've-ui-texture-alert' );
+               this.$element
+                       .addClass( 'mw-widget-mediaResultWidget-error' )
+                       .removeClass( 've-ui-texture-pending' );
+       };
+
+       /**
+        * Resize the thumbnail and wrapper according to row height and bounding boxes, if given.
+        *
+        * @param {Object} originalDimensions Original image dimensions with width and height values
+        * @param {Object} [boundingBox] Specific bounding box, if supplied
+        */
+       mw.widgets.MediaResultWidget.prototype.calculateSizing = function ( originalDimensions, boundingBox ) {
+               var wrapperPadding,
+                       imageDimensions = {};
+
+               boundingBox = boundingBox || {};
+
+               if ( this.isAudio ) {
+                       // HACK: We are getting the wrong information from the
+                       // API about audio files. Set their thumbnail to square 120px
+                       imageDimensions = {
+                               width: 120,
+                               height: 120
+                       };
+               } else {
+                       // Get the image within the bounding box
+                       imageDimensions = this.constructor.static.resizeToBoundingBox(
+                               // Image original dimensions
+                               {
+                                       width: originalDimensions.width || originalDimensions.thumbwidth,
+                                       height: originalDimensions.height || originalDimensions.thumbwidth
+                               },
+                               // Bounding box
+                               {
+                                       width: boundingBox.width || this.getImageMaxWidth(),
+                                       height: boundingBox.height || this.getRowHeight()
+                               }
+                       );
+               }
+               this.imageDimensions = imageDimensions;
+               // Set the thumbnail size
+               this.$thumb.css( this.imageDimensions );
+
+               // Set the box size
+               wrapperPadding = this.calculateWrapperPadding( this.imageDimensions );
+               this.$element.css( wrapperPadding );
+       };
+
+       /**
+        * Replace the empty .src attribute of the image with the
+        * actual src.
+        */
+       mw.widgets.MediaResultWidget.prototype.lazyLoad = function () {
+               if ( !this.hasSrc() ) {
+                       this.src = this.thumbUrl;
+                       this.$thumb.attr( 'src', this.thumbUrl );
+               }
+       };
+
+       /**
+        * Retrieve the store dimensions object
+        *
+        * @return {Object} Thumb dimensions
+        */
+       mw.widgets.MediaResultWidget.prototype.getDimensions = function () {
+               return this.dimensions;
+       };
+
+       /**
+        * Resize thumbnail and element according to the resize factor
+        *
+        * @param {number} resizeFactor The resizing factor for the image
+        */
+       mw.widgets.MediaResultWidget.prototype.resizeThumb = function ( resizeFactor ) {
+               var boundingBox,
+                       imageOriginalWidth = this.imageDimensions.width,
+                       wrapperWidth = this.$element.width();
+               // Set the new row height
+               this.setRowHeight( Math.ceil( this.getRowHeight() * resizeFactor ) );
+
+               boundingBox = {
+                       width: Math.ceil( this.imageDimensions.width * resizeFactor ),
+                       height: this.getRowHeight()
+               };
+
+               this.calculateSizing( this.data, boundingBox );
+
+               // We need to adjust the wrapper this time to fit the "perfect"
+               // dimensions, regardless of how small the image is
+               if ( imageOriginalWidth < wrapperWidth ) {
+                       boundingBox.width = wrapperWidth * resizeFactor;
+               }
+               this.$element.css( this.calculateWrapperPadding( boundingBox ) );
+       };
+
+       /**
+        * Adjust the wrapper padding for small images
+        *
+        * @param {Object} thumbDimensions Thumbnail dimensions
+        * @return {Object} Css styling for the wrapper
+        */
+       mw.widgets.MediaResultWidget.prototype.calculateWrapperPadding = function ( thumbDimensions ) {
+               var css = {
+                       height: this.rowHeight,
+                       width: thumbDimensions.width,
+                       lineHeight: this.getRowHeight() + 'px'
+               };
+
+               // Check if the image is too thin so we can make a bit of space around it
+               if ( thumbDimensions.width < this.minWidth ) {
+                       css.width = this.minWidth;
+               }
+
+               return css;
+       };
+
+       /**
+        * Set the row height for all size calculations
+        *
+        * @return {number} rowHeight Row height
+        */
+       mw.widgets.MediaResultWidget.prototype.getRowHeight = function () {
+               return this.rowHeight;
+       };
+
+       /**
+        * Set the row height for all size calculations
+        *
+        * @param {number} rowHeight Row height
+        */
+       mw.widgets.MediaResultWidget.prototype.setRowHeight = function ( rowHeight ) {
+               this.rowHeight = rowHeight;
+       };
+
+       mw.widgets.MediaResultWidget.prototype.setImageMaxWidth = function ( width ) {
+               this.maxWidth = width;
+       };
+       mw.widgets.MediaResultWidget.prototype.getImageMaxWidth = function () {
+               return this.maxWidth;
+       };
+
+       /**
+        * Set the row this result is in.
+        *
+        * @param {number} row Row number
+        */
+       mw.widgets.MediaResultWidget.prototype.setRow = function ( row ) {
+               this.row = row;
+       };
+
+       /**
+        * Get the row this result is in.
+        *
+        * @return {number} row Row number
+        */
+       mw.widgets.MediaResultWidget.prototype.getRow = function () {
+               return this.row;
+       };
+
+       /**
+        * Check if the image has a src attribute already
+        *
+        * @return {boolean} Thumbnail has its source attribute set
+        */
+       mw.widgets.MediaResultWidget.prototype.hasSrc = function () {
+               return !!this.src;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchProvider.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchProvider.js
new file mode 100644 (file)
index 0000000..a46d911
--- /dev/null
@@ -0,0 +1,69 @@
+/*!
+ * MediaWiki Widgets - MediaSearchProvider class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * MediaWiki media search provider.
+        *
+        * @class
+        * @extends mw.widgets.MediaResourceProvider
+        *
+        * @constructor
+        * @param {string} apiurl The API url
+        * @param {Object} [config] Configuration options
+        */
+       mw.widgets.MediaSearchProvider = function MwWidgetsMediaSearchProvider( apiurl, config ) {
+               config = config || {};
+
+               config.staticParams = $.extend( {
+                       generator: 'search',
+                       gsrnamespace: mw.config.get( 'wgNamespaceIds' ).file
+               }, config.staticParams );
+
+               // Parent constructor
+               mw.widgets.MediaSearchProvider.super.call( this, apiurl, config );
+       };
+
+       /* Inheritance */
+       OO.inheritClass( mw.widgets.MediaSearchProvider, mw.widgets.MediaResourceProvider );
+
+       /* Methods */
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.MediaSearchProvider.prototype.getContinueData = function ( howMany ) {
+               return {
+                       gsroffset: this.getOffset(),
+                       gsrlimit: howMany || this.getDefaultFetchLimit()
+               };
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.MediaSearchProvider.prototype.setContinue = function ( continueData ) {
+               // Update the offset for next time
+               this.setOffset( continueData.gsroffset );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.MediaSearchProvider.prototype.sort = function ( results ) {
+               return results.sort( function ( a, b ) {
+                       return a.index - b.index;
+               } );
+       };
+
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.MediaSearchProvider.prototype.isValid = function () {
+               return this.getUserParams().gsrsearch && mw.widgets.MediaSearchProvider.super.prototype.isValid.call( this );
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchQueue.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchQueue.js
new file mode 100644 (file)
index 0000000..7ee98bb
--- /dev/null
@@ -0,0 +1,82 @@
+/*!
+ * MediaWiki Widgets - MediaSearchQueue class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * MediaWiki media resource queue.
+        *
+        * @class
+        * @extends mw.widgets.MediaResourceQueue
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {number} maxHeight The maximum height of the media, used in the
+        *  search call to the API.
+        */
+       mw.widgets.MediaSearchQueue = function MwWidgetsMediaSearchQueue( config ) {
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.MediaSearchQueue.super.call( this, config );
+
+               this.searchQuery = '';
+       };
+
+       /* Inheritance */
+       OO.inheritClass( mw.widgets.MediaSearchQueue, mw.widgets.MediaResourceQueue );
+
+       /**
+        * Override parent method to set up the providers according to
+        * the file repos
+        *
+        * @return {jQuery.Promise} Promise that resolves when the resources are set up
+        */
+       mw.widgets.MediaSearchQueue.prototype.setup = function () {
+               var i, len,
+                       queue = this;
+
+               return this.getFileRepos().then( function ( sources ) {
+                       if ( queue.providers.length === 0 ) {
+                               // Set up the providers
+                               for ( i = 0, len = sources.length; i < len; i++ ) {
+                                       queue.providers.push( new mw.widgets.MediaSearchProvider(
+                                               sources[ i ].apiurl,
+                                               {
+                                                       name: sources[ i ].name,
+                                                       local: sources[ i ].local,
+                                                       scriptDirUrl: sources[ i ].scriptDirUrl,
+                                                       userParams: {
+                                                               gsrsearch: queue.getSearchQuery()
+                                                       },
+                                                       staticParams: {
+                                                               iiurlheight: queue.getMaxHeight()
+                                                       }
+                                               } )
+                                       );
+                               }
+                       }
+               } );
+       };
+
+       /**
+        * Set the search query
+        *
+        * @param {string} searchQuery API search query
+        */
+       mw.widgets.MediaSearchQueue.prototype.setSearchQuery = function ( searchQuery ) {
+               this.setParams( { gsrsearch: searchQuery } );
+       };
+
+       /**
+        * Get the search query
+        *
+        * @return {string} API search query
+        */
+       mw.widgets.MediaSearchQueue.prototype.getSearchQuery = function () {
+               return this.getParams().gsrsearch;
+       };
+}( jQuery, mediaWiki ) );
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.css b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.css
new file mode 100644 (file)
index 0000000..3d28ef8
--- /dev/null
@@ -0,0 +1,10 @@
+/*!
+ * MediaWiki Widgets - MediaSearchWidget styles.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+.mw-widget-mediaSearchWidget .oo-ui-searchWidget-query .oo-ui-inputWidget {
+       max-width: none;
+}
diff --git a/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.js b/resources/src/mediawiki.widgets/MediaSearch/mw.widgets.MediaSearchWidget.js
new file mode 100644 (file)
index 0000000..c6938e8
--- /dev/null
@@ -0,0 +1,462 @@
+/*!
+ * MediaWiki Widgets - MediaSearchWidget class.
+ *
+ * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+( function ( $, mw ) {
+
+       /**
+        * Creates an mw.widgets.MediaSearchWidget object.
+        *
+        * @class
+        * @extends OO.ui.SearchWidget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @param {number} [size] Vertical size of thumbnails
+        */
+       mw.widgets.MediaSearchWidget = function MwWidgetsMediaSearchWidget( config ) {
+               // Configuration initialization
+               config = $.extend( {
+                       placeholder: mw.msg( 'mw-widgets-mediasearch-input-placeholder' )
+               }, config );
+
+               // Parent constructor
+               mw.widgets.MediaSearchWidget.super.call( this, config );
+
+               // Properties
+               this.providers = {};
+               this.lastQueryValue = '';
+               this.searchQueue = new mw.widgets.MediaSearchQueue( {
+                       limit: this.constructor.static.limit,
+                       threshold: this.constructor.static.threshold
+               } );
+
+               this.queryTimeout = null;
+               this.itemCache = {};
+               this.promises = [];
+               this.lang = config.lang || 'en';
+               this.$panels = config.$panels;
+
+               this.externalLinkUrlProtocolsRegExp = new RegExp(
+                       '^(' + mw.config.get( 'wgUrlProtocols' ) + ')',
+                       'i'
+               );
+
+               // Masonry fit properties
+               this.rows = [];
+               this.rowHeight = config.rowHeight || 200;
+               this.layoutQueue = [];
+               this.numItems = 0;
+               this.currentItemCache = [];
+
+               this.resultsSize = {};
+
+               this.selected = null;
+
+               this.noItemsMessage = new OO.ui.LabelWidget( {
+                       label: mw.msg( 'mw-widgets-mediasearch-noresults' ),
+                       classes: [ 'mw-widget-mediaSearchWidget-noresults' ]
+               } );
+               this.noItemsMessage.toggle( false );
+
+               // Events
+               this.$results.on( 'scroll', this.onResultsScroll.bind( this ) );
+               this.$query.append( this.noItemsMessage.$element );
+               this.results.connect( this, {
+                       add: 'onResultsAdd',
+                       remove: 'onResultsRemove'
+               } );
+
+               this.resizeHandler = OO.ui.debounce( this.afterResultsResize.bind( this ), 500 );
+
+               // Initialization
+               this.$element.addClass( 'mw-widget-mediaSearchWidget' );
+       };
+
+       /* Inheritance */
+
+       OO.inheritClass( mw.widgets.MediaSearchWidget, OO.ui.SearchWidget );
+
+       /* Static properties */
+
+       mw.widgets.MediaSearchWidget.static.limit = 10;
+
+       mw.widgets.MediaSearchWidget.static.threshold = 5;
+
+       /* Methods */
+
+       /**
+        * Respond to window resize and check if the result display should
+        * be updated.
+        */
+       mw.widgets.MediaSearchWidget.prototype.afterResultsResize = function () {
+               var items = this.currentItemCache;
+
+               if (
+                       items.length > 0 &&
+                       (
+                               this.resultsSize.width !== this.$results.width() ||
+                               this.resultsSize.height !== this.$results.height()
+                       )
+               ) {
+                       this.resetRows();
+                       this.itemCache = {};
+                       this.processQueueResults( items );
+                       if ( this.results.getItems().length > 0 ) {
+                               this.lazyLoadResults();
+                       }
+
+                       // Cache the size
+                       this.resultsSize = {
+                               width: this.$results.width(),
+                               height: this.$results.height()
+                       };
+               }
+       };
+
+       /**
+        * Teardown the widget; disconnect the window resize event.
+        */
+       mw.widgets.MediaSearchWidget.prototype.teardown = function () {
+               $( window ).off( 'resize', this.resizeHandler );
+       };
+
+       /**
+        * Setup the widget; activate the resize event.
+        */
+       mw.widgets.MediaSearchWidget.prototype.setup = function () {
+               $( window ).on( 'resize', this.resizeHandler );
+       };
+
+       /**
+        * Query all sources for media.
+        *
+        * @method
+        */
+       mw.widgets.MediaSearchWidget.prototype.queryMediaQueue = function () {
+               var search = this,
+                       value = this.getQueryValue();
+
+               if ( value === '' ) {
+                       return;
+               }
+
+               this.query.pushPending();
+               search.noItemsMessage.toggle( false );
+
+               this.searchQueue.setSearchQuery( value );
+               this.searchQueue.get( this.constructor.static.limit )
+                       .then( function ( items ) {
+                               if ( items.length > 0 ) {
+                                       search.processQueueResults( items );
+                                       search.currentItemCache = search.currentItemCache.concat( items );
+                               }
+
+                               search.query.popPending();
+                               search.noItemsMessage.toggle( search.results.getItems().length === 0 );
+                               if ( search.results.getItems().length > 0 ) {
+                                       search.lazyLoadResults();
+                               }
+
+                       } );
+       };
+
+       /**
+        * Process the media queue giving more items
+        *
+        * @method
+        * @param {Object[]} items Given items by the media queue
+        */
+       mw.widgets.MediaSearchWidget.prototype.processQueueResults = function ( items ) {
+               var i, len, title,
+                       resultWidgets = [],
+                       inputSearchQuery = this.getQueryValue(),
+                       queueSearchQuery = this.searchQueue.getSearchQuery();
+
+               if ( inputSearchQuery === '' || queueSearchQuery !== inputSearchQuery ) {
+                       return;
+               }
+
+               for ( i = 0, len = items.length; i < len; i++ ) {
+                       title = new mw.Title( items[ i ].title ).getMainText();
+                       // Do not insert duplicates
+                       if ( !Object.prototype.hasOwnProperty.call( this.itemCache, title ) ) {
+                               this.itemCache[ title ] = true;
+                               resultWidgets.push(
+                                       new mw.widgets.MediaResultWidget( {
+                                               data: items[ i ],
+                                               rowHeight: this.rowHeight,
+                                               maxWidth: this.results.$element.width() / 3,
+                                               minWidth: 30,
+                                               rowWidth: this.results.$element.width()
+                                       } )
+                               );
+                       }
+               }
+               this.results.addItems( resultWidgets );
+
+       };
+
+       /**
+        * Get the sanitized query value from the input
+        *
+        * @return {string} Query value
+        */
+       mw.widgets.MediaSearchWidget.prototype.getQueryValue = function () {
+               var queryValue = this.query.getValue().trim();
+
+               if ( queryValue.match( this.externalLinkUrlProtocolsRegExp ) ) {
+                       queryValue = queryValue.match( /.+\/([^\/]+)/ )[ 1 ];
+               }
+               return queryValue;
+       };
+
+       /**
+        * Handle search value change
+        *
+        * @param {string} value New value
+        */
+       mw.widgets.MediaSearchWidget.prototype.onQueryChange = function () {
+               // Get the sanitized query value
+               var queryValue = this.getQueryValue();
+
+               if ( queryValue === this.lastQueryValue ) {
+                       return;
+               }
+
+               // Parent method
+               mw.widgets.MediaSearchWidget.super.prototype.onQueryChange.apply( this, arguments );
+
+               // Reset
+               this.itemCache = {};
+               this.currentItemCache = [];
+               this.resetRows();
+
+               // Empty the results queue
+               this.layoutQueue = [];
+
+               // Change resource queue query
+               this.searchQueue.setSearchQuery( queryValue );
+               this.lastQueryValue = queryValue;
+
+               // Queue
+               clearTimeout( this.queryTimeout );
+               this.queryTimeout = setTimeout( this.queryMediaQueue.bind( this ), 350 );
+       };
+
+       /**
+        * Handle results scroll events.
+        *
+        * @param {jQuery.Event} e Scroll event
+        */
+       mw.widgets.MediaSearchWidget.prototype.onResultsScroll = function () {
+               var position = this.$results.scrollTop() + this.$results.outerHeight(),
+                       threshold = this.results.$element.outerHeight() - this.rowHeight * 3;
+
+               // Check if we need to ask for more results
+               if ( !this.query.isPending() && position > threshold ) {
+                       this.queryMediaQueue();
+               }
+
+               this.lazyLoadResults();
+       };
+
+       /**
+        * Lazy-load the images that are visible.
+        */
+       mw.widgets.MediaSearchWidget.prototype.lazyLoadResults = function () {
+               var i, elementTop,
+                       items = this.results.getItems(),
+                       resultsScrollTop = this.$results.scrollTop(),
+                       position = resultsScrollTop + this.$results.outerHeight();
+
+               // Lazy-load results
+               for ( i = 0; i < items.length; i++ ) {
+                       elementTop = items[ i ].$element.position().top;
+                       if ( elementTop <= position && !items[ i ].hasSrc() ) {
+                               // Load the image
+                               items[ i ].lazyLoad();
+                       }
+               }
+       };
+
+       /**
+        * Reset all the rows; destroy the jQuery elements and reset
+        * the rows array.
+        */
+       mw.widgets.MediaSearchWidget.prototype.resetRows = function () {
+               var i, len;
+
+               for ( i = 0, len = this.rows.length; i < len; i++ ) {
+                       this.rows[ i ].$element.remove();
+               }
+
+               this.rows = [];
+               this.itemCache = {};
+       };
+
+       /**
+        * Find an available row at the end. Either we will need to create a new
+        * row or use the last available row if it isn't full.
+        *
+        * @return {number} Row index
+        */
+       mw.widgets.MediaSearchWidget.prototype.getAvailableRow = function () {
+               var row;
+
+               if ( this.rows.length === 0 ) {
+                       row = 0;
+               } else {
+                       row = this.rows.length - 1;
+               }
+
+               if ( !this.rows[ row ] ) {
+                       // Create new row
+                       this.rows[ row ] = {
+                               isFull: false,
+                               width: 0,
+                               items: [],
+                               $element: $( '<div>' )
+                                       .addClass( 'mw-widget-mediaResultWidget-row' )
+                                       .css( {
+                                               overflow: 'hidden'
+                                       } )
+                                       .data( 'row', row )
+                                       .attr( 'data-full', false )
+                       };
+                       // Append to results
+                       this.results.$element.append( this.rows[ row ].$element );
+               } else if ( this.rows[ row ].isFull ) {
+                       row++;
+                       // Create new row
+                       this.rows[ row ] = {
+                               isFull: false,
+                               width: 0,
+                               items: [],
+                               $element: $( '<div>' )
+                                       .addClass( 'mw-widget-mediaResultWidget-row' )
+                                       .css( {
+                                               overflow: 'hidden'
+                                       } )
+                                       .data( 'row', row )
+                                       .attr( 'data-full', false )
+                       };
+                       // Append to results
+                       this.results.$element.append( this.rows[ row ].$element );
+               }
+
+               return row;
+       };
+
+       /**
+        * Respond to add results event in the results widget.
+        * Override the way SelectWidget and GroupElement append the items
+        * into the group so we can append them in groups of rows.
+        *
+        * @param {mw.widgets.MediaResultWidget[]} items An array of item elements
+        */
+       mw.widgets.MediaSearchWidget.prototype.onResultsAdd = function ( items ) {
+               var search = this;
+
+               // Add method to a queue; this queue will only run when the widget
+               // is visible
+               this.layoutQueue.push( function () {
+                       var i, j, ilen, jlen, itemWidth, row, effectiveWidth,
+                               resizeFactor,
+                               maxRowWidth = search.results.$element.width() - 15;
+
+                       // Go over the added items
+                       row = search.getAvailableRow();
+                       for ( i = 0, ilen = items.length; i < ilen; i++ ) {
+                               itemWidth = items[ i ].$element.outerWidth( true );
+
+                               // Add items to row until it is full
+                               if ( search.rows[ row ].width + itemWidth >= maxRowWidth ) {
+                                       // Mark this row as full
+                                       search.rows[ row ].isFull = true;
+                                       search.rows[ row ].$element.attr( 'data-full', true );
+
+                                       // Find the resize factor
+                                       effectiveWidth = search.rows[ row ].width;
+                                       resizeFactor = maxRowWidth / effectiveWidth;
+
+                                       search.rows[ row ].$element.attr( 'data-effectiveWidth', effectiveWidth );
+                                       search.rows[ row ].$element.attr( 'data-resizeFactor', resizeFactor );
+                                       search.rows[ row ].$element.attr( 'data-row', row );
+
+                                       // Resize all images in the row to fit the width
+                                       for ( j = 0, jlen = search.rows[ row ].items.length; j < jlen; j++ ) {
+                                               search.rows[ row ].items[ j ].resizeThumb( resizeFactor );
+                                       }
+
+                                       // find another row
+                                       row = search.getAvailableRow();
+                               }
+
+                               // Add the cumulative
+                               search.rows[ row ].width += itemWidth;
+
+                               // Store reference to the item and to the row
+                               search.rows[ row ].items.push( items[ i ] );
+                               items[ i ].setRow( row );
+
+                               // Append the item
+                               search.rows[ row ].$element.append( items[ i ].$element );
+                       }
+
+                       // If we have less than 4 rows, call for more images
+                       if ( search.rows.length < 4 ) {
+                               search.queryMediaQueue();
+                       }
+               } );
+               this.runLayoutQueue();
+       };
+
+       /**
+        * Run layout methods from the queue only if the element is visible.
+        */
+       mw.widgets.MediaSearchWidget.prototype.runLayoutQueue = function () {
+               var i, len;
+
+               if ( this.$element.is( ':visible' ) ) {
+                       for ( i = 0, len = this.layoutQueue.length; i < len; i++ ) {
+                               this.layoutQueue.pop()();
+                       }
+               }
+       };
+
+       /**
+        * Respond to removing results event in the results widget.
+        * Clear the relevant rows.
+        *
+        * @param {OO.ui.OptionWidget[]} items Removed items
+        */
+       mw.widgets.MediaSearchWidget.prototype.onResultsRemove = function ( items ) {
+               if ( items.length > 0 ) {
+                       // In the case of the media search widget, if any items are removed
+                       // all are removed (new search)
+                       this.resetRows();
+                       this.currentItemCache = [];
+               }
+       };
+
+       /**
+        * Set language for the search results.
+        *
+        * @param {string} lang Language
+        */
+       mw.widgets.MediaSearchWidget.prototype.setLang = function ( lang ) {
+               this.lang = lang;
+       };
+
+       /**
+        * Get language for the search results.
+        *
+        * @return {string} lang Language
+        */
+       mw.widgets.MediaSearchWidget.prototype.getLang = function () {
+               return this.lang;
+       };
+}( jQuery, mediaWiki ) );