mediawiki-skins-Citizen/resources/scripts/wm-typeahead.js

592 lines
17 KiB
JavaScript
Raw Normal View History

2019-12-11 22:14:36 +00:00
/**
* Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master
* See T219590 for more details
*/
2019-12-26 09:40:43 +00:00
/**
* 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.
*
2019-12-26 09:40:43 +00:00
* @return {number} Device pixel ratio
*/
2019-12-26 09:40:43 +00:00
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 ) {
2019-12-26 09:40:43 +00:00
obj.attachedEvents.push( [ obj, evt, fn ] );
obj.attachEvent( 'on' + evt, fn );
}
}
2019-12-11 22:14:36 +00:00
/**
* 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');
*
*/
2019-12-26 09:40:43 +00:00
window.WMTypeAhead = function ( appendTo, searchInput ) {
2019-12-11 22:37:54 +00:00
2019-12-26 09:40:43 +00:00
let typeAheadID = 'typeahead-suggestions',
typeAheadEl = document.getElementById( typeAheadID ), // Type-ahead DOM element.
appendEl = document.getElementById( appendTo ),
searchEl = document.getElementById( searchInput ),
2019-12-31 04:29:10 +00:00
server = mw.config.get( 'wgServer' ),
2020-01-01 01:27:48 +00:00
articleurl = server + mw.config.get( 'wgArticlePath' ).replace( '$1', '' ),
2019-12-25 23:24:31 +00:00
thumbnailSize = getDevicePixelRatio() * 80,
descriptionSource = mw.config.get( 'wgCitizenSearchDescriptionSource' ),
2019-12-25 23:24:31 +00:00
maxSearchResults = mw.config.get( 'wgCitizenMaxSearchResults' ),
searchString,
typeAheadItems,
activeItem,
2019-12-31 00:45:24 +00:00
ssActiveIndex,
api = new mw.Api();
2019-12-25 23:24:31 +00:00
// Only create typeAheadEl once on page.
2019-12-26 09:40:43 +00:00
if ( !typeAheadEl ) {
typeAheadEl = document.createElement( 'div' );
2019-12-25 23:24:31 +00:00
typeAheadEl.id = typeAheadID;
2019-12-26 09:40:43 +00:00
appendEl.appendChild( typeAheadEl );
2019-12-25 23:24:31 +00:00
}
/**
* 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,
2019-12-26 09:40:43 +00:00
incrementIndex: function () {
2019-12-25 23:24:31 +00:00
this.index += 1;
return this.index;
},
2019-12-26 09:40:43 +00:00
addCallback: function ( func ) {
const index = this.incrementIndex();
this.queue[ index ] = func( index );
2019-12-25 23:24:31 +00:00
return index;
},
2019-12-26 09:40:43 +00:00
deleteSelfFromQueue: function ( i ) {
delete this.queue[ i ];
2019-12-25 23:24:31 +00:00
},
2019-12-26 09:40:43 +00:00
deletePrevCallbacks: function ( j ) {
let callback;
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
this.deleteSelfFromQueue( j );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
for ( callback in this.queue ) {
if ( callback < j ) {
this.queue[ callback ] = this.deleteSelfFromQueue.bind(
2019-12-25 23:24:31 +00:00
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,
2019-12-26 09:40:43 +00:00
setMax: function ( x ) {
2019-12-25 23:24:31 +00:00
this.max = x;
},
2019-12-26 09:40:43 +00:00
increment: function ( i ) {
2019-12-25 23:24:31 +00:00
this.index += i;
2019-12-26 09:40:43 +00:00
if ( this.index < 0 ) {
this.setIndex( this.max - 1 );
2019-12-25 23:24:31 +00:00
} // Index reaches top
2019-12-26 09:40:43 +00:00
if ( this.index === this.max ) {
this.setIndex( 0 );
2019-12-25 23:24:31 +00:00
} // Index reaches bottom
return this.index;
},
2019-12-26 09:40:43 +00:00
setIndex: function ( i ) {
if ( i <= this.max - 1 ) {
2019-12-25 23:24:31 +00:00
this.index = i;
}
return this.index;
},
2019-12-26 09:40:43 +00:00
clear: function () {
this.setIndex( -1 );
2019-12-25 23:24:31 +00:00
}
};
/**
* Removed the actual child nodes from typeAheadEl
* @see {typeAheadEl}
*/
function clearTypeAheadElements() {
2020-01-01 01:27:48 +00:00
if ( typeof typeAheadEl === 'undefined' ) {
return;
}
2020-01-01 01:27:48 +00:00
while ( typeAheadEl.firstChild !== null ) {
typeAheadEl.removeChild( typeAheadEl.firstChild );
}
}
2019-12-25 23:24:31 +00:00
/**
* 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() {
2019-12-26 09:40:43 +00:00
setTimeout( function () {
clearTypeAheadElements();
2019-12-25 23:24:31 +00:00
ssActiveIndex.clear();
2019-12-26 09:40:43 +00:00
}, 300 );
2019-12-25 23:24:31 +00:00
}
/**
* 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
*/
2019-12-26 09:40:43 +00:00
function forceLinkFollow( e ) {
const el = e.relatedTarget;
if ( el && /suggestion-link/.test( el.className ) ) {
2019-12-25 23:24:31 +00:00
window.location = el.href;
}
}
/**
* Card displayed while loading search results
2020-01-01 01:27:48 +00:00
* @return {string}
*/
function getLoadingIndicator() {
2020-01-01 01:27:48 +00:00
return '<div class="suggestions-dropdown">' +
'<div class="suggestion-link">' +
'<div class="suggestion-text suggestion-placeholder">' +
'<h3 class="suggestion-title"></h3>' +
'<p class="suggestion-description"></p>' +
'</div>' +
'<div class="suggestion-thumbnail"></div>' +
'</div>' +
'</div>';
}
/**
* Card displayed if no results could be found
2019-12-31 18:46:11 +00:00
* @param {string} searchString - The search string.
2020-01-01 01:27:48 +00:00
* @return {string}
*/
function getNoResultsIndicator( searchString ) {
2020-01-01 01:27:48 +00:00
const titlemsg = mw.message( 'citizen-search-no-results-title', searchString ).text(),
descmsg = mw.message( 'citizen-search-no-results-desc', searchString ).text();
return `
2020-01-06 19:28:08 +00:00
< 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 > `;
}
2019-12-25 23:24:31 +00:00
/**
* 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.
*/
2019-12-26 09:40:43 +00:00
function loadQueryScript( string ) {
let callbackIndex,
2019-12-25 23:24:31 +00:00
searchQuery;
// Variables declared in parent function.
2019-12-26 09:40:43 +00:00
searchString = encodeURIComponent( string );
if ( searchString.length === 0 ) {
2019-12-25 23:24:31 +00:00
clearTypeAhead();
return;
}
2019-12-26 09:40:43 +00:00
callbackIndex = window.callbackStack.addCallback( window.portalOpensearchCallback );
2019-12-25 23:24:31 +00:00
// Removed description prop
// TODO: Use text extract or PCS for description
searchQuery = {
generator: 'prefixsearch',
prop: 'pageprops|pageimages',
2019-12-25 23:24:31 +00:00
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: thumbnailSize,
pilimit: maxSearchResults,
gpssearch: string,
gpsnamespace: 0,
2020-01-01 01:27:48 +00:00
gpslimit: maxSearchResults
2019-12-25 23:24:31 +00:00
};
2020-01-06 19:32:03 +00:00
switch ( descriptionSource ) {
case 'wikidata':
2020-01-01 21:43:08 +00:00
searchQuery.prop += '|description';
break;
case 'textextracts':
2020-01-01 21:43:08 +00:00
searchQuery.prop += '|extracts';
searchQuery.exchars = '60';
searchQuery.exintro = '1';
searchQuery.exlimit = maxSearchResults;
searchQuery.explaintext = '1';
break;
case 'pagedescription':
2020-01-01 21:43:08 +00:00
searchQuery.prop += '|pageprops';
searchQuery.ppprop = 'description';
break;
}
typeAheadEl.innerHTML = getLoadingIndicator();
2019-12-25 23:24:31 +00:00
2020-01-01 01:27:48 +00:00
api.get( searchQuery )
.done( ( data ) => {
clearTypeAheadElements();
2020-01-01 01:27:48 +00:00
window.callbackStack.queue[ callbackIndex ]( data, string );
} );
} // END loadQueryScript
2019-12-25 23:24:31 +00:00
/**
* 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 <em> tag.
*/
2019-12-26 09:40:43 +00:00
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 ),
2019-12-25 23:24:31 +00:00
endHighlightIndex,
strong,
beforeHighlight,
aferHighlight;
2019-12-26 09:40:43 +00:00
if ( startHighlightIndex >= 0 ) {
2019-12-25 23:24:31 +00:00
endHighlightIndex = startHighlightIndex + sanitizedSearchString.length;
2019-12-26 09:40:43 +00:00
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;
2019-12-25 23:24:31 +00:00
}
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
*/
2019-12-26 09:40:43 +00:00
function generateTemplateString( suggestions ) {
let string = '<div class="suggestions-dropdown">',
2019-12-25 23:24:31 +00:00
suggestionLink,
suggestionThumbnail,
suggestionText,
suggestionTitle,
suggestionDescription,
page,
sanitizedThumbURL = false,
descriptionText = '',
pageDescription = '',
i;
2020-01-01 01:27:48 +00:00
if ( suggestions.length === 0 ) {
return getNoResultsIndicator( searchString );
}
2019-12-25 23:24:31 +00:00
for ( i = 0; i < suggestions.length; i++ ) {
2019-12-26 09:40:43 +00:00
if ( !suggestions[ i ] ) {
2019-12-25 23:24:31 +00:00
continue;
}
2019-12-26 09:40:43 +00:00
page = suggestions[ i ];
2020-01-06 19:32:03 +00:00
switch ( descriptionSource ) {
case 'wikidata':
pageDescription = page.description || '';
break;
case 'textextracts':
pageDescription = page.extract || '';
break;
case 'pagedescription':
pageDescription = page.pageprops.description || '';
break;
}
2019-12-25 23:24:31 +00:00
// Ensure that the value from the previous iteration isn't used
sanitizedThumbURL = false;
2019-12-26 09:40:43 +00:00
if ( page.thumbnail && page.thumbnail.source ) {
sanitizedThumbURL = page.thumbnail.source.replace( /"/g, '%22' );
sanitizedThumbURL = sanitizedThumbURL.replace( /'/g, '%27' );
2019-12-25 23:24:31 +00:00
}
// Ensure that the value from the previous iteration isn't used
descriptionText = '';
// Check if description exists
2019-12-26 09:40:43 +00:00
if ( pageDescription ) {
2019-12-25 23:24:31 +00:00
// If the description is an array, use the first item
2019-12-26 09:40:43 +00:00
if ( typeof pageDescription === 'object' && pageDescription[ 0 ] ) {
descriptionText = pageDescription[ 0 ].toString();
2019-12-25 23:24:31 +00:00
} else {
// Otherwise, use the description as is.
descriptionText = pageDescription.toString();
}
}
// Filter out no text from TextExtracts
2019-12-26 09:40:43 +00:00
if ( descriptionText === '...' ) {
2019-12-25 23:24:31 +00:00
descriptionText = '';
}
2019-12-26 09:40:43 +00:00
suggestionDescription = mw.html.element( 'p', { class: 'suggestion-description' }, descriptionText );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
suggestionTitle = mw.html.element( 'h3', { class: 'suggestion-title' }, new mw.html.Raw( highlightTitle( page.title, searchString ) ) );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
suggestionText = mw.html.element( 'div', { class: 'suggestion-text' }, new mw.html.Raw( suggestionTitle + suggestionDescription ) );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
suggestionThumbnail = mw.html.element( 'div', {
class: 'suggestion-thumbnail',
style: ( sanitizedThumbURL ) ? 'background-image:url(' + sanitizedThumbURL + ')' : false
}, '' );
2019-12-25 23:24:31 +00:00
// TODO: Make it configurable from the skin
2019-12-26 09:40:43 +00:00
suggestionLink = mw.html.element( 'a', {
class: 'suggestion-link',
2019-12-31 04:29:10 +00:00
href: articleurl + encodeURIComponent( page.title.replace( / /gi, '_' ) )
2019-12-26 09:40:43 +00:00
}, new mw.html.Raw( suggestionText + suggestionThumbnail ) );
2019-12-25 23:24:31 +00:00
string += suggestionLink;
}
string += '</div>';
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.
*/
2019-12-26 09:40:43 +00:00
function toggleActiveClass( item, collection ) {
let activeClass = ' active', // Prefixed with space.
2019-12-25 23:24:31 +00:00
colItem,
i;
2019-12-26 09:40:43 +00:00
for ( i = 0; i < collection.length; i++ ) {
colItem = collection[ i ];
2019-12-25 23:24:31 +00:00
// Remove the class name from everything except item.
2019-12-26 09:40:43 +00:00
if ( colItem !== item ) {
colItem.className = colItem.className.replace( activeClass, '' );
2019-12-25 23:24:31 +00:00
} else {
// If item has class name, remove it
2019-12-26 09:40:43 +00:00
if ( / active/.test( item.className ) ) {
item.className = item.className.replace( activeClass, '' );
2019-12-25 23:24:31 +00:00
} else {
// It item doesn't have class name, add it.
item.className += activeClass;
2019-12-26 09:40:43 +00:00
ssActiveIndex.setIndex( i );
2019-12-25 23:24:31 +00:00
}
}
}
}
/**
* 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}
*/
2019-12-26 09:40:43 +00:00
window.portalOpensearchCallback = function ( i ) {
let callbackIndex = i,
2019-12-25 23:24:31 +00:00
orderedResults = [],
suggestions,
item,
result,
templateDOMString,
listEl;
2020-01-01 01:27:48 +00:00
return function ( xhrResults, queryString ) {
2019-12-26 09:40:43 +00:00
window.callbackStack.deletePrevCallbacks( callbackIndex );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
if ( document.activeElement !== searchEl ) {
2019-12-25 23:24:31 +00:00
return;
}
2019-12-26 09:40:43 +00:00
suggestions = ( xhrResults.query && xhrResults.query.pages ) ?
2019-12-25 23:24:31 +00:00
xhrResults.query.pages : [];
2020-01-01 01:27:48 +00:00
if ( suggestions.length === 0 ) {
typeAheadEl.innerHTML = getNoResultsIndicator( queryString );
return;
}
2019-12-26 09:40:43 +00:00
for ( item in suggestions ) {
if ( Object.prototype.hasOwnProperty.call( suggestions, item ) ) {
result = suggestions[ item ];
orderedResults[ result.index - 1 ] = result;
}
2019-12-25 23:24:31 +00:00
}
2019-12-26 09:40:43 +00:00
templateDOMString = generateTemplateString( orderedResults );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
ssActiveIndex.setMax( orderedResults.length );
2019-12-25 23:24:31 +00:00
ssActiveIndex.clear();
typeAheadEl.innerHTML = templateDOMString;
2019-12-26 09:40:43 +00:00
typeAheadItems = typeAheadEl.childNodes[ 0 ].childNodes;
2019-12-25 23:24:31 +00:00
// Attaching hover events
2019-12-26 09:40:43 +00:00
for ( i = 0; i < typeAheadItems.length; i++ ) {
listEl = typeAheadItems[ i ];
2019-12-25 23:24:31 +00:00
// Requires the addEvent global polyfill
2019-12-26 09:40:43 +00:00
addEvent( listEl, 'mouseenter', toggleActiveClass.bind( this, listEl, typeAheadItems ) );
addEvent( listEl, 'mouseleave', toggleActiveClass.bind( this, listEl, typeAheadItems ) );
2019-12-25 23:24:31 +00:00
}
};
};
/**
* Keyboard events: up arrow, down arrow and enter.
* moves the 'active' suggestion up and down.
*
* @param {event} event
*/
2019-12-26 09:40:43 +00:00
function keyboardEvents( event ) {
let e = event || window.event,
2019-12-25 23:24:31 +00:00
keycode = e.which || e.keyCode,
suggestionItems,
searchSuggestionIndex;
2019-12-26 09:40:43 +00:00
if ( !typeAheadEl.firstChild ) {
2019-12-25 23:24:31 +00:00
return;
}
2019-12-26 09:40:43 +00:00
if ( keycode === 40 || keycode === 38 ) {
2019-12-25 23:24:31 +00:00
suggestionItems = typeAheadEl.firstChild.childNodes;
2019-12-26 09:40:43 +00:00
if ( keycode === 40 ) {
searchSuggestionIndex = ssActiveIndex.increment( 1 );
2019-12-25 23:24:31 +00:00
} else {
2019-12-26 09:40:43 +00:00
searchSuggestionIndex = ssActiveIndex.increment( -1 );
2019-12-25 23:24:31 +00:00
}
2019-12-26 09:40:43 +00:00
activeItem = ( suggestionItems ) ? suggestionItems[ searchSuggestionIndex ] : false;
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
toggleActiveClass( activeItem, suggestionItems );
2019-12-25 23:24:31 +00:00
}
2019-12-26 09:40:43 +00:00
if ( keycode === 13 && activeItem ) {
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
if ( e.preventDefault ) {
2019-12-25 23:24:31 +00:00
e.preventDefault();
} else {
2019-12-26 09:40:43 +00:00
( e.returnValue = false );
2019-12-25 23:24:31 +00:00
}
2019-12-26 09:40:43 +00:00
activeItem.children[ 0 ].click();
2019-12-25 23:24:31 +00:00
}
}
2019-12-26 09:40:43 +00:00
addEvent( searchEl, 'keydown', keyboardEvents );
2019-12-25 23:24:31 +00:00
2019-12-26 09:40:43 +00:00
addEvent( searchEl, 'blur', function ( e ) {
2019-12-25 23:24:31 +00:00
clearTypeAhead();
2019-12-26 09:40:43 +00:00
forceLinkFollow( e );
} );
2019-12-25 23:24:31 +00:00
return {
typeAheadEl: typeAheadEl,
query: loadQueryScript
};
2019-12-26 09:40:43 +00:00
};