Merge "Update docs/hooks.txt for ShowSearchHitTitle"
[mediawiki.git] / resources / src / mediawiki.widgets / MediaSearch / mw.widgets.MediaSearchWidget.js
blobc6938e874eaf3cb4f4ff4d9515b6d5c7756df45a
1 /*!
2  * MediaWiki Widgets - MediaSearchWidget class.
3  *
4  * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
5  * @license The MIT License (MIT); see LICENSE.txt
6  */
7 ( function ( $, mw ) {
9         /**
10          * Creates an mw.widgets.MediaSearchWidget object.
11          *
12          * @class
13          * @extends OO.ui.SearchWidget
14          *
15          * @constructor
16          * @param {Object} [config] Configuration options
17          * @param {number} [size] Vertical size of thumbnails
18          */
19         mw.widgets.MediaSearchWidget = function MwWidgetsMediaSearchWidget( config ) {
20                 // Configuration initialization
21                 config = $.extend( {
22                         placeholder: mw.msg( 'mw-widgets-mediasearch-input-placeholder' )
23                 }, config );
25                 // Parent constructor
26                 mw.widgets.MediaSearchWidget.super.call( this, config );
28                 // Properties
29                 this.providers = {};
30                 this.lastQueryValue = '';
31                 this.searchQueue = new mw.widgets.MediaSearchQueue( {
32                         limit: this.constructor.static.limit,
33                         threshold: this.constructor.static.threshold
34                 } );
36                 this.queryTimeout = null;
37                 this.itemCache = {};
38                 this.promises = [];
39                 this.lang = config.lang || 'en';
40                 this.$panels = config.$panels;
42                 this.externalLinkUrlProtocolsRegExp = new RegExp(
43                         '^(' + mw.config.get( 'wgUrlProtocols' ) + ')',
44                         'i'
45                 );
47                 // Masonry fit properties
48                 this.rows = [];
49                 this.rowHeight = config.rowHeight || 200;
50                 this.layoutQueue = [];
51                 this.numItems = 0;
52                 this.currentItemCache = [];
54                 this.resultsSize = {};
56                 this.selected = null;
58                 this.noItemsMessage = new OO.ui.LabelWidget( {
59                         label: mw.msg( 'mw-widgets-mediasearch-noresults' ),
60                         classes: [ 'mw-widget-mediaSearchWidget-noresults' ]
61                 } );
62                 this.noItemsMessage.toggle( false );
64                 // Events
65                 this.$results.on( 'scroll', this.onResultsScroll.bind( this ) );
66                 this.$query.append( this.noItemsMessage.$element );
67                 this.results.connect( this, {
68                         add: 'onResultsAdd',
69                         remove: 'onResultsRemove'
70                 } );
72                 this.resizeHandler = OO.ui.debounce( this.afterResultsResize.bind( this ), 500 );
74                 // Initialization
75                 this.$element.addClass( 'mw-widget-mediaSearchWidget' );
76         };
78         /* Inheritance */
80         OO.inheritClass( mw.widgets.MediaSearchWidget, OO.ui.SearchWidget );
82         /* Static properties */
84         mw.widgets.MediaSearchWidget.static.limit = 10;
86         mw.widgets.MediaSearchWidget.static.threshold = 5;
88         /* Methods */
90         /**
91          * Respond to window resize and check if the result display should
92          * be updated.
93          */
94         mw.widgets.MediaSearchWidget.prototype.afterResultsResize = function () {
95                 var items = this.currentItemCache;
97                 if (
98                         items.length > 0 &&
99                         (
100                                 this.resultsSize.width !== this.$results.width() ||
101                                 this.resultsSize.height !== this.$results.height()
102                         )
103                 ) {
104                         this.resetRows();
105                         this.itemCache = {};
106                         this.processQueueResults( items );
107                         if ( this.results.getItems().length > 0 ) {
108                                 this.lazyLoadResults();
109                         }
111                         // Cache the size
112                         this.resultsSize = {
113                                 width: this.$results.width(),
114                                 height: this.$results.height()
115                         };
116                 }
117         };
119         /**
120          * Teardown the widget; disconnect the window resize event.
121          */
122         mw.widgets.MediaSearchWidget.prototype.teardown = function () {
123                 $( window ).off( 'resize', this.resizeHandler );
124         };
126         /**
127          * Setup the widget; activate the resize event.
128          */
129         mw.widgets.MediaSearchWidget.prototype.setup = function () {
130                 $( window ).on( 'resize', this.resizeHandler );
131         };
133         /**
134          * Query all sources for media.
135          *
136          * @method
137          */
138         mw.widgets.MediaSearchWidget.prototype.queryMediaQueue = function () {
139                 var search = this,
140                         value = this.getQueryValue();
142                 if ( value === '' ) {
143                         return;
144                 }
146                 this.query.pushPending();
147                 search.noItemsMessage.toggle( false );
149                 this.searchQueue.setSearchQuery( value );
150                 this.searchQueue.get( this.constructor.static.limit )
151                         .then( function ( items ) {
152                                 if ( items.length > 0 ) {
153                                         search.processQueueResults( items );
154                                         search.currentItemCache = search.currentItemCache.concat( items );
155                                 }
157                                 search.query.popPending();
158                                 search.noItemsMessage.toggle( search.results.getItems().length === 0 );
159                                 if ( search.results.getItems().length > 0 ) {
160                                         search.lazyLoadResults();
161                                 }
163                         } );
164         };
166         /**
167          * Process the media queue giving more items
168          *
169          * @method
170          * @param {Object[]} items Given items by the media queue
171          */
172         mw.widgets.MediaSearchWidget.prototype.processQueueResults = function ( items ) {
173                 var i, len, title,
174                         resultWidgets = [],
175                         inputSearchQuery = this.getQueryValue(),
176                         queueSearchQuery = this.searchQueue.getSearchQuery();
178                 if ( inputSearchQuery === '' || queueSearchQuery !== inputSearchQuery ) {
179                         return;
180                 }
182                 for ( i = 0, len = items.length; i < len; i++ ) {
183                         title = new mw.Title( items[ i ].title ).getMainText();
184                         // Do not insert duplicates
185                         if ( !Object.prototype.hasOwnProperty.call( this.itemCache, title ) ) {
186                                 this.itemCache[ title ] = true;
187                                 resultWidgets.push(
188                                         new mw.widgets.MediaResultWidget( {
189                                                 data: items[ i ],
190                                                 rowHeight: this.rowHeight,
191                                                 maxWidth: this.results.$element.width() / 3,
192                                                 minWidth: 30,
193                                                 rowWidth: this.results.$element.width()
194                                         } )
195                                 );
196                         }
197                 }
198                 this.results.addItems( resultWidgets );
200         };
202         /**
203          * Get the sanitized query value from the input
204          *
205          * @return {string} Query value
206          */
207         mw.widgets.MediaSearchWidget.prototype.getQueryValue = function () {
208                 var queryValue = this.query.getValue().trim();
210                 if ( queryValue.match( this.externalLinkUrlProtocolsRegExp ) ) {
211                         queryValue = queryValue.match( /.+\/([^\/]+)/ )[ 1 ];
212                 }
213                 return queryValue;
214         };
216         /**
217          * Handle search value change
218          *
219          * @param {string} value New value
220          */
221         mw.widgets.MediaSearchWidget.prototype.onQueryChange = function () {
222                 // Get the sanitized query value
223                 var queryValue = this.getQueryValue();
225                 if ( queryValue === this.lastQueryValue ) {
226                         return;
227                 }
229                 // Parent method
230                 mw.widgets.MediaSearchWidget.super.prototype.onQueryChange.apply( this, arguments );
232                 // Reset
233                 this.itemCache = {};
234                 this.currentItemCache = [];
235                 this.resetRows();
237                 // Empty the results queue
238                 this.layoutQueue = [];
240                 // Change resource queue query
241                 this.searchQueue.setSearchQuery( queryValue );
242                 this.lastQueryValue = queryValue;
244                 // Queue
245                 clearTimeout( this.queryTimeout );
246                 this.queryTimeout = setTimeout( this.queryMediaQueue.bind( this ), 350 );
247         };
249         /**
250          * Handle results scroll events.
251          *
252          * @param {jQuery.Event} e Scroll event
253          */
254         mw.widgets.MediaSearchWidget.prototype.onResultsScroll = function () {
255                 var position = this.$results.scrollTop() + this.$results.outerHeight(),
256                         threshold = this.results.$element.outerHeight() - this.rowHeight * 3;
258                 // Check if we need to ask for more results
259                 if ( !this.query.isPending() && position > threshold ) {
260                         this.queryMediaQueue();
261                 }
263                 this.lazyLoadResults();
264         };
266         /**
267          * Lazy-load the images that are visible.
268          */
269         mw.widgets.MediaSearchWidget.prototype.lazyLoadResults = function () {
270                 var i, elementTop,
271                         items = this.results.getItems(),
272                         resultsScrollTop = this.$results.scrollTop(),
273                         position = resultsScrollTop + this.$results.outerHeight();
275                 // Lazy-load results
276                 for ( i = 0; i < items.length; i++ ) {
277                         elementTop = items[ i ].$element.position().top;
278                         if ( elementTop <= position && !items[ i ].hasSrc() ) {
279                                 // Load the image
280                                 items[ i ].lazyLoad();
281                         }
282                 }
283         };
285         /**
286          * Reset all the rows; destroy the jQuery elements and reset
287          * the rows array.
288          */
289         mw.widgets.MediaSearchWidget.prototype.resetRows = function () {
290                 var i, len;
292                 for ( i = 0, len = this.rows.length; i < len; i++ ) {
293                         this.rows[ i ].$element.remove();
294                 }
296                 this.rows = [];
297                 this.itemCache = {};
298         };
300         /**
301          * Find an available row at the end. Either we will need to create a new
302          * row or use the last available row if it isn't full.
303          *
304          * @return {number} Row index
305          */
306         mw.widgets.MediaSearchWidget.prototype.getAvailableRow = function () {
307                 var row;
309                 if ( this.rows.length === 0 ) {
310                         row = 0;
311                 } else {
312                         row = this.rows.length - 1;
313                 }
315                 if ( !this.rows[ row ] ) {
316                         // Create new row
317                         this.rows[ row ] = {
318                                 isFull: false,
319                                 width: 0,
320                                 items: [],
321                                 $element: $( '<div>' )
322                                         .addClass( 'mw-widget-mediaResultWidget-row' )
323                                         .css( {
324                                                 overflow: 'hidden'
325                                         } )
326                                         .data( 'row', row )
327                                         .attr( 'data-full', false )
328                         };
329                         // Append to results
330                         this.results.$element.append( this.rows[ row ].$element );
331                 } else if ( this.rows[ row ].isFull ) {
332                         row++;
333                         // Create new row
334                         this.rows[ row ] = {
335                                 isFull: false,
336                                 width: 0,
337                                 items: [],
338                                 $element: $( '<div>' )
339                                         .addClass( 'mw-widget-mediaResultWidget-row' )
340                                         .css( {
341                                                 overflow: 'hidden'
342                                         } )
343                                         .data( 'row', row )
344                                         .attr( 'data-full', false )
345                         };
346                         // Append to results
347                         this.results.$element.append( this.rows[ row ].$element );
348                 }
350                 return row;
351         };
353         /**
354          * Respond to add results event in the results widget.
355          * Override the way SelectWidget and GroupElement append the items
356          * into the group so we can append them in groups of rows.
357          *
358          * @param {mw.widgets.MediaResultWidget[]} items An array of item elements
359          */
360         mw.widgets.MediaSearchWidget.prototype.onResultsAdd = function ( items ) {
361                 var search = this;
363                 // Add method to a queue; this queue will only run when the widget
364                 // is visible
365                 this.layoutQueue.push( function () {
366                         var i, j, ilen, jlen, itemWidth, row, effectiveWidth,
367                                 resizeFactor,
368                                 maxRowWidth = search.results.$element.width() - 15;
370                         // Go over the added items
371                         row = search.getAvailableRow();
372                         for ( i = 0, ilen = items.length; i < ilen; i++ ) {
373                                 itemWidth = items[ i ].$element.outerWidth( true );
375                                 // Add items to row until it is full
376                                 if ( search.rows[ row ].width + itemWidth >= maxRowWidth ) {
377                                         // Mark this row as full
378                                         search.rows[ row ].isFull = true;
379                                         search.rows[ row ].$element.attr( 'data-full', true );
381                                         // Find the resize factor
382                                         effectiveWidth = search.rows[ row ].width;
383                                         resizeFactor = maxRowWidth / effectiveWidth;
385                                         search.rows[ row ].$element.attr( 'data-effectiveWidth', effectiveWidth );
386                                         search.rows[ row ].$element.attr( 'data-resizeFactor', resizeFactor );
387                                         search.rows[ row ].$element.attr( 'data-row', row );
389                                         // Resize all images in the row to fit the width
390                                         for ( j = 0, jlen = search.rows[ row ].items.length; j < jlen; j++ ) {
391                                                 search.rows[ row ].items[ j ].resizeThumb( resizeFactor );
392                                         }
394                                         // find another row
395                                         row = search.getAvailableRow();
396                                 }
398                                 // Add the cumulative
399                                 search.rows[ row ].width += itemWidth;
401                                 // Store reference to the item and to the row
402                                 search.rows[ row ].items.push( items[ i ] );
403                                 items[ i ].setRow( row );
405                                 // Append the item
406                                 search.rows[ row ].$element.append( items[ i ].$element );
407                         }
409                         // If we have less than 4 rows, call for more images
410                         if ( search.rows.length < 4 ) {
411                                 search.queryMediaQueue();
412                         }
413                 } );
414                 this.runLayoutQueue();
415         };
417         /**
418          * Run layout methods from the queue only if the element is visible.
419          */
420         mw.widgets.MediaSearchWidget.prototype.runLayoutQueue = function () {
421                 var i, len;
423                 if ( this.$element.is( ':visible' ) ) {
424                         for ( i = 0, len = this.layoutQueue.length; i < len; i++ ) {
425                                 this.layoutQueue.pop()();
426                         }
427                 }
428         };
430         /**
431          * Respond to removing results event in the results widget.
432          * Clear the relevant rows.
433          *
434          * @param {OO.ui.OptionWidget[]} items Removed items
435          */
436         mw.widgets.MediaSearchWidget.prototype.onResultsRemove = function ( items ) {
437                 if ( items.length > 0 ) {
438                         // In the case of the media search widget, if any items are removed
439                         // all are removed (new search)
440                         this.resetRows();
441                         this.currentItemCache = [];
442                 }
443         };
445         /**
446          * Set language for the search results.
447          *
448          * @param {string} lang Language
449          */
450         mw.widgets.MediaSearchWidget.prototype.setLang = function ( lang ) {
451                 this.lang = lang;
452         };
454         /**
455          * Get language for the search results.
456          *
457          * @return {string} lang Language
458          */
459         mw.widgets.MediaSearchWidget.prototype.getLang = function () {
460                 return this.lang;
461         };
462 }( jQuery, mediaWiki ) );