Localisation updates from https://translatewiki.net.
[mediawiki.git] / resources / src / mediawiki.widgets / MediaSearch / mw.widgets.MediaSearchWidget.js
blob5cd706c811357219fc312cf521d63305741fe232
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 () {
9         /**
10          * @classdesc Media search widget.
11          *
12          * @class
13          * @extends OO.ui.SearchWidget
14          *
15          * @constructor
16          * @description Creates an mw.widgets.MediaSearchWidget object.
17          * @param {Object} [config] Configuration options
18          * @param {number} [size] Vertical size of thumbnails
19          */
20         mw.widgets.MediaSearchWidget = function MwWidgetsMediaSearchWidget( config ) {
21                 // Configuration initialization
22                 config = Object.assign( {
23                         placeholder: mw.msg( 'mw-widgets-mediasearch-input-placeholder' )
24                 }, config );
26                 // Parent constructor
27                 mw.widgets.MediaSearchWidget.super.call( this, config );
29                 // Properties
30                 this.providers = {};
31                 this.lastQueryValue = '';
33                 const queueConfig = {
34                         limit: this.constructor.static.limit,
35                         threshold: this.constructor.static.threshold
36                 };
37                 this.searchQueue = new mw.widgets.MediaSearchQueue( queueConfig );
38                 this.userUploadsQueue = new mw.widgets.MediaUserUploadsQueue( queueConfig );
39                 this.currentQueue = null;
41                 this.queryTimeout = null;
42                 this.itemCache = {};
43                 this.promises = [];
44                 this.$panels = config.$panels;
46                 this.externalLinkUrlProtocolsRegExp = new RegExp(
47                         '^(' + mw.config.get( 'wgUrlProtocols' ) + ')',
48                         'i'
49                 );
51                 // Masonry fit properties
52                 this.rows = [];
53                 this.rowHeight = config.rowHeight || 200;
54                 this.layoutQueue = [];
55                 this.numItems = 0;
56                 this.currentItemCache = [];
58                 this.resultsSize = {};
60                 this.selected = null;
62                 this.recentUploadsMessage = new OO.ui.LabelWidget( {
63                         label: mw.msg( 'mw-widgets-mediasearch-recent-uploads', mw.user ),
64                         classes: [ 'mw-widget-mediaSearchWidget-recentUploads' ]
65                 } );
66                 this.recentUploadsMessage.toggle( false );
67                 this.noItemsMessage = new OO.ui.LabelWidget( {
68                         label: mw.msg( 'mw-widgets-mediasearch-noresults' ),
69                         classes: [ 'mw-widget-mediaSearchWidget-noResults' ]
70                 } );
71                 this.noItemsMessage.toggle( false );
73                 // Events
74                 this.$results.on( 'scroll', this.onResultsScroll.bind( this ) );
75                 this.results.connect( this, {
76                         change: 'onResultsChange',
77                         remove: 'onResultsRemove'
78                 } );
80                 this.resizeHandler = OO.ui.debounce( this.afterResultsResize.bind( this ), 500 );
82                 // Initialization
83                 this.setLang( config.lang || 'en' );
84                 this.$results.prepend( this.recentUploadsMessage.$element, this.noItemsMessage.$element );
85                 this.$element.addClass( 'mw-widget-mediaSearchWidget' );
87                 this.query.$input.attr( 'aria-label', mw.msg( 'mw-widgets-mediasearch-input-placeholder' ) );
88                 this.results.$element.attr( 'aria-label', mw.msg( 'mw-widgets-mediasearch-results-aria-label' ) );
89         };
91         /* Inheritance */
93         OO.inheritClass( mw.widgets.MediaSearchWidget, OO.ui.SearchWidget );
95         /* Static properties */
97         mw.widgets.MediaSearchWidget.static.limit = 10;
99         mw.widgets.MediaSearchWidget.static.threshold = 5;
101         /* Methods */
103         /**
104          * Respond to window resize and check if the result display should
105          * be updated.
106          */
107         mw.widgets.MediaSearchWidget.prototype.afterResultsResize = function () {
108                 const items = this.currentItemCache;
110                 if (
111                         items.length > 0 &&
112                         (
113                                 this.resultsSize.width !== this.$results.width() ||
114                                 this.resultsSize.height !== this.$results.height()
115                         )
116                 ) {
117                         this.resetRows();
118                         this.itemCache = {};
119                         this.processQueueResults( items );
120                         if ( !this.results.isEmpty() ) {
121                                 this.lazyLoadResults();
122                         }
124                         // Cache the size
125                         this.resultsSize = {
126                                 width: this.$results.width(),
127                                 height: this.$results.height()
128                         };
129                 }
130         };
132         /**
133          * Teardown the widget; disconnect the window resize event.
134          */
135         mw.widgets.MediaSearchWidget.prototype.teardown = function () {
136                 $( window ).off( 'resize', this.resizeHandler );
137         };
139         /**
140          * Setup the widget; activate the resize event.
141          */
142         mw.widgets.MediaSearchWidget.prototype.setup = function () {
143                 $( window ).on( 'resize', this.resizeHandler );
144         };
146         /**
147          * Query all sources for media.
148          *
149          * @method
150          */
151         mw.widgets.MediaSearchWidget.prototype.queryMediaQueue = function () {
152                 const value = this.getQueryValue();
154                 if ( value === '' ) {
155                         if ( mw.user.isAnon() ) {
156                                 return;
157                         } else {
158                                 if ( this.currentQueue !== this.userUploadsQueue ) {
159                                         this.userUploadsQueue.reset();
160                                 }
161                                 this.currentQueue = this.userUploadsQueue;
162                                 // TODO: use cached results?
163                         }
164                 } else {
165                         this.currentQueue = this.searchQueue;
166                         this.currentQueue.setSearchQuery( value );
167                 }
169                 this.recentUploadsMessage.toggle( this.currentQueue === this.userUploadsQueue );
171                 this.query.pushPending();
172                 this.noItemsMessage.toggle( false );
174                 this.currentQueue.get( this.constructor.static.limit )
175                         .then( ( items ) => {
176                                 if ( items.length > 0 ) {
177                                         this.processQueueResults( items );
178                                         this.currentItemCache = this.currentItemCache.concat( items );
179                                 }
181                                 this.query.popPending();
182                                 this.noItemsMessage.toggle( this.results.isEmpty() );
183                                 if ( !this.results.isEmpty() ) {
184                                         this.lazyLoadResults();
185                                 }
187                         } );
188         };
190         /**
191          * Process the media queue giving more items.
192          *
193          * @method
194          * @param {Object[]} items Given items by the media queue
195          */
196         mw.widgets.MediaSearchWidget.prototype.processQueueResults = function ( items ) {
197                 const resultWidgets = [],
198                         inputSearchQuery = this.getQueryValue(),
199                         queueSearchQuery = this.searchQueue.getSearchQuery();
201                 if (
202                         this.currentQueue === this.searchQueue &&
203                         ( inputSearchQuery === '' || queueSearchQuery !== inputSearchQuery )
204                 ) {
205                         return;
206                 }
208                 for ( let i = 0, len = items.length; i < len; i++ ) {
209                         const title = new mw.Title( items[ i ].title ).getMainText();
210                         // Do not insert duplicates
211                         if ( !Object.prototype.hasOwnProperty.call( this.itemCache, title ) ) {
212                                 this.itemCache[ title ] = true;
213                                 resultWidgets.push(
214                                         new mw.widgets.MediaResultWidget( {
215                                                 data: items[ i ],
216                                                 rowHeight: this.rowHeight,
217                                                 maxWidth: this.results.$element.width() / 3,
218                                                 minWidth: 30,
219                                                 rowWidth: this.results.$element.width()
220                                         } )
221                                 );
222                         }
223                 }
224                 this.results.addItems( resultWidgets );
226         };
228         /**
229          * Get the sanitized query value from the input.
230          *
231          * @return {string} Query value
232          */
233         mw.widgets.MediaSearchWidget.prototype.getQueryValue = function () {
234                 let queryValue = this.query.getValue().trim();
236                 if ( queryValue.match( this.externalLinkUrlProtocolsRegExp ) ) {
237                         queryValue = queryValue.match( /.+\/([^/]+)/ )[ 1 ];
238                 }
239                 return queryValue;
240         };
242         /**
243          * Handle search value change.
244          *
245          * @param {string} value New value
246          */
247         mw.widgets.MediaSearchWidget.prototype.onQueryChange = function () {
248                 // Get the sanitized query value
249                 const queryValue = this.getQueryValue();
251                 if ( queryValue === this.lastQueryValue ) {
252                         return;
253                 }
255                 // Parent method
256                 mw.widgets.MediaSearchWidget.super.prototype.onQueryChange.apply( this, arguments );
258                 // Reset
259                 this.itemCache = {};
260                 this.currentItemCache = [];
261                 this.resetRows();
262                 this.recentUploadsMessage.toggle( false );
264                 // Empty the results queue
265                 this.layoutQueue = [];
267                 // Change resource queue query
268                 this.searchQueue.setSearchQuery( queryValue );
269                 this.lastQueryValue = queryValue;
271                 // Queue
272                 clearTimeout( this.queryTimeout );
273                 this.queryTimeout = setTimeout( this.queryMediaQueue.bind( this ), 350 );
274         };
276         /**
277          * Handle results scroll events.
278          *
279          * @param {jQuery.Event} e Scroll event
280          */
281         mw.widgets.MediaSearchWidget.prototype.onResultsScroll = function () {
282                 const position = this.$results.scrollTop() + this.$results.outerHeight(),
283                         threshold = this.results.$element.outerHeight() - this.rowHeight * 3;
285                 // Check if we need to ask for more results
286                 if ( !this.query.isPending() && position > threshold ) {
287                         this.queryMediaQueue();
288                 }
290                 this.lazyLoadResults();
291         };
293         /**
294          * Lazy-load the images that are visible.
295          */
296         mw.widgets.MediaSearchWidget.prototype.lazyLoadResults = function () {
297                 const items = this.results.getItems(),
298                         resultsScrollTop = this.$results.scrollTop(),
299                         position = resultsScrollTop + this.$results.outerHeight();
301                 // Lazy-load results
302                 for ( let i = 0; i < items.length; i++ ) {
303                         const elementTop = items[ i ].$element.position().top;
304                         if ( elementTop <= position && !items[ i ].hasSrc() ) {
305                                 // Load the image
306                                 items[ i ].lazyLoad();
307                         }
308                 }
309         };
311         /**
312          * Reset all the rows; destroy the jQuery elements and reset
313          * the rows array.
314          */
315         mw.widgets.MediaSearchWidget.prototype.resetRows = function () {
316                 for ( let i = 0, len = this.rows.length; i < len; i++ ) {
317                         this.rows[ i ].$element.remove();
318                 }
320                 this.rows = [];
321                 this.itemCache = {};
322         };
324         /**
325          * Find an available row at the end. Either we will need to create a new
326          * row or use the last available row if it isn't full.
327          *
328          * @return {number} Row index
329          */
330         mw.widgets.MediaSearchWidget.prototype.getAvailableRow = function () {
331                 let row;
333                 if ( this.rows.length === 0 ) {
334                         row = 0;
335                 } else {
336                         row = this.rows.length - 1;
337                 }
339                 if ( !this.rows[ row ] ) {
340                         // Create new row
341                         this.rows[ row ] = {
342                                 isFull: false,
343                                 width: 0,
344                                 items: [],
345                                 $element: $( '<div>' )
346                                         .addClass( 'mw-widget-mediaResultWidget-row' )
347                                         .css( {
348                                                 overflow: 'hidden'
349                                         } )
350                                         .data( 'row', row )
351                                         .attr( 'data-full', false )
352                         };
353                         // Append to results
354                         this.results.$element.append( this.rows[ row ].$element );
355                 } else if ( this.rows[ row ].isFull ) {
356                         row++;
357                         // Create new row
358                         this.rows[ row ] = {
359                                 isFull: false,
360                                 width: 0,
361                                 items: [],
362                                 $element: $( '<div>' )
363                                         .addClass( 'mw-widget-mediaResultWidget-row' )
364                                         .css( {
365                                                 overflow: 'hidden'
366                                         } )
367                                         .data( 'row', row )
368                                         .attr( 'data-full', false )
369                         };
370                         // Append to results
371                         this.results.$element.append( this.rows[ row ].$element );
372                 }
374                 return row;
375         };
377         /**
378          * Respond to change results event in the results widget.
379          * Override the way SelectWidget and GroupElement append the items
380          * into the group so we can append them in groups of rows.
381          *
382          * @param {mw.widgets.MediaResultWidget[]} items An array of item elements
383          */
384         mw.widgets.MediaSearchWidget.prototype.onResultsChange = function ( items ) {
385                 if ( !items.length ) {
386                         return;
387                 }
389                 // Add method to a queue; this queue will only run when the widget
390                 // is visible
391                 this.layoutQueue.push( () => {
392                         const maxRowWidth = this.results.$element.width() - 15;
394                         // Go over the added items
395                         let row = this.getAvailableRow();
396                         for ( let i = 0, ilen = items.length; i < ilen; i++ ) {
398                                 // Check item has just been added
399                                 if ( items[ i ].row !== null ) {
400                                         continue;
401                                 }
403                                 const itemWidth = items[ i ].$element.outerWidth( true );
405                                 // Add items to row until it is full
406                                 if ( this.rows[ row ].width + itemWidth >= maxRowWidth ) {
407                                         // Mark this row as full
408                                         this.rows[ row ].isFull = true;
409                                         this.rows[ row ].$element.attr( 'data-full', true );
411                                         // Find the resize factor
412                                         const effectiveWidth = this.rows[ row ].width;
413                                         const resizeFactor = maxRowWidth / effectiveWidth;
415                                         this.rows[ row ].$element.attr( 'data-effectiveWidth', effectiveWidth );
416                                         this.rows[ row ].$element.attr( 'data-resizeFactor', resizeFactor );
417                                         this.rows[ row ].$element.attr( 'data-row', row );
419                                         // Resize all images in the row to fit the width
420                                         for ( let j = 0, jlen = this.rows[ row ].items.length; j < jlen; j++ ) {
421                                                 this.rows[ row ].items[ j ].resizeThumb( resizeFactor );
422                                         }
424                                         // find another row
425                                         row = this.getAvailableRow();
426                                 }
428                                 // Add the cumulative
429                                 this.rows[ row ].width += itemWidth;
431                                 // Store reference to the item and to the row
432                                 this.rows[ row ].items.push( items[ i ] );
433                                 items[ i ].setRow( row );
435                                 // Append the item
436                                 this.rows[ row ].$element.append( items[ i ].$element );
438                         }
440                         // If we have less than 4 rows, call for more images
441                         if ( this.rows.length < 4 ) {
442                                 this.queryMediaQueue();
443                         }
444                 } );
445                 this.runLayoutQueue();
446         };
448         /**
449          * Run layout methods from the queue only if the element is visible.
450          */
451         mw.widgets.MediaSearchWidget.prototype.runLayoutQueue = function () {
452                 // eslint-disable-next-line no-jquery/no-sizzle
453                 if ( this.$element.is( ':visible' ) ) {
454                         for ( let i = 0, len = this.layoutQueue.length; i < len; i++ ) {
455                                 this.layoutQueue.pop()();
456                         }
457                 }
458         };
460         /**
461          * Respond to removing results event in the results widget.
462          * Clear the relevant rows.
463          *
464          * @param {OO.ui.OptionWidget[]} items Removed items
465          */
466         mw.widgets.MediaSearchWidget.prototype.onResultsRemove = function ( items ) {
467                 if ( items.length > 0 ) {
468                         // In the case of the media search widget, if any items are removed
469                         // all are removed (new search)
470                         this.resetRows();
471                         this.currentItemCache = [];
472                 }
473         };
475         /**
476          * Set language for the search results.
477          *
478          * @param {string} lang Language
479          */
480         mw.widgets.MediaSearchWidget.prototype.setLang = function ( lang ) {
481                 this.lang = lang;
482                 this.searchQueue.setLang( lang );
483         };
485         /**
486          * Get language for the search results.
487          *
488          * @return {string} lang Language
489          */
490         mw.widgets.MediaSearchWidget.prototype.getLang = function () {
491                 return this.lang;
492         };
493 }() );