1 // Copyright 2014 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 6 /** 7 * @fileoverview Uses ChromeVox API to enhance the search experience. 8 */ 9 10 goog.provide('cvox.Search'); 11 12 goog.require('cvox.ChromeVox'); 13 goog.require('cvox.SearchConstants'); 14 goog.require('cvox.SearchResults'); 15 goog.require('cvox.SearchUtil'); 16 goog.require('cvox.UnknownResult'); 17 18 /** 19 * @constructor 20 */ 21 cvox.Search = function() { 22 }; 23 24 /** 25 * Selectors to match results. 26 * @type {Object.<string, string>} 27 */ 28 cvox.Search.selectors = {}; 29 30 /** 31 * Selectors for web results. 32 */ 33 cvox.Search.webSelectors = { 34 /* Topstuff typically contains important messages to be added first. */ 35 TOPSTUFF_SELECT: '#topstuff', 36 SPELL_SUGG_SELECT: '.ssp', 37 SPELL_CORRECTION_SELECT: '.sp_cnt', 38 KNOW_PANEL_SELECT: '.knop', 39 RESULT_SELECT: 'li.g', 40 RELATED_SELECT: '#brs' 41 }; 42 43 /** 44 * Selectors for image results. 45 */ 46 cvox.Search.imageSelectors = { 47 IMAGE_CATEGORIES_SELECT: '#ifbc .rg_fbl', 48 IMAGE_RESULT_SELECT: '#rg_s .rg_di' 49 }; 50 51 /** 52 * Index of the currently synced result. 53 * @type {number} 54 */ 55 cvox.Search.index; 56 57 /** 58 * Array of the search results. 59 * @type {Array.<Element>} 60 */ 61 cvox.Search.results = []; 62 63 /** 64 * Array of the navigation panes. 65 * @type {Array.<Element>} 66 */ 67 cvox.Search.panes = []; 68 69 /** 70 * Index of the currently synced pane. 71 * @type {number} 72 */ 73 cvox.Search.paneIndex; 74 75 /** 76 * If currently synced item is a pane. 77 */ 78 cvox.Search.isPane = false; 79 80 /** 81 * Class of a selected pane. 82 */ 83 cvox.Search.SELECTED_PANE_CLASS = 'hdtb_mitem hdtb_msel'; 84 85 86 /** 87 * Speak and sync. 88 * @private 89 */ 90 cvox.Search.speakSync_ = function() { 91 var result = cvox.Search.results[cvox.Search.index]; 92 var resultType = cvox.Search.getResultType(result); 93 var isSpoken = resultType.speak(result); 94 cvox.ChromeVox.syncToNode(resultType.getSyncNode(result), !isSpoken); 95 cvox.Search.isPane = false; 96 }; 97 98 /** 99 * Sync the search result index to ChromeVox. 100 */ 101 cvox.Search.syncToIndex = function() { 102 cvox.ChromeVox.tts.stop(); 103 var prop = { endCallback: cvox.Search.speakSync_ }; 104 if (cvox.Search.index === 0) { 105 cvox.ChromeVox.tts.speak('First result', 1, prop); 106 } else if (cvox.Search.index === cvox.Search.results.length - 1) { 107 cvox.ChromeVox.tts.speak('Last result', 1, prop); 108 } else { 109 cvox.Search.speakSync_(); 110 } 111 }; 112 113 /** 114 * Sync the current pane index to ChromeVox. 115 */ 116 cvox.Search.syncPaneToIndex = function() { 117 var pane = cvox.Search.panes[cvox.Search.paneIndex]; 118 var anchor = pane.querySelector('a'); 119 if (anchor) { 120 cvox.ChromeVox.syncToNode(anchor, true); 121 } else { 122 cvox.ChromeVox.syncToNode(pane, true); 123 } 124 cvox.Search.isPane = true; 125 }; 126 127 /** 128 * Get the type of the result such as Knowledge Panel, Weather, etc. 129 * @param {Element} result Result to be classified. 130 * @return {cvox.AbstractResult} Type of the result. 131 */ 132 cvox.Search.getResultType = function(result) { 133 for (var i = 0; i < cvox.SearchResults.RESULT_TYPES.length; i++) { 134 var resultType = new cvox.SearchResults.RESULT_TYPES[i](); 135 if (resultType.isType(result)) { 136 return resultType; 137 } 138 } 139 return new cvox.UnknownResult(); 140 }; 141 142 /** 143 * Get the page number associated with the url. 144 * @param {string} url Url of search page. 145 * @return {number} Page number. 146 */ 147 cvox.Search.getPageNumber = function(url) { 148 var PAGE_ANCHOR_SELECTOR = '#nav .fl'; 149 var pageAnchors = document.querySelectorAll(PAGE_ANCHOR_SELECTOR); 150 for (var i = 0; i < pageAnchors.length; i++) { 151 var pageAnchor = pageAnchors.item(i); 152 if (pageAnchor.href === url) { 153 return parseInt(pageAnchor.innerText, 10); 154 } 155 } 156 return NaN; 157 }; 158 159 /** 160 * Navigate to the next / previous page. 161 * @param {boolean} next True for the next page, false for the previous. 162 */ 163 cvox.Search.navigatePage = function(next) { 164 /* NavEnd contains previous / next page links. */ 165 var NAV_END_CLASS = 'navend'; 166 var navEnds = document.getElementsByClassName(NAV_END_CLASS); 167 var navEnd = next ? navEnds[1] : navEnds[0]; 168 var url = cvox.SearchUtil.extractURL(navEnd); 169 var navToUrl = function() { 170 window.location = url; 171 }; 172 var prop = { endCallback: navToUrl }; 173 if (url) { 174 var pageNumber = cvox.Search.getPageNumber(url); 175 if (!isNaN(pageNumber)) { 176 cvox.ChromeVox.tts.speak('Page ' + pageNumber, 0, prop); 177 } else { 178 cvox.ChromeVox.tts.speak('Unknown page.', 0, prop); 179 } 180 } 181 }; 182 183 /** 184 * Navigates to the currently synced pane. 185 */ 186 cvox.Search.goToPane = function() { 187 var pane = cvox.Search.panes[cvox.Search.paneIndex]; 188 if (pane.className === cvox.Search.SELECTED_PANE_CLASS) { 189 cvox.ChromeVox.tts.speak('You are already on that page.'); 190 return; 191 } 192 var anchor = pane.querySelector('a'); 193 cvox.ChromeVox.tts.speak(anchor.textContent); 194 var url = cvox.SearchUtil.extractURL(pane); 195 if (url) { 196 window.location = url; 197 } 198 }; 199 200 /** 201 * Follow the link to the current result. 202 */ 203 cvox.Search.goToResult = function() { 204 var result = cvox.Search.results[cvox.Search.index]; 205 var resultType = cvox.Search.getResultType(result); 206 var url = resultType.getURL(result); 207 if (url) { 208 window.location = url; 209 } 210 }; 211 212 /** 213 * Handle the keyboard. 214 * @param {Event} evt Keydown event. 215 * @return {boolean} True if key was handled, false otherwise. 216 */ 217 cvox.Search.keyhandler = function(evt) { 218 var SEARCH_INPUT_ID = 'gbqfq'; 219 var searchInput = document.getElementById(SEARCH_INPUT_ID); 220 var result = cvox.Search.results[cvox.Search.index]; 221 var ret = false; 222 223 /* TODO(peterxiao): Add cvox api call to determine cvox key. */ 224 if (evt.shiftKey || evt.altKey || evt.ctrlKey) { 225 return false; 226 } 227 228 /* Do not handle if search input has focus, or if the search widget 229 * has focus. 230 */ 231 if (document.activeElement !== searchInput && 232 !cvox.SearchUtil.isSearchWidgetActive()) { 233 switch (evt.keyCode) { 234 case cvox.SearchConstants.KeyCode.UP: 235 /* Add results.length because JS Modulo is silly. */ 236 cvox.Search.index = cvox.SearchUtil.subOneWrap(cvox.Search.index, 237 cvox.Search.results.length); 238 if (cvox.Search.index === cvox.Search.results.length - 1) { 239 cvox.ChromeVox.earcons.playEarconByName('WRAP'); 240 } 241 cvox.Search.syncToIndex(); 242 break; 243 244 case cvox.SearchConstants.KeyCode.DOWN: 245 cvox.Search.index = cvox.SearchUtil.addOneWrap(cvox.Search.index, 246 cvox.Search.results.length); 247 if (cvox.Search.index === 0) { 248 cvox.ChromeVox.earcons.playEarconByName('WRAP'); 249 } 250 cvox.Search.syncToIndex(); 251 break; 252 253 case cvox.SearchConstants.KeyCode.PAGE_UP: 254 cvox.Search.navigatePage(false); 255 break; 256 257 case cvox.SearchConstants.KeyCode.PAGE_DOWN: 258 cvox.Search.navigatePage(true); 259 break; 260 261 case cvox.SearchConstants.KeyCode.LEFT: 262 cvox.Search.paneIndex = cvox.SearchUtil.subOneWrap(cvox.Search.paneIndex, 263 cvox.Search.panes.length); 264 cvox.Search.syncPaneToIndex(); 265 break; 266 267 case cvox.SearchConstants.KeyCode.RIGHT: 268 cvox.Search.paneIndex = cvox.SearchUtil.addOneWrap(cvox.Search.paneIndex, 269 cvox.Search.panes.length); 270 cvox.Search.syncPaneToIndex(); 271 break; 272 273 case cvox.SearchConstants.KeyCode.ENTER: 274 if (cvox.Search.isPane) { 275 cvox.Search.goToPane(); 276 } else { 277 cvox.Search.goToResult(); 278 } 279 break; 280 281 default: 282 return false; 283 } 284 evt.preventDefault(); 285 evt.stopPropagation(); 286 return true; 287 } 288 return false; 289 }; 290 291 /** 292 * Adds the elements that match the selector to results. 293 * @param {string} selector Selector of element to add. 294 */ 295 cvox.Search.addToResultsBySelector = function(selector) { 296 var nodes = document.querySelectorAll(selector); 297 for (var i = 0; i < nodes.length; i++) { 298 var node = nodes.item(i); 299 /* Do not add if empty. */ 300 if (node.innerHTML !== '') { 301 cvox.Search.results.push(nodes.item(i)); 302 } 303 } 304 }; 305 306 /** 307 * Populates the panes array. 308 */ 309 cvox.Search.populatePanes = function() { 310 cvox.Search.panes = []; 311 var PANE_SELECT = '.hdtb_mitem'; 312 var paneElems = document.querySelectorAll(PANE_SELECT); 313 for (var i = 0; i < paneElems.length; i++) { 314 cvox.Search.panes.push(paneElems.item(i)); 315 } 316 }; 317 318 /** 319 * Populates the results with results. 320 */ 321 cvox.Search.populateResults = function() { 322 for (var prop in cvox.Search.selectors) { 323 cvox.Search.addToResultsBySelector(cvox.Search.selectors[prop]); 324 } 325 }; 326 327 /** 328 * Populates the results with ad results. 329 */ 330 cvox.Search.populateAdResults = function() { 331 cvox.Search.results = []; 332 var ADS_SELECT = '.ads-ad'; 333 cvox.Search.addToResultsBySelector(ADS_SELECT); 334 }; 335 336 /** 337 * Observes mutations and updates results accordingly. 338 */ 339 cvox.Search.observeMutation = function() { 340 var SEARCH_AREA_SELECT = '#rg_s'; 341 var target = document.querySelector(SEARCH_AREA_SELECT); 342 343 var observer = new MutationObserver(function(mutations) { 344 cvox.Search.results = []; 345 cvox.Search.populateResults(); 346 }); 347 348 var config = 349 /** @type MutationObserverInit */ 350 ({ attributes: true, childList: true, characterData: true }); 351 observer.observe(target, config); 352 }; 353 354 /** 355 * Get the current selected pane's index. 356 * @return {number} Index of selected pane. 357 */ 358 cvox.Search.getSelectedPaneIndex = function() { 359 var panes = cvox.Search.panes; 360 for (var i = 0; i < panes.length; i++) { 361 if (panes[i].className === cvox.Search.SELECTED_PANE_CLASS) { 362 return i; 363 } 364 } 365 return 0; 366 }; 367 368 /** 369 * Get the ancestor of node that is a result. 370 * @param {Node} node Node. 371 * @return {Node} Result ancestor. 372 */ 373 cvox.Search.getAncestorResult = function(node) { 374 var curr = node; 375 while (curr) { 376 for (var prop in cvox.Search.selectors) { 377 var selector = cvox.Search.selectors[prop]; 378 if (curr.webkitMatchesSelector && curr.webkitMatchesSelector(selector)) { 379 return curr; 380 } 381 } 382 curr = curr.parentNode; 383 } 384 return null; 385 }; 386 387 /** 388 * Sync to the correct initial node. 389 */ 390 cvox.Search.initialSync = function() { 391 var currNode = cvox.ChromeVox.navigationManager.getCurrentNode(); 392 var result = cvox.Search.getAncestorResult(currNode); 393 cvox.Search.index = cvox.Search.results.indexOf(result); 394 if (cvox.Search.index === -1) { 395 cvox.Search.index = 0; 396 } 397 398 if (cvox.Search.results.length > 0) { 399 cvox.Search.syncToIndex(); 400 } 401 }; 402 403 /** 404 * Initialize Search. 405 */ 406 cvox.Search.init = function() { 407 cvox.Search.index = 0; 408 409 /* Flush out anything that may have been speaking. */ 410 cvox.ChromeVox.tts.stop(); 411 412 /* Determine the type of search. */ 413 var SELECTED_CLASS = 'hdtb_msel'; 414 var selected = document.getElementsByClassName(SELECTED_CLASS)[0]; 415 if (!selected) { 416 return; 417 } 418 419 var selectedHTML = selected.innerHTML; 420 switch (selectedHTML) { 421 case 'Web': 422 case 'News': 423 cvox.Search.selectors = cvox.Search.webSelectors; 424 break; 425 case 'Images': 426 cvox.Search.selectors = cvox.Search.imageSelectors; 427 cvox.Search.observeMutation(); 428 break; 429 default: 430 return; 431 } 432 433 cvox.Search.populateResults(); 434 cvox.Search.populatePanes(); 435 cvox.Search.paneIndex = cvox.Search.getSelectedPaneIndex(); 436 437 cvox.Search.initialSync(); 438 439 }; 440