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