From 34ff3660f362a7b2a65a37a8c5ca30db09bd1264 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Wed, 11 Dec 2019 17:14:36 -0500 Subject: [PATCH] Experimential search suggestions --- includes/CitizenTemplate.php | 2 + includes/SkinCitizen.php | 3 +- resources/scripts/typeahead-init.js | 32 ++ resources/scripts/wm-typeahead.js | 480 ++++++++++++++++++++++++++++ skin.json | 6 + 5 files changed, 522 insertions(+), 1 deletion(-) create mode 100644 resources/scripts/typeahead-init.js create mode 100644 resources/scripts/wm-typeahead.js diff --git a/includes/CitizenTemplate.php b/includes/CitizenTemplate.php index 99d4b8ee..bb8dc954 100644 --- a/includes/CitizenTemplate.php +++ b/includes/CitizenTemplate.php @@ -280,6 +280,8 @@ class CitizenTemplate extends BaseTemplate { /** * Generates the search form + * In order to use the old opensearch, change search-input to searchInput + * See T219590 for more details * @return string html */ protected function getSearch() { diff --git a/includes/SkinCitizen.php b/includes/SkinCitizen.php index a9364f5c..765c71b8 100644 --- a/includes/SkinCitizen.php +++ b/includes/SkinCitizen.php @@ -67,7 +67,8 @@ class SkinCitizen extends SkinTemplate { 'skins.citizen.icons.badges' ] ); $out->addModules( [ - 'skins.citizen.js' + 'skins.citizen.js', + 'skins.citizen.search' ] ); } diff --git a/resources/scripts/typeahead-init.js b/resources/scripts/typeahead-init.js new file mode 100644 index 00000000..52dcf0bf --- /dev/null +++ b/resources/scripts/typeahead-init.js @@ -0,0 +1,32 @@ +/** + * Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master + * See T219590 for more details + */ + +/* global wmTest, WMTypeAhead, _, addEvent */ + +( function ( wmTest, WMTypeAhead ) { + + var inputEvent, + searchInput = document.getElementById( 'search-input' ), + typeAhead = new WMTypeAhead( 'search-form', 'searchInput' ); + + /** + * Testing for 'input' event and falling back to 'propertychange' event for IE. + */ + if ( 'oninput' in document ) { + inputEvent = 'input'; + } else { + inputEvent = 'propertychange'; + } + + /** + * Attaching type-ahead query action to 'input' event. + */ + /* + addEvent( searchInput, inputEvent, _.debounce( function () { + typeAhead.query( searchInput.value, document.getElementById( 'searchLanguage' ).value ); + }, 100 ) ); + */ + +}( wmTest, WMTypeAhead ) ); diff --git a/resources/scripts/wm-typeahead.js b/resources/scripts/wm-typeahead.js new file mode 100644 index 00000000..62d03aab --- /dev/null +++ b/resources/scripts/wm-typeahead.js @@ -0,0 +1,480 @@ +/** + * Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master + * See T219590 for more details + */ + +/** + * 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'); + * + */ + +/* global addEvent, getDevicePixelRatio */ + +window.WMTypeAhead = function ( appendTo, searchInput ) { // eslint-disable-line no-unused-vars + + var typeAheadID = 'typeahead-suggestions', + typeAheadEl = document.getElementById( typeAheadID ), // Type-ahead DOM element. + appendEl = document.getElementById( appendTo ), + searchEl = document.getElementById( searchInput ), + thumbnailSize = getDevicePixelRatio() * 80, + maxSearchResults = 6, + searchLang, + searchString, + typeAheadItems, + activeItem, + ssActiveIndex; + + // Only create typeAheadEl once on page. + if ( !typeAheadEl ) { + typeAheadEl = document.createElement( 'div' ); + typeAheadEl.id = typeAheadID; + appendEl.appendChild( typeAheadEl ); + } + + /** + * Serializes a JS object into a URL parameter string. + * + * @param {Object} obj - object whose properties will be serialized + * @return {string} + */ + function serialize( obj ) { + var serialized = [], + prop; + + for ( prop in obj ) { + if ( obj.hasOwnProperty( prop ) ) { // eslint-disable-line no-prototype-builtins + serialized.push( prop + '=' + encodeURIComponent( obj[ prop ] ) ); + } + } + return serialized.join( '&' ); + } + + /** + * 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 ) { + var index = this.incrementIndex(); + this.queue[ index ] = func( index ); + return index; + }, + deleteSelfFromQueue: function ( i ) { + delete this.queue[ i ]; + }, + deletePrevCallbacks: function ( j ) { + var 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 ); + } + }; + + /** + * 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 () { + var searchScript = document.getElementById( 'api_opensearch' ); + typeAheadEl.innerHTML = ''; + if ( searchScript ) { + searchScript.src = false; + } + 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 ) { + var el = e.relatedTarget; + if ( el && /suggestion-link/.test( el.className ) ) { + window.location = el.href; + } + } + + /** + * 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, lang ) { + var script = document.getElementById( 'api_opensearch' ), + docHead = document.getElementsByTagName( 'head' )[ 0 ], + hostname, + callbackIndex, + searchQuery; + + // Variables declared in parent function. + searchLang = encodeURIComponent( lang ) || 'en'; + searchString = encodeURIComponent( string ); + if ( searchString.length === 0 ) { + clearTypeAhead(); + return; + } + + hostname = '//' + searchLang + '.wikipedia.org/w/api.php?'; + + // If script already exists, remove it. + if ( script ) { + docHead.removeChild( script ); + } + + script = document.createElement( 'script' ); + script.id = 'api_opensearch'; + + callbackIndex = window.callbackStack.addCallback( window.portalOpensearchCallback ); + searchQuery = { + action: 'query', + format: 'json', + generator: 'prefixsearch', + prop: 'pageprops|pageimages|description', + redirects: '', + ppprop: 'displaytitle', + piprop: 'thumbnail', + pithumbsize: thumbnailSize, + pilimit: maxSearchResults, + gpssearch: string, + gpsnamespace: 0, + gpslimit: maxSearchResults, + callback: 'callbackStack.queue[' + callbackIndex + ']' + }; + + script.src = hostname + serialize( searchQuery ); + docHead.appendChild( script ); + } + + // 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 ) { + + var 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 ) { + var string = '
', + suggestionLink, + suggestionThumbnail, + suggestionText, + suggestionTitle, + suggestionDescription, + page, + sanitizedThumbURL = false, + descriptionText = '', + pageDescription = '', + i; + + for ( i = 0; i < suggestions.length; i++ ) { + + if ( !suggestions[ i ] ) { + continue; + } + + page = suggestions[ i ]; + pageDescription = page.description || ''; + + // 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(); + } + } + + 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 + }, '' ); + + suggestionLink = mw.html.element( 'a', { + 'class': 'suggestion-link', + href: 'https://' + searchLang + '.wikipedia.org/wiki/' + 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 ) { + + var 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 ) { + + var callbackIndex = i, + orderedResults = [], + suggestions, + item, + result, + templateDOMString, + listEl; + + return function ( xhrResults ) { + + window.callbackStack.deletePrevCallbacks( callbackIndex ); + + if ( document.activeElement !== searchEl ) { + return; + } + + suggestions = ( xhrResults.query && xhrResults.query.pages ) ? + xhrResults.query.pages : []; + + for ( item in suggestions ) { + 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 ) { + + var 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 + }; +}; diff --git a/skin.json b/skin.json index b4a693df..988a68d4 100644 --- a/skin.json +++ b/skin.json @@ -47,6 +47,12 @@ "resources/scripts/toc.js" ] }, + "skins.citizen.search": { + "scripts": [ + "resources/scripts/wm-typeahead.js", + "resources/scripts/typeahead-init.js" + ] + }, "skins.citizen.bpd": { "scripts": [ "resources/scripts/lazyload.js"