2 * MediaWiki Widgets - MediaSearchWidget class.
4 * @copyright 2011-2016 VisualEditor Team and others; see AUTHORS.txt
5 * @license The MIT License (MIT); see LICENSE.txt
10 * @classdesc Media search widget.
13 * @extends OO.ui.SearchWidget
16 * @description Creates an mw.widgets.MediaSearchWidget object.
17 * @param {Object} [config] Configuration options
18 * @param {number} [size] Vertical size of thumbnails
20 mw.widgets.MediaSearchWidget = function MwWidgetsMediaSearchWidget( config ) {
21 // Configuration initialization
22 config = Object.assign( {
23 placeholder: mw.msg( 'mw-widgets-mediasearch-input-placeholder' )
27 mw.widgets.MediaSearchWidget.super.call( this, config );
31 this.lastQueryValue = '';
34 limit: this.constructor.static.limit,
35 threshold: this.constructor.static.threshold
37 this.searchQueue = new mw.widgets.MediaSearchQueue( queueConfig );
38 this.userUploadsQueue = new mw.widgets.MediaUserUploadsQueue( queueConfig );
39 this.currentQueue = null;
41 this.queryTimeout = null;
44 this.$panels = config.$panels;
46 this.externalLinkUrlProtocolsRegExp = new RegExp(
47 '^(' + mw.config.get( 'wgUrlProtocols' ) + ')',
51 // Masonry fit properties
53 this.rowHeight = config.rowHeight || 200;
54 this.layoutQueue = [];
56 this.currentItemCache = [];
58 this.resultsSize = {};
62 this.recentUploadsMessage = new OO.ui.LabelWidget( {
63 label: mw.msg( 'mw-widgets-mediasearch-recent-uploads', mw.user ),
64 classes: [ 'mw-widget-mediaSearchWidget-recentUploads' ]
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' ]
71 this.noItemsMessage.toggle( false );
74 this.$results.on( 'scroll', this.onResultsScroll.bind( this ) );
75 this.results.connect( this, {
76 change: 'onResultsChange',
77 remove: 'onResultsRemove'
80 this.resizeHandler = OO.ui.debounce( this.afterResultsResize.bind( this ), 500 );
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' ) );
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;
104 * Respond to window resize and check if the result display should
107 mw.widgets.MediaSearchWidget.prototype.afterResultsResize = function () {
108 const items = this.currentItemCache;
113 this.resultsSize.width !== this.$results.width() ||
114 this.resultsSize.height !== this.$results.height()
119 this.processQueueResults( items );
120 if ( !this.results.isEmpty() ) {
121 this.lazyLoadResults();
126 width: this.$results.width(),
127 height: this.$results.height()
133 * Teardown the widget; disconnect the window resize event.
135 mw.widgets.MediaSearchWidget.prototype.teardown = function () {
136 $( window ).off( 'resize', this.resizeHandler );
140 * Setup the widget; activate the resize event.
142 mw.widgets.MediaSearchWidget.prototype.setup = function () {
143 $( window ).on( 'resize', this.resizeHandler );
147 * Query all sources for media.
151 mw.widgets.MediaSearchWidget.prototype.queryMediaQueue = function () {
152 const value = this.getQueryValue();
154 if ( value === '' ) {
155 if ( mw.user.isAnon() ) {
158 if ( this.currentQueue !== this.userUploadsQueue ) {
159 this.userUploadsQueue.reset();
161 this.currentQueue = this.userUploadsQueue;
162 // TODO: use cached results?
165 this.currentQueue = this.searchQueue;
166 this.currentQueue.setSearchQuery( value );
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 );
181 this.query.popPending();
182 this.noItemsMessage.toggle( this.results.isEmpty() );
183 if ( !this.results.isEmpty() ) {
184 this.lazyLoadResults();
191 * Process the media queue giving more items.
194 * @param {Object[]} items Given items by the media queue
196 mw.widgets.MediaSearchWidget.prototype.processQueueResults = function ( items ) {
197 const resultWidgets = [],
198 inputSearchQuery = this.getQueryValue(),
199 queueSearchQuery = this.searchQueue.getSearchQuery();
202 this.currentQueue === this.searchQueue &&
203 ( inputSearchQuery === '' || queueSearchQuery !== inputSearchQuery )
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;
214 new mw.widgets.MediaResultWidget( {
216 rowHeight: this.rowHeight,
217 maxWidth: this.results.$element.width() / 3,
219 rowWidth: this.results.$element.width()
224 this.results.addItems( resultWidgets );
229 * Get the sanitized query value from the input.
231 * @return {string} Query value
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 ];
243 * Handle search value change.
245 * @param {string} value New value
247 mw.widgets.MediaSearchWidget.prototype.onQueryChange = function () {
248 // Get the sanitized query value
249 const queryValue = this.getQueryValue();
251 if ( queryValue === this.lastQueryValue ) {
256 mw.widgets.MediaSearchWidget.super.prototype.onQueryChange.apply( this, arguments );
260 this.currentItemCache = [];
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;
272 clearTimeout( this.queryTimeout );
273 this.queryTimeout = setTimeout( this.queryMediaQueue.bind( this ), 350 );
277 * Handle results scroll events.
279 * @param {jQuery.Event} e Scroll event
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();
290 this.lazyLoadResults();
294 * Lazy-load the images that are visible.
296 mw.widgets.MediaSearchWidget.prototype.lazyLoadResults = function () {
297 const items = this.results.getItems(),
298 resultsScrollTop = this.$results.scrollTop(),
299 position = resultsScrollTop + this.$results.outerHeight();
302 for ( let i = 0; i < items.length; i++ ) {
303 const elementTop = items[ i ].$element.position().top;
304 if ( elementTop <= position && !items[ i ].hasSrc() ) {
306 items[ i ].lazyLoad();
312 * Reset all the rows; destroy the jQuery elements and reset
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();
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.
328 * @return {number} Row index
330 mw.widgets.MediaSearchWidget.prototype.getAvailableRow = function () {
333 if ( this.rows.length === 0 ) {
336 row = this.rows.length - 1;
339 if ( !this.rows[ row ] ) {
345 $element: $( '<div>' )
346 .addClass( 'mw-widget-mediaResultWidget-row' )
351 .attr( 'data-full', false )
354 this.results.$element.append( this.rows[ row ].$element );
355 } else if ( this.rows[ row ].isFull ) {
362 $element: $( '<div>' )
363 .addClass( 'mw-widget-mediaResultWidget-row' )
368 .attr( 'data-full', false )
371 this.results.$element.append( this.rows[ row ].$element );
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.
382 * @param {mw.widgets.MediaResultWidget[]} items An array of item elements
384 mw.widgets.MediaSearchWidget.prototype.onResultsChange = function ( items ) {
385 if ( !items.length ) {
389 // Add method to a queue; this queue will only run when the widget
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 ) {
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 );
425 row = this.getAvailableRow();
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 );
436 this.rows[ row ].$element.append( items[ i ].$element );
440 // If we have less than 4 rows, call for more images
441 if ( this.rows.length < 4 ) {
442 this.queryMediaQueue();
445 this.runLayoutQueue();
449 * Run layout methods from the queue only if the element is visible.
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()();
461 * Respond to removing results event in the results widget.
462 * Clear the relevant rows.
464 * @param {OO.ui.OptionWidget[]} items Removed items
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)
471 this.currentItemCache = [];
476 * Set language for the search results.
478 * @param {string} lang Language
480 mw.widgets.MediaSearchWidget.prototype.setLang = function ( lang ) {
482 this.searchQueue.setLang( lang );
486 * Get language for the search results.
488 * @return {string} lang Language
490 mw.widgets.MediaSearchWidget.prototype.getLang = function () {