/** * Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master * See T219590 for more details */ /** * Below are additional dependency extracted from polyfills.js * TODO: Optimize and clear unneeded code */ /** * Detects reported or approximate device pixel ratio. * * 1.0 means 1 CSS pixel is 1 hardware pixel * * 2.0 means 1 CSS pixel is 2 hardware pixels * * etc. * * Uses window.devicePixelRatio if available, or CSS media queries on IE. * * @return {number} Device pixel ratio */ function getDevicePixelRatio() { if ( window.devicePixelRatio !== undefined ) { // Most web browsers: // * WebKit (Safari, Chrome, Android browser, etc) // * Opera // * Firefox 18+ return window.devicePixelRatio; } else if ( window.msMatchMedia !== undefined ) { // Windows 8 desktops / tablets, probably Windows Phone 8 // // IE 10 doesn't report pixel ratio directly, but we can get the // screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for // simplicity, but you may get different values depending on zoom // factor, size of screen and orientation in Metro IE. if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) { return 2; } else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) { return 1.5; } else { return 1; } } else { // Legacy browsers... // Assume 1 if unknown. return 1; } } function addEvent( obj, evt, fn ) { if ( !obj ) { return; } if ( obj.addEventListener ) { obj.addEventListener( evt, fn, false ); } else if ( obj.attachEvent ) { obj.attachedEvents.push( [ obj, evt, fn ] ); obj.attachEvent( 'on' + evt, fn ); } } /** * WMTypeAhead. * Displays search suggestions with thumbnail and description * as user types into an input field. * * @constructor * @param {string} appendTo - ID of a container element that the suggestions will be appended to. * @param {string} searchInput - ID of a search input whose value will be used to generate * search suggestions. * * @return {Object} Returns an object with the following properties: * @return {HTMLElement} return.typeAheadEl The type-ahead DOM object. * @return {Function} return.query A function that loads the type-ahead suggestions. * * @example * var typeAhead = new WMTypeAhead('containerID', 'inputID'); * typeAhead.query('search string', 'en'); * */ window.WMTypeAhead = function ( appendTo, searchInput ) { let typeAheadID = 'typeahead-suggestions', typeAheadEl = document.getElementById( typeAheadID ), // Type-ahead DOM element. appendEl = document.getElementById( appendTo ), searchEl = document.getElementById( searchInput ), server = mw.config.get( 'wgServer' ), articleurl = server + mw.config.get( 'wgArticlePath' ).replace( '$1', '' ), thumbnailSize = getDevicePixelRatio() * 80, descriptionSource = mw.config.get( 'wgCitizenSearchDescriptionSource' ), maxSearchResults = mw.config.get( 'wgCitizenMaxSearchResults' ), searchString, typeAheadItems, activeItem, ssActiveIndex, api = new mw.Api(); // Only create typeAheadEl once on page. if ( !typeAheadEl ) { typeAheadEl = document.createElement( 'div' ); typeAheadEl.id = typeAheadID; appendEl.appendChild( typeAheadEl ); } /** * Keeps track of the search query callbacks. Consists of an array of * callback functions and an index that keeps track of the order of requests. * Callbacks are deleted by replacing the callback function with a no-op. */ window.callbackStack = { queue: {}, index: -1, incrementIndex: function () { this.index += 1; return this.index; }, addCallback: function ( func ) { const index = this.incrementIndex(); this.queue[ index ] = func( index ); return index; }, deleteSelfFromQueue: function ( i ) { delete this.queue[ i ]; }, deletePrevCallbacks: function ( j ) { let callback; this.deleteSelfFromQueue( j ); for ( callback in this.queue ) { if ( callback < j ) { this.queue[ callback ] = this.deleteSelfFromQueue.bind( window.callbackStack, callback ); } } } }; /** * Maintains the 'active' state on search suggestions. * Makes sure the 'active' element is synchronized between mouse and keyboard usage, * and cleared when new search suggestions appear. */ ssActiveIndex = { index: -1, max: maxSearchResults, setMax: function ( x ) { this.max = x; }, increment: function ( i ) { this.index += i; if ( this.index < 0 ) { this.setIndex( this.max - 1 ); } // Index reaches top if ( this.index === this.max ) { this.setIndex( 0 ); } // Index reaches bottom return this.index; }, setIndex: function ( i ) { if ( i <= this.max - 1 ) { this.index = i; } return this.index; }, clear: function () { this.setIndex( -1 ); } }; /** * Removed the actual child nodes from typeAheadEl * @see {typeAheadEl} */ function clearTypeAheadElements() { if ( typeof typeAheadEl === 'undefined' ) { return; } while ( typeAheadEl.firstChild !== null ) { typeAheadEl.removeChild( typeAheadEl.firstChild ); } } /** * Removes the type-ahead suggestions from the DOM. * Reason for timeout: The typeahead is set to clear on input blur. * When a user clicks on a search suggestion, they triggers the input blur * and remove the typeahead before a click event is registered. * The timeout makes it so a click on the search suggestion is registered before * an input blur. * 300ms is used to account for the click delay on mobile devices. * */ function clearTypeAhead() { setTimeout( function () { clearTypeAheadElements(); ssActiveIndex.clear(); }, 300 ); } /** * Manually redirects the page to the href of a given element. * * For Chrome on Android to solve T221628. * When search suggestions below the fold are clicked, the blur event * on the search input is triggered and the page scrolls the search input * into view. However, the originating click event does not redirect * the page. * * @param {Event} e */ function forceLinkFollow( e ) { const el = e.relatedTarget; if ( el && /suggestion-link/.test( el.className ) ) { window.location = el.href; } } /** * Card displayed while loading search results * @return {string} */ function getLoadingIndicator() { return '
' + '' + '
'; } /** * Card displayed if no results could be found * @param {string} searchString - The search string. * @return {string} */ function getNoResultsIndicator( searchString ) { const titlemsg = mw.message( 'citizen-search-no-results-title', searchString ).text(), descmsg = mw.message( 'citizen-search-no-results-desc', searchString ).text(); return ` < div class = "suggestions-dropdown" > < div class = "suggestion-link" > < div class = "suggestion-text" > < h3 class = "suggestion-title" > ` + titlemsg + ` < / h3 > < p class = "suggestion-description" > ` + descmsg + ` < / p > < / div > < div class = "suggestion-thumbnail" > < / div > < / div > < / div > `; } /** * Inserts script element containing the Search API results into document head. * The script itself calls the 'portalOpensearchCallback' callback function, * * @param {string} string - query string to search. * @param {string} lang - ISO code of language to search in. */ function loadQueryScript( string ) { let callbackIndex, searchQuery; // Variables declared in parent function. searchString = encodeURIComponent( string ); if ( searchString.length === 0 ) { clearTypeAhead(); return; } callbackIndex = window.callbackStack.addCallback( window.portalOpensearchCallback ); // Removed description prop // TODO: Use text extract or PCS for description searchQuery = { generator: 'prefixsearch', prop: 'pageprops|pageimages', redirects: '', ppprop: 'displaytitle', piprop: 'thumbnail', pithumbsize: thumbnailSize, pilimit: maxSearchResults, gpssearch: string, gpsnamespace: 0, gpslimit: maxSearchResults }; switch ( descriptionSource ) { case 'wikidata': searchQuery.prop += '|description'; break; case 'textextracts': searchQuery.prop += '|extracts'; searchQuery.exchars = '60'; searchQuery.exintro = '1'; searchQuery.exlimit = maxSearchResults; searchQuery.explaintext = '1'; break; case 'pagedescription': searchQuery.prop += '|pageprops'; searchQuery.ppprop = 'description'; break; } typeAheadEl.innerHTML = getLoadingIndicator(); api.get( searchQuery ) .done( ( data ) => { clearTypeAheadElements(); window.callbackStack.queue[ callbackIndex ]( data, string ); } ); } // END loadQueryScript /** * Highlights the part of the suggestion title that matches the search query. * Used inside the generateTemplateString function. * * @param {string} title - The title of the search suggestion. * @param {string} searchString - The string to highlight. * @return {string} The title with highlighted part in an tag. */ function highlightTitle( title, searchString ) { let sanitizedSearchString = mw.html.escape( mw.RegExp.escape( searchString ) ), searchRegex = new RegExp( sanitizedSearchString, 'i' ), startHighlightIndex = title.search( searchRegex ), formattedTitle = mw.html.escape( title ), endHighlightIndex, strong, beforeHighlight, aferHighlight; if ( startHighlightIndex >= 0 ) { endHighlightIndex = startHighlightIndex + sanitizedSearchString.length; strong = title.substring( startHighlightIndex, endHighlightIndex ); beforeHighlight = title.substring( 0, startHighlightIndex ); aferHighlight = title.substring( endHighlightIndex, title.length ); formattedTitle = beforeHighlight + mw.html.element( 'em', { class: 'suggestion-highlight' }, strong ) + aferHighlight; } return formattedTitle; } // END highlightTitle /** * Generates a template string based on an array of search suggestions. * * @param {Array} suggestions - An array of search suggestion results. * @return {string} A string representing the search suggestions DOM */ function generateTemplateString( suggestions ) { let string = '
', suggestionLink, suggestionThumbnail, suggestionText, suggestionTitle, suggestionDescription, page, sanitizedThumbURL = false, descriptionText = '', pageDescription = '', i; if ( suggestions.length === 0 ) { return getNoResultsIndicator( searchString ); } for ( i = 0; i < suggestions.length; i++ ) { if ( !suggestions[ i ] ) { continue; } page = suggestions[ i ]; switch ( descriptionSource ) { case 'wikidata': pageDescription = page.description || ''; break; case 'textextracts': pageDescription = page.extract || ''; break; case 'pagedescription': pageDescription = page.pageprops.description || ''; break; } // Ensure that the value from the previous iteration isn't used sanitizedThumbURL = false; if ( page.thumbnail && page.thumbnail.source ) { sanitizedThumbURL = page.thumbnail.source.replace( /"/g, '%22' ); sanitizedThumbURL = sanitizedThumbURL.replace( /'/g, '%27' ); } // Ensure that the value from the previous iteration isn't used descriptionText = ''; // Check if description exists if ( pageDescription ) { // If the description is an array, use the first item if ( typeof pageDescription === 'object' && pageDescription[ 0 ] ) { descriptionText = pageDescription[ 0 ].toString(); } else { // Otherwise, use the description as is. descriptionText = pageDescription.toString(); } } // Filter out no text from TextExtracts if ( descriptionText === '...' ) { descriptionText = ''; } suggestionDescription = mw.html.element( 'p', { class: 'suggestion-description' }, descriptionText ); suggestionTitle = mw.html.element( 'h3', { class: 'suggestion-title' }, new mw.html.Raw( highlightTitle( page.title, searchString ) ) ); suggestionText = mw.html.element( 'div', { class: 'suggestion-text' }, new mw.html.Raw( suggestionTitle + suggestionDescription ) ); suggestionThumbnail = mw.html.element( 'div', { class: 'suggestion-thumbnail', style: ( sanitizedThumbURL ) ? 'background-image:url(' + sanitizedThumbURL + ')' : false }, '' ); // TODO: Make it configurable from the skin suggestionLink = mw.html.element( 'a', { class: 'suggestion-link', href: articleurl + encodeURIComponent( page.title.replace( / /gi, '_' ) ) }, new mw.html.Raw( suggestionText + suggestionThumbnail ) ); string += suggestionLink; } string += '
'; return string; } // END generateTemplateString /** * - Removes 'active' class from a collection of elements. * - Adds 'active' class to an item if missing. * - Removes 'active' class from item if present. * * @param {HTMLElement} item Item to add active class to. * @param {NodeList} collection Sibling items. */ function toggleActiveClass( item, collection ) { let activeClass = ' active', // Prefixed with space. colItem, i; for ( i = 0; i < collection.length; i++ ) { colItem = collection[ i ]; // Remove the class name from everything except item. if ( colItem !== item ) { colItem.className = colItem.className.replace( activeClass, '' ); } else { // If item has class name, remove it if ( / active/.test( item.className ) ) { item.className = item.className.replace( activeClass, '' ); } else { // It item doesn't have class name, add it. item.className += activeClass; ssActiveIndex.setIndex( i ); } } } } /** * Search API callback. Returns a closure that holds the index of the request. * Deletes previous callbacks based on this index. This prevents callbacks for old * requests from executing. Then: * - parses the search results * - generates the template String * - inserts the template string into the DOM * - attaches event listeners on each suggestion item. * * @param {number} i * @return {Function} */ window.portalOpensearchCallback = function ( i ) { let callbackIndex = i, orderedResults = [], suggestions, item, result, templateDOMString, listEl; return function ( xhrResults, queryString ) { window.callbackStack.deletePrevCallbacks( callbackIndex ); if ( document.activeElement !== searchEl ) { return; } suggestions = ( xhrResults.query && xhrResults.query.pages ) ? xhrResults.query.pages : []; if ( suggestions.length === 0 ) { typeAheadEl.innerHTML = getNoResultsIndicator( queryString ); return; } for ( item in suggestions ) { if ( Object.prototype.hasOwnProperty.call( suggestions, item ) ) { result = suggestions[ item ]; orderedResults[ result.index - 1 ] = result; } } templateDOMString = generateTemplateString( orderedResults ); ssActiveIndex.setMax( orderedResults.length ); ssActiveIndex.clear(); typeAheadEl.innerHTML = templateDOMString; typeAheadItems = typeAheadEl.childNodes[ 0 ].childNodes; // Attaching hover events for ( i = 0; i < typeAheadItems.length; i++ ) { listEl = typeAheadItems[ i ]; // Requires the addEvent global polyfill addEvent( listEl, 'mouseenter', toggleActiveClass.bind( this, listEl, typeAheadItems ) ); addEvent( listEl, 'mouseleave', toggleActiveClass.bind( this, listEl, typeAheadItems ) ); } }; }; /** * Keyboard events: up arrow, down arrow and enter. * moves the 'active' suggestion up and down. * * @param {event} event */ function keyboardEvents( event ) { let e = event || window.event, keycode = e.which || e.keyCode, suggestionItems, searchSuggestionIndex; if ( !typeAheadEl.firstChild ) { return; } if ( keycode === 40 || keycode === 38 ) { suggestionItems = typeAheadEl.firstChild.childNodes; if ( keycode === 40 ) { searchSuggestionIndex = ssActiveIndex.increment( 1 ); } else { searchSuggestionIndex = ssActiveIndex.increment( -1 ); } activeItem = ( suggestionItems ) ? suggestionItems[ searchSuggestionIndex ] : false; toggleActiveClass( activeItem, suggestionItems ); } if ( keycode === 13 && activeItem ) { if ( e.preventDefault ) { e.preventDefault(); } else { ( e.returnValue = false ); } activeItem.children[ 0 ].click(); } } addEvent( searchEl, 'keydown', keyboardEvents ); addEvent( searchEl, 'blur', function ( e ) { clearTypeAhead(); forceLinkFollow( e ); } ); return { typeAheadEl: typeAheadEl, query: loadQueryScript }; };