traverse_content.js revision cedac228d2dd51db4b79ea1e72c7f249408ee061
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 A DOM traversal interface for moving a selection around a 8 * webpage. Provides multiple granularities: 9 * 1. Move by paragraph. 10 * 2. Move by sentence. 11 * 3. Move by line. 12 * 4. Move by word. 13 * 5. Move by character. 14 */ 15 16goog.provide('cvox.TraverseContent'); 17 18goog.require('cvox.CursorSelection'); 19goog.require('cvox.DomUtil'); 20goog.require('cvox.SelectionUtil'); 21goog.require('cvox.TraverseUtil'); 22 23/** 24 * Moves a selection around a document or within a provided DOM object. 25 * 26 * @constructor 27 * @param {Node=} domObj a DOM node (optional). 28 */ 29cvox.TraverseContent = function(domObj) { 30 if (domObj != null) { 31 this.currentDomObj = domObj; 32 } else { 33 this.currentDomObj = document.body; 34 } 35 var range = document.createRange(); 36 // TODO (dmazzoni): Switch this to avoid using range methods. Range methods 37 // can cause exceptions (such as if the node is not attached to the DOM). 38 try { 39 range.selectNode(this.currentDomObj); 40 this.startCursor_ = new cvox.Cursor( 41 range.startContainer, range.startOffset, 42 cvox.TraverseUtil.getNodeText(range.startContainer)); 43 this.endCursor_ = new cvox.Cursor( 44 range.endContainer, range.endOffset, 45 cvox.TraverseUtil.getNodeText(range.endContainer)); 46 } catch (e) { 47 // Ignoring this error so that it will not break everything else. 48 window.console.log('Error: Unselectable node:'); 49 window.console.log(domObj); 50 } 51}; 52goog.addSingletonGetter(cvox.TraverseContent); 53 54/** 55 * Whether the last navigated selection only contained whitespace. 56 * @type {boolean} 57 */ 58cvox.TraverseContent.prototype.lastSelectionWasWhitespace = false; 59 60/** 61 * Whether we should skip whitespace when traversing individual characters. 62 * @type {boolean} 63 */ 64cvox.TraverseContent.prototype.skipWhitespace = false; 65 66/** 67 * If moveNext and movePrev should skip past an invalid selection, 68 * so the user never gets stuck. Ideally the navigation code should never 69 * return a range that's not a valid selection, but this keeps the user from 70 * getting stuck if that code fails. This is set to false for unit testing. 71 * @type {boolean} 72 */ 73cvox.TraverseContent.prototype.skipInvalidSelections = true; 74 75/** 76 * If line and sentence navigation should break at <a> links. 77 * @type {boolean} 78 */ 79cvox.TraverseContent.prototype.breakAtLinks = true; 80 81/** 82 * The string constant for character granularity. 83 * @type {string} 84 * @const 85 */ 86cvox.TraverseContent.kCharacter = 'character'; 87 88/** 89 * The string constant for word granularity. 90 * @type {string} 91 * @const 92 */ 93cvox.TraverseContent.kWord = 'word'; 94 95/** 96 * The string constant for sentence granularity. 97 * @type {string} 98 * @const 99 */ 100cvox.TraverseContent.kSentence = 'sentence'; 101 102/** 103 * The string constant for line granularity. 104 * @type {string} 105 * @const 106 */ 107cvox.TraverseContent.kLine = 'line'; 108 109/** 110 * The string constant for paragraph granularity. 111 * @type {string} 112 * @const 113 */ 114cvox.TraverseContent.kParagraph = 'paragraph'; 115 116/** 117 * A constant array of all granularities. 118 * @type {Array.<string>} 119 * @const 120 */ 121cvox.TraverseContent.kAllGrains = 122 [cvox.TraverseContent.kParagraph, 123 cvox.TraverseContent.kSentence, 124 cvox.TraverseContent.kLine, 125 cvox.TraverseContent.kWord, 126 cvox.TraverseContent.kCharacter]; 127 128/** 129 * Set the current position to match the current WebKit selection. 130 */ 131cvox.TraverseContent.prototype.syncToSelection = function() { 132 this.normalizeSelection(); 133 134 var selection = window.getSelection(); 135 if (!selection || !selection.anchorNode || !selection.focusNode) { 136 return; 137 } 138 this.startCursor_ = new cvox.Cursor( 139 selection.anchorNode, selection.anchorOffset, 140 cvox.TraverseUtil.getNodeText(selection.anchorNode)); 141 this.endCursor_ = new cvox.Cursor( 142 selection.focusNode, selection.focusOffset, 143 cvox.TraverseUtil.getNodeText(selection.focusNode)); 144}; 145 146/** 147 * Set the start and end cursors to the selection. 148 * @param {cvox.CursorSelection} sel The selection. 149 */ 150cvox.TraverseContent.prototype.syncToCursorSelection = function(sel) { 151 this.startCursor_ = sel.start.clone(); 152 this.endCursor_ = sel.end.clone(); 153}; 154 155/** 156 * Get the cursor selection. 157 * @return {cvox.CursorSelection} The selection. 158 */ 159cvox.TraverseContent.prototype.getCurrentCursorSelection = function() { 160 return new cvox.CursorSelection(this.startCursor_, this.endCursor_); 161}; 162 163/** 164 * Set the WebKit selection based on the current position. 165 */ 166cvox.TraverseContent.prototype.updateSelection = function() { 167 cvox.TraverseUtil.setSelection(this.startCursor_, this.endCursor_); 168 cvox.SelectionUtil.scrollToSelection(window.getSelection()); 169}; 170 171/** 172 * Get the current position as a range. 173 * @return {Range} The current range. 174 */ 175cvox.TraverseContent.prototype.getCurrentRange = function() { 176 var range = document.createRange(); 177 try { 178 range.setStart(this.startCursor_.node, this.startCursor_.index); 179 range.setEnd(this.endCursor_.node, this.endCursor_.index); 180 } catch (e) { 181 console.log('Invalid range '); 182 } 183 return range; 184}; 185 186/** 187 * Get the current text content as a string. 188 * @return {string} The current spanned content. 189 */ 190cvox.TraverseContent.prototype.getCurrentText = function() { 191 return cvox.SelectionUtil.getRangeText(this.getCurrentRange()); 192}; 193 194/** 195 * Collapse to the end of the range. 196 */ 197cvox.TraverseContent.prototype.collapseToEnd = function() { 198 this.startCursor_ = this.endCursor_.clone(); 199}; 200 201/** 202 * Collapse to the start of the range. 203 */ 204cvox.TraverseContent.prototype.collapseToStart = function() { 205 this.endCursor_ = this.startCursor_.clone(); 206}; 207 208/** 209 * Moves selection forward. 210 * 211 * @param {string} grain specifies "sentence", "word", "character", 212 * or "paragraph" granularity. 213 * @return {?string} Either: 214 * 1) The new selected text. 215 * 2) null if the end of the domObj has been reached. 216 */ 217cvox.TraverseContent.prototype.moveNext = function(grain) { 218 var breakTags = this.getBreakTags(); 219 220 // As a special case, if the current selection is empty or all 221 // whitespace, ensure that the next returned selection will NOT be 222 // only whitespace - otherwise you can get trapped. 223 var skipWhitespace = this.skipWhitespace; 224 225 var range = this.getCurrentRange(); 226 if (!cvox.SelectionUtil.isRangeValid(range)) { 227 skipWhitespace = true; 228 } 229 230 var elementsEntered = []; 231 var elementsLeft = []; 232 var str; 233 do { 234 if (grain === cvox.TraverseContent.kSentence) { 235 str = cvox.TraverseUtil.getNextSentence( 236 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 237 breakTags); 238 } else if (grain === cvox.TraverseContent.kWord) { 239 str = cvox.TraverseUtil.getNextWord( 240 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft); 241 } else if (grain === cvox.TraverseContent.kCharacter) { 242 str = cvox.TraverseUtil.getNextChar( 243 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 244 skipWhitespace); 245 } else if (grain === cvox.TraverseContent.kParagraph) { 246 str = cvox.TraverseUtil.getNextParagraph( 247 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft); 248 } else if (grain === cvox.TraverseContent.kLine) { 249 str = cvox.TraverseUtil.getNextLine( 250 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 251 breakTags); 252 } else { 253 // User has provided an invalid string. 254 // Fall through to default: extend by sentence 255 window.console.log('Invalid selection granularity: "' + grain + '"'); 256 grain = cvox.TraverseContent.kSentence; 257 str = cvox.TraverseUtil.getNextSentence( 258 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 259 breakTags); 260 } 261 262 if (str == null) { 263 // We reached the end of the document. 264 return null; 265 } 266 267 range = this.getCurrentRange(); 268 var isInvalid = !range.getBoundingClientRect(); 269 } while (this.skipInvalidSelections && isInvalid); 270 271 if (!cvox.SelectionUtil.isRangeValid(range)) { 272 // It's OK if the selection navigation lands on whitespace once (in 273 // character granularity), but if it hits whitespace more than once, then 274 // skip forward until there is real content. 275 if (!this.lastSelectionWasWhitespace && 276 grain == cvox.TraverseContent.kCharacter) { 277 this.lastSelectionWasWhitespace = true; 278 } else { 279 while (!cvox.SelectionUtil.isRangeValid(this.getCurrentRange())) { 280 if (this.moveNext(grain) == null) { 281 break; 282 } 283 } 284 } 285 } else { 286 this.lastSelectionWasWhitespace = false; 287 } 288 289 return this.getCurrentText(); 290}; 291 292 293/** 294 * Moves selection backward. 295 * 296 * @param {string} grain specifies "sentence", "word", "character", 297 * or "paragraph" granularity. 298 * @return {?string} Either: 299 * 1) The new selected text. 300 * 2) null if the beginning of the domObj has been reached. 301 */ 302cvox.TraverseContent.prototype.movePrev = function(grain) { 303 var breakTags = this.getBreakTags(); 304 305 // As a special case, if the current selection is empty or all 306 // whitespace, ensure that the next returned selection will NOT be 307 // only whitespace - otherwise you can get trapped. 308 var skipWhitespace = this.skipWhitespace; 309 310 var range = this.getCurrentRange(); 311 if (!cvox.SelectionUtil.isRangeValid(range)) { 312 skipWhitespace = true; 313 } 314 315 var elementsEntered = []; 316 var elementsLeft = []; 317 var str; 318 do { 319 if (grain === cvox.TraverseContent.kSentence) { 320 str = cvox.TraverseUtil.getPreviousSentence( 321 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 322 breakTags); 323 } else if (grain === cvox.TraverseContent.kWord) { 324 str = cvox.TraverseUtil.getPreviousWord( 325 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft); 326 } else if (grain === cvox.TraverseContent.kCharacter) { 327 str = cvox.TraverseUtil.getPreviousChar( 328 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 329 skipWhitespace); 330 } else if (grain === cvox.TraverseContent.kParagraph) { 331 str = cvox.TraverseUtil.getPreviousParagraph( 332 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft); 333 } else if (grain === cvox.TraverseContent.kLine) { 334 str = cvox.TraverseUtil.getPreviousLine( 335 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 336 breakTags); 337 } else { 338 // User has provided an invalid string. 339 // Fall through to default: extend by sentence 340 window.console.log('Invalid selection granularity: "' + grain + '"'); 341 grain = cvox.TraverseContent.kSentence; 342 str = cvox.TraverseUtil.getPreviousSentence( 343 this.startCursor_, this.endCursor_, elementsEntered, elementsLeft, 344 breakTags); 345 } 346 347 if (str == null) { 348 // We reached the end of the document. 349 return null; 350 } 351 352 range = this.getCurrentRange(); 353 var isInvalid = !range.getBoundingClientRect(); 354 } while (this.skipInvalidSelections && isInvalid); 355 356 if (!cvox.SelectionUtil.isRangeValid(range)) { 357 // It's OK if the selection navigation lands on whitespace once (in 358 // character granularity), but if it hits whitespace more than once, then 359 // skip forward until there is real content. 360 if (!this.lastSelectionWasWhitespace && 361 grain == cvox.TraverseContent.kCharacter) { 362 this.lastSelectionWasWhitespace = true; 363 } else { 364 while (!cvox.SelectionUtil.isRangeValid(this.getCurrentRange())) { 365 if (this.movePrev(grain) == null) { 366 break; 367 } 368 } 369 } 370 } else { 371 this.lastSelectionWasWhitespace = false; 372 } 373 374 return this.getCurrentText(); 375}; 376 377/** 378 * Get the tag names that should break a sentence or line. Currently 379 * just an anchor 'A' should break a sentence or line if the breakAtLinks 380 * flag is true, but in the future we might have other rules for breaking. 381 * 382 * @return {Object} An associative array mapping a tag name to true if 383 * it should break a sentence or line. 384 */ 385cvox.TraverseContent.prototype.getBreakTags = function() { 386 return { 387 'A': this.breakAtLinks, 388 'BR': true, 389 'HR': true 390 }; 391}; 392 393/** 394 * Selects the next element of the document or within the provided DOM object. 395 * Scrolls the window as appropriate. 396 * 397 * @param {string} grain specifies "sentence", "word", "character", 398 * or "paragraph" granularity. 399 * @param {Node=} domObj a DOM node (optional). 400 * @return {?string} Either: 401 * 1) The new selected text. 402 * 2) null if the end of the domObj has been reached. 403 */ 404cvox.TraverseContent.prototype.nextElement = function(grain, domObj) { 405 if (domObj != null) { 406 this.currentDomObj = domObj; 407 } 408 409 var result = this.moveNext(grain); 410 if (result != null && 411 (!cvox.DomUtil.isDescendantOfNode( 412 this.startCursor_.node, this.currentDomObj) || 413 !cvox.DomUtil.isDescendantOfNode( 414 this.endCursor_.node, this.currentDomObj))) { 415 return null; 416 } 417 418 return result; 419}; 420 421 422/** 423 * Selects the previous element of the document or within the provided DOM 424 * object. Scrolls the window as appropriate. 425 * 426 * @param {string} grain specifies "sentence", "word", "character", 427 * or "paragraph" granularity. 428 * @param {Node=} domObj a DOM node (optional). 429 * @return {?string} Either: 430 * 1) The new selected text. 431 * 2) null if the beginning of the domObj has been reached. 432 */ 433cvox.TraverseContent.prototype.prevElement = function(grain, domObj) { 434 if (domObj != null) { 435 this.currentDomObj = domObj; 436 } 437 438 var result = this.movePrev(grain); 439 if (result != null && 440 (!cvox.DomUtil.isDescendantOfNode( 441 this.startCursor_.node, this.currentDomObj) || 442 !cvox.DomUtil.isDescendantOfNode( 443 this.endCursor_.node, this.currentDomObj))) { 444 return null; 445 } 446 447 return result; 448}; 449 450/** 451 * Make sure that exactly one item is selected. If there's no selection, 452 * set the selection to the start of the document. 453 */ 454cvox.TraverseContent.prototype.normalizeSelection = function() { 455 var selection = window.getSelection(); 456 if (selection.rangeCount < 1) { 457 // Before the user has clicked a freshly-loaded page 458 459 var range = document.createRange(); 460 range.setStart(this.currentDomObj, 0); 461 range.setEnd(this.currentDomObj, 0); 462 463 selection.removeAllRanges(); 464 selection.addRange(range); 465 466 } else if (selection.rangeCount > 1) { 467 // Multiple ranges exist - remove all ranges but the last one 468 for (var i = 0; i < (selection.rangeCount - 1); i++) { 469 selection.removeRange(selection.getRangeAt(i)); 470 } 471 } 472}; 473 474/** 475 * Resets the selection. 476 * 477 * @param {Node=} domObj a DOM node. Optional. 478 * 479 */ 480cvox.TraverseContent.prototype.reset = function(domObj) { 481 window.getSelection().removeAllRanges(); 482}; 483