1/** 2 * @file 3 * SEARCH AUTOCOMPLETE javascript mechanism. 4 */ 5 6(function ($, Drupal, drupalSettings, once) { 7 8 'use strict'; 9 10 var DOMPurify = window.DOMPurify; 11 if (typeof DOMPurify !== 'undefined') { 12 // Add a hook to keep script tag but sanitize it via Drupal.checkPlain(). 13 DOMPurify.addHook('uponSanitizeElement', function (node, data) { 14 if (data.tagName === 'script') { 15 node.outerHTML = Drupal.checkPlain(node.outerHTML); 16 return node; 17 } 18 }); 19 } 20 21 var autocomplete; 22 23 // Escape characters in pattern before creating regexp. 24 function escapeRegExp(str) { 25 str = str.trim(); 26 return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); 27 } 28 29 /** 30 * Helper splitting terms from the autocomplete value. 31 * 32 * @param {String} value 33 * 34 * @return {Array} 35 */ 36 function autocompleteSplitValues(value) { 37 // We will match the value against comma-separated terms. 38 var result = []; 39 var quote = false; 40 var current = ''; 41 var valueLength = value.length; 42 var i, character; 43 44 for (i = 0; i < valueLength; i++) { 45 character = value.charAt(i); 46 if (character === '"') { 47 current += character; 48 quote = !quote; 49 } 50 else if (character === ',' && !quote) { 51 result.push(current.trim()); 52 current = ''; 53 } 54 else { 55 current += character; 56 } 57 } 58 if (value.length > 0) { 59 result.push(current.trim()); 60 } 61 62 return result; 63 } 64 65 /** 66 * Returns the last value of an multi-value textfield. 67 * 68 * @param {String} terms 69 * 70 * @return {String} 71 */ 72 function extractLastTerm(terms) { 73 return autocomplete.splitValues(terms).pop(); 74 } 75 76 /** 77 * The search handler is called before a search is performed. 78 * 79 * @param {Object} event 80 * 81 * @return {Boolean} 82 */ 83 function searchHandler(event) { 84 var options = autocomplete.options; 85 var term = autocomplete.extractLastTerm(event.target.value); 86 // Abort search if the first character is in firstCharacterBlacklist. 87 if (term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1) { 88 return false; 89 } 90 // Only search when the term is at least the minimum length. 91 return term.length >= options.minLength; 92 } 93 94 /** 95 * jQuery UI autocomplete source callback. 96 * 97 * @param {Object} request 98 * @param {Function} response 99 */ 100 function sourceData(request, response) { 101 var elementId = this.element.attr('id'); 102 103 // Build empty cache for this element. 104 if (!(elementId in autocomplete.cache)) { 105 autocomplete.cache[elementId] = {}; 106 } 107 108 // Retrieve the key for this element. 109 var key = this.element.data('key'); 110 111 /** 112 * Filter through the suggestions removing all terms already tagged and 113 * display the available terms to the user. 114 * 115 * @param {Object} suggestions 116 */ 117 function showSuggestions(suggestions) { 118 var tagged = autocomplete.splitValues(request.term); 119 for (var i = 0, il = tagged.length; i < il; i++) { 120 var index = suggestions.indexOf(tagged[i]); 121 if (index >= 0) { 122 suggestions.splice(index, 1); 123 } 124 } 125 response(suggestions); 126 } 127 128 /** 129 * Transforms the data object into an array and update autocomplete results. 130 * 131 * @param {Object} data 132 */ 133 function sourceCallbackHandler(data) { 134 // Reduce number to limit. 135 const length = data.length; 136 if (key) { 137 data = data.slice(0, autocomplete.options.forms[key].maxSuggestions); 138 } 139 140 // Add no_result or more_results depending on the situation. 141 if (key) { 142 if (data.length) { 143 var moreResults = replaceInObject(autocomplete.options.forms[key].moreResults, '\\[search-phrase\\]', request.term); 144 moreResults = replaceInObject(moreResults, '\\[search-count\\]', length); 145 data.push(moreResults); 146 } 147 else { 148 var noResult = replaceInObject(autocomplete.options.forms[key].noResult, '\\[search-phrase\\]', request.term); 149 data.push(noResult); 150 } 151 } 152 153 // Cache the results. 154 autocomplete.cache[elementId][term] = data; 155 156 // Send the new string array of terms to the jQuery UI list. 157 showSuggestions(data); 158 } 159 160 // Get the desired term and construct the autocomplete URL for it. 161 var term = autocomplete.extractLastTerm(request.term); 162 163 // Check if the term is already cached. 164 if (autocomplete.cache[elementId].hasOwnProperty(term)) { 165 showSuggestions(autocomplete.cache[elementId][term]); 166 } 167 else { 168 var data = {}; 169 var path = ''; 170 if (key && autocomplete.options.forms[key]) { 171 path = autocomplete.options.forms[key].source; 172 $.each(autocomplete.options.forms[key].filters, function (key, value) { 173 data[value] = term; 174 }); 175 } 176 else { 177 path = this.element.attr('data-autocomplete-path'); 178 data.q = term; 179 } 180 var options = $.extend({ 181 success: sourceCallbackHandler, 182 data: data 183 }, autocomplete.ajax); 184 $.ajax(path, options); 185 } 186 } 187 188 /** 189 * Handles an autocompletefocus event. 190 * 191 * @return {Boolean} 192 */ 193 function focusHandler() { 194 return false;
195 } 196 197 /** 198 * Modify HTML. 199 * 200 * @param {String} html 201 * @param {String} mode 202 * @param {RegExp} regex 203 * 204 * @return {String} 205 */ 206 function modifyHtml(html, mode, regex) { 207 let regexHtml = /(<("[^"]*"|\'[^\']*\'|[^\'">])*>)/g; 208 switch (mode) { 209 case 'remove_html_tags': 210 html = html.replace(regexHtml, ''); 211 break; 212 213 case 'highlight': 214 // Our goal here is to highlight only the text without destroying the 215 // tags in the HTML. 216 // To do this, we first save the tags in the outputted HTML in a 217 // separate variable and mark them so that we can restore them later. 218 // Then we apply the highlighting process, and finally we restore the 219 // tags we saved in the separate variable and we're done. 220 let nullStrings = ''; 221 let detachedTags = {}; 222 html = html.replace(regexHtml, match => { 223 // The string used for marking should be a special string that a user 224 // would not normally be able to enter in order to restore the tag 225 // correctly. 226 nullStrings += `\0`; 227 let detachedMark = `\v${nullStrings}\v`; 228 detachedTags[detachedMark] = match; 229 return detachedMark; 230 }).replace(regex, '<span class="ui-autocomplete-field-term">$1</span>').replace(/(\v\0+\v)/g, match => detachedTags[match]); 231 break; 232 } 233 return html.replaceAll(/\s+/g, ' ').trim(); 234 } 235 236 /** 237 * Handles an autocompleteselect event. 238 * 239 * @param {Object} event 240 * @param {Object} ui 241 * 242 * @return {Boolean} 243 */ 244 function selectHandler(event, ui) { 245 var terms = autocomplete.splitValues(event.target.value); 246 // Remove the current input. 247 terms.pop(); 248 249 // Trick here to handle encoded characters (see #2936846). 250 const helper = document.createElement('textarea'); 251 helper.innerHTML = ui.item.value; 252 ui.item.value = modifyHtml(helper.value, 'remove_html_tags'); 253 254 // Add the selected item. 255 if (ui.item.value.search(',') > 0) { 256 terms.push('"' + ui.item.value + '"'); 257 } 258 else { 259 terms.push(ui.item.value); 260 } 261 event.target.value = terms.join(', '); 262 var key = $(event.target).data('key'); 263 264 // Add our own handling on submission if needed 265 if (key && autocomplete.options.forms[key].autoRedirect && ui.item.link) { 266 // Decode '&' characters in links: #3240117. 267 helper.innerHTML = ui.item.link; 268 document.location.href = helper.value; 269 } 270 else if (key && autocomplete.options.forms[key].autoSubmit && ui.item.value) { 271 $(this).val(ui.item.value); 272 const form = $(this).closest('form'); 273 const submit = $('[type="submit"]', form); 274 // If we find a submit input click on it rather then submit the form to 275 // trigger the attached click behavior such as AJAX refresh 276 // (case of an ajax view with expose filters for instance). 277 // @see #2820337 278 if (submit.length === 1) { 279 submit.click(); 280 } else { 281 form.submit(); 282 } 283 } 284 // Return false to tell jQuery UI that we've filled in the value already. 285 return false; 286 } 287 288 function renderMenu(ul, items) { 289 var that = this; 290 let currentGroup = null; 291 const content = $('<div class="ui-autocomplete-content"></div>') 292 ul.append(content); 293 $.each( items, function( index, item ) { 294 if ('group' in item) { 295 currentGroup = $('<div class="ui-autocomplete-container ui-autocomplete-container-' + item.group.group_id + '"></div>'); 296 if (item.group.group_id === 'more_results' || item.group.group_id === 'no_results') { 297 ul.append(currentGroup); 298 } else { 299 content.append(currentGroup) 300 } 301 } 302 that._renderItemData(currentGroup || ul, item); 303 }); 304 } 305 306 /** 307 * Override jQuery UI _renderItem function to output HTML by default. 308 * 309 * @param {Object} ul 310 * @param {Object} item 311 * 312 * @return {Object} 313 */ 314 function renderItem(ul, item) { 315 var term = this.term; 316 var first = ('group' in item) ? 'first' : ''; 317 let innerHTML = ''; 318 var regex = new RegExp('(' + escapeRegExp(term) + ')', 'gi'); 319 320 // Move everything to fields if none defined. 321 if (!item.fields) { 322 item.fields = [item.label]; 323 } 324 325 var helper = document.createElement('textarea'); 326 innerHTML = '<div class="ui-autocomplete-fields ' + first + '">'; 327 $.each(item.fields, function (key, value) { 328 helper.innerHTML = value; 329 let output = value; 330 if (typeof DOMPurify !== 'undefined') { 331 output = DOMPurify.sanitize(helper.value, {ADD_TAGS: ['script']}); 332 } 333 else { 334 let parser = new DOMParser(); 335 let doc = parser.parseFromString(helper.value, 'text/html'); 336 output = doc.body.textContent; 337 } 338 if (output.indexOf('src=') === -1 && output.indexOf('href=') === -1) { 339 output = modifyHtml(output, 'highlight', regex); 340 } 341 innerHTML += ('<div class="ui-autocomplete-field-' + key + '">' + output + '</div>'); 342 }); 343 innerHTML += '</div>'; 344 345 if ('group' in item) { 346 var groupId = typeof (item.group.group_id) !== 'undefined' ? item.group.group_id : ''; 347 var groupName = typeof (item.group.group_name) !== 'undefined' ? item.group.group_name : ''; 348 $('<div class="ui-autocomplete-field-group ui-state-disabled ' + groupId + '">' + groupName + '</div>').appendTo(ul); 349 } 350 var isSearchAutoComplete = ul.closest('ul[data-input-ref]').data('input-ref'); 351 var uiMenuItemClass = (isSearchAutoComplete ? '' : ' ui-menu-item'); 352 var elem = $('<li class="ui-menu-item-' + first + uiMenuItemClass + '"></li>').append('<a>' + innerHTML + '</a>'); 353 if (item.value === '') { 354 elem = $('<li class="ui-state-disabled ui-menu-item-' + first + uiMenuItemClass + '">' + item.label + '</li>'); 355 } 356 elem.data('item.autocomplete', item).appendTo(ul); 357 return elem; 358 } 359 360 /** 361 * This method resizes the suggestion panel property. 362 */ 363 function resizeMenu() { 364 var ul = this.menu.element; 365 ul.outerWidth(Math.max(ul.width('').outerWidth() + 5, this.options.position.of == null ? this.element.outerWidth() : this.options.position.of.outerWidth())); 366 const parent = ul.parent()[0]; 367 ul.css({"maxHeight": (window.innerHeight - parent.offsetHeight - parent.getBoundingClientRect().top) + 'px'}); 368 } 369 370 /** 371 * This method replaces needle by replacement in stash. 372 */ 373 function replaceInObject(stash, needle, replacement) { 374 var regex = new RegExp(needle, 'g'); 375 var input = Drupal.checkPlain(replacement); 376 var result = {}; 377 $.each(stash, function (index, value) { 378 if (typeof(value) === 'string') { 379 result[index] = value.replace(regex, input); 380 } 381 else { 382 result[index] = value; 383 } 384 }); 385 return result; 386 } 387 388 /** 389 * Attaches the autocomplete behavior to all required fields. 390 */ 391 Drupal.behaviors.autocomplete = { 392 attach: function (context) { 393 // Act on textfields with the "form-autocomplete" class. 394 var $autocomplete = $(context).find('input.form-autocomplete'); 395 // Act also on registered fields 396 $.each(autocomplete.options.forms, function (key, value) { 397 var elem = $(context).find(autocomplete.options.forms[key].selector).data('key', key).addClass('form-autocomplete').attr('data-id', key); 398 $autocomplete = $.merge($autocomplete, elem); 399 }); 400 401 $.each($autocomplete, function (_, value) { 402 value = $(value); 403 // Retrieve the key for this element. 404 var key = value.data('key'); 405
406 // Run only once on found elements 407 once('autocomplete', value); 408 409 // If present: autocomplete those fields 410 if (value.length) { 411 // Allow options to be overriden per instance. 412 var blacklist = value.attr('data-autocomplete-first-character-blacklist'); 413 // Append the autocomplete results to the form. 414 var formId = '#' + $(this).closest('form').attr('id'); 415 $.extend(autocomplete.options, { 416 firstCharacterBlacklist: (blacklist) ? blacklist : '', 417 minLength: (typeof key !== 'undefined') ? autocomplete.options.forms[key].minChars : autocomplete.options.minLength, 418 appendTo: (formId) ? formId : 'body', 419 }); 420 421 // Use jQuery UI Autocomplete on the textfield. 422 value.autocomplete(autocomplete.options).autocomplete('widget').menu( 'option', 'items', '.ui-autocomplete-container > *' ); 423 value.autocomplete(autocomplete.options).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; 424 value.autocomplete(autocomplete.options).data('ui-autocomplete')._renderMenu = autocomplete.options.renderMenu; 425 value.autocomplete(autocomplete.options).data('ui-autocomplete')._resizeMenu = autocomplete.options.resizeMenu; 426 427 if (key) { 428 // Add theme id to suggestion list. 429 value.autocomplete('widget').attr('data-sa-theme', autocomplete.options.forms[key].theme); 430 // Add unique key (helpfull for styling differently multiple instances on a single form). 431 value.autocomplete('widget').attr('data-input-ref', key); 432 } 433 } 434 }); 435 }, 436 detach: function (context, settings, trigger) { 437 if (trigger === 'unload') { 438 $(once.remove('autocomplete', 'input.form-autocomplete', context)) 439 .autocomplete('destroy'); 440 } 441 } 442 }; 443 444 /** 445 * Autocomplete object implementation. 446 */ 447 autocomplete = { 448 cache: {}, 449 // Exposes options to allow overriding by contrib. 450 splitValues: autocompleteSplitValues, 451 extractLastTerm: extractLastTerm, 452 // jQuery UI autocomplete options. 453 options: { 454 source: sourceData, 455 focus: focusHandler, 456 search: searchHandler, 457 select: selectHandler, 458 renderItem: renderItem, 459 renderMenu: renderMenu, 460 resizeMenu: resizeMenu, 461 minLength: 1, 462 // Custom options, used by Drupal.autocomplete. 463 firstCharacterBlacklist: '', 464 forms: drupalSettings.search_autocomplete ? drupalSettings.search_autocomplete : [] 465 }, 466 ajax: { 467 dataType: 'json' 468 } 469 }; 470 471 Drupal.autocomplete = autocomplete; 472 473})(jQuery, Drupal, drupalSettings, once);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.