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 * @fileoverview A collection of JavaScript utilities used to improve selection 7 * at different granularities. 8 */ 9 10 11 goog.provide('cvox.SelectionUtil'); 12 13 goog.require('cvox.DomUtil'); 14 goog.require('cvox.XpathUtil'); 15 16 /** 17 * Utilities for improving selection. 18 * @constructor 19 */ 20 cvox.SelectionUtil = function() {}; 21 22 /** 23 * Cleans up a paragraph selection acquired by extending forward. 24 * In this context, a paragraph selection is 'clean' when the focus 25 * node (the end of the selection) is not on a text node. 26 * @param {Selection} sel The paragraph-length selection. 27 * @return {boolean} True if the selection has been cleaned. 28 * False if the selection cannot be cleaned without invalid extension. 29 */ 30 cvox.SelectionUtil.cleanUpParagraphForward = function(sel) { 31 var expand = true; 32 33 // nodeType:3 == TEXT_NODE 34 while (sel.focusNode.nodeType == 3) { 35 // Ending with a text node, which is incorrect. Keep extending forward. 36 var fnode = sel.focusNode; 37 var foffset = sel.focusOffset; 38 39 sel.modify('extend', 'forward', 'sentence'); 40 if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { 41 // Nothing more to be done, cannot extend forward further. 42 return false; 43 } 44 } 45 46 return true; 47 }; 48 49 /** 50 * Cleans up a paragraph selection acquired by extending backward. 51 * In this context, a paragraph selection is 'clean' when the focus 52 * node (the end of the selection) is not on a text node. 53 * @param {Selection} sel The paragraph-length selection. 54 * @return {boolean} True if the selection has been cleaned. 55 * False if the selection cannot be cleaned without invalid extension. 56 */ 57 cvox.SelectionUtil.cleanUpParagraphBack = function(sel) { 58 var expand = true; 59 60 var fnode; 61 var foffset; 62 63 // nodeType:3 == TEXT_NODE 64 while (sel.focusNode.nodeType == 3) { 65 // Ending with a text node, which is incorrect. Keep extending backward. 66 fnode = sel.focusNode; 67 foffset = sel.focusOffset; 68 69 sel.modify('extend', 'backward', 'sentence'); 70 71 if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { 72 // Nothing more to be done, cannot extend backward further. 73 return true; 74 } 75 } 76 77 return true; 78 }; 79 80 /** 81 * Cleans up a sentence selection by extending forward. 82 * In this context, a sentence selection is 'clean' when the focus 83 * node (the end of the selection) is either: 84 * - not on a text node 85 * - on a text node that ends with a period or a space 86 * @param {Selection} sel The sentence-length selection. 87 * @return {boolean} True if the selection has been cleaned. 88 * False if the selection cannot be cleaned without invalid extension. 89 */ 90 cvox.SelectionUtil.cleanUpSentence = function(sel) { 91 var expand = true; 92 var lastSelection; 93 var lastSelectionOffset; 94 95 while (expand) { 96 97 // nodeType:3 == TEXT_NODE 98 if (sel.focusNode.nodeType == 3) { 99 // The focus node is of type text, check end for period 100 101 var fnode = sel.focusNode; 102 var foffset = sel.focusOffset; 103 104 if (sel.rangeCount > 0 && sel.getRangeAt(0).endOffset > 0) { 105 if (fnode.substringData(sel.getRangeAt(0).endOffset - 1, 1) == '.') { 106 // Text node ends with period. 107 return true; 108 } else if (fnode.substringData(sel.getRangeAt(0).endOffset - 1, 1) == 109 ' ') { 110 // Text node ends with space. 111 return true; 112 } else { 113 // Text node does not end with period or space. Extend forward. 114 sel.modify('extend', 'forward', 'sentence'); 115 116 if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { 117 // Nothing more to be done, cannot extend forward any further. 118 return false; 119 } 120 } 121 } else { 122 return true; 123 } 124 } else { 125 // Focus node is not text node, no further cleaning required. 126 return true; 127 } 128 } 129 130 return true; 131 }; 132 133 /** 134 * Finds the starting position (height from top and left width) of a 135 * selection in a document. 136 * @param {Selection} sel The selection. 137 * @return {Array} The coordinates [top, left] of the selection. 138 */ 139 cvox.SelectionUtil.findSelPosition = function(sel) { 140 if (sel.rangeCount == 0) { 141 return [0, 0]; 142 } 143 144 var clientRect = sel.getRangeAt(0).getBoundingClientRect(); 145 146 if (!clientRect) { 147 return [0, 0]; 148 } 149 150 var top = window.pageYOffset + clientRect.top; 151 var left = window.pageXOffset + clientRect.left; 152 return [top, left]; 153 }; 154 155 /** 156 * Calculates the horizontal and vertical position of a node 157 * @param {Node} targetNode The node. 158 * @return {Array} The coordinates [top, left] of the node. 159 */ 160 cvox.SelectionUtil.findTopLeftPosition = function(targetNode) { 161 var left = 0; 162 var top = 0; 163 var obj = targetNode; 164 165 if (obj.offsetParent) { 166 left = obj.offsetLeft; 167 top = obj.offsetTop; 168 obj = obj.offsetParent; 169 170 while (obj !== null) { 171 left += obj.offsetLeft; 172 top += obj.offsetTop; 173 obj = obj.offsetParent; 174 } 175 } 176 177 return [top, left]; 178 }; 179 180 181 /** 182 * Checks the contents of a selection for meaningful content. 183 * @param {Selection} sel The selection. 184 * @return {boolean} True if the selection is valid. False if the selection 185 * contains only whitespace or is an empty string. 186 */ 187 cvox.SelectionUtil.isSelectionValid = function(sel) { 188 var regExpWhiteSpace = new RegExp(/^\s+$/); 189 return (! ((regExpWhiteSpace.test(sel.toString())) || 190 (sel.toString() == ''))); 191 }; 192 193 /** 194 * Checks the contents of a range for meaningful content. 195 * @param {Range} range The range. 196 * @return {boolean} True if the range is valid. False if the range 197 * contains only whitespace or is an empty string. 198 */ 199 cvox.SelectionUtil.isRangeValid = function(range) { 200 var text = range.cloneContents().textContent; 201 var regExpWhiteSpace = new RegExp(/^\s+$/); 202 return (! ((regExpWhiteSpace.test(text)) || 203 (text == ''))); 204 }; 205 206 /** 207 * Returns absolute top and left positions of an element. 208 * 209 * @param {!Node} node The element for which to compute the position. 210 * @return {Array.<number>} Index 0 is the left; index 1 is the top. 211 * @private 212 */ 213 cvox.SelectionUtil.findPos_ = function(node) { 214 var curLeft = 0; 215 var curTop = 0; 216 if (node.offsetParent) { 217 do { 218 curLeft += node.offsetLeft; 219 curTop += node.offsetTop; 220 } while (node = node.offsetParent); 221 } 222 return [curLeft, curTop]; 223 }; 224 225 /** 226 * Scrolls node in its parent node such the given node is visible. 227 * @param {Node} focusNode The node. 228 */ 229 cvox.SelectionUtil.scrollElementsToView = function(focusNode) { 230 // First, walk up the DOM until we find a node with a bounding rectangle. 231 while (focusNode && !focusNode.getBoundingClientRect) { 232 focusNode = focusNode.parentElement; 233 } 234 if (!focusNode) { 235 return; 236 } 237 238 // Walk up the DOM, ensuring each element is visible inside its parent. 239 var node = focusNode; 240 var parentNode = node.parentElement; 241 while (node != document.body && parentNode) { 242 node.scrollTop = node.offsetTop; 243 node.scrollLeft = node.offsetLeft; 244 node = parentNode; 245 parentNode = node.parentElement; 246 } 247 248 // Center the active element on the page once we know it's visible. 249 var pos = cvox.SelectionUtil.findPos_(focusNode); 250 window.scrollTo(pos[0] - window.innerWidth / 2, 251 pos[1] - window.innerHeight / 2); 252 }; 253 254 /** 255 * Scrolls the selection into view if it is out of view in the current window. 256 * Inspired by workaround for already-on-screen elements @ 257 * http:// 258 * www.performantdesign.com/2009/08/26/scrollintoview-but-only-if-out-of-view/ 259 * @param {Selection} sel The selection to be scrolled into view. 260 */ 261 cvox.SelectionUtil.scrollToSelection = function(sel) { 262 if (sel.rangeCount == 0) { 263 return; 264 } 265 266 // First, scroll all parent elements into view. Later, move the body 267 // which works slightly differently. 268 269 cvox.SelectionUtil.scrollElementsToView(sel.focusNode); 270 271 var pos = cvox.SelectionUtil.findSelPosition(sel); 272 var top = pos[0]; 273 var left = pos[1]; 274 275 var scrolledVertically = window.pageYOffset || 276 document.documentElement.scrollTop || 277 document.body.scrollTop; 278 var pageHeight = window.innerHeight || 279 document.documentElement.clientHeight || document.body.clientHeight; 280 var pageWidth = window.innerWidth || 281 document.documentElement.innerWidth || document.body.clientWidth; 282 283 if (left < pageWidth) { 284 left = 0; 285 } 286 287 // window.scroll puts specified pixel in upper left of window 288 if ((scrolledVertically + pageHeight) < top) { 289 // Align with bottom of page 290 var diff = top - pageHeight; 291 window.scroll(left, diff + 100); 292 } else if (top < scrolledVertically) { 293 // Align with top of page 294 window.scroll(left, top - 100); 295 } 296 }; 297 298 /** 299 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 300 * Determine whether a node's text content is entirely whitespace. 301 * 302 * Throughout, whitespace is defined as one of the characters 303 * "\t" TAB \u0009 304 * "\n" LF \u000A 305 * "\r" CR \u000D 306 * " " SPC \u0020 307 * 308 * This does not use Javascript's "\s" because that includes non-breaking 309 * spaces (and also some other characters). 310 * 311 * @param {Node} node A node implementing the |CharacterData| interface (i.e., 312 * a |Text|, |Comment|, or |CDATASection| node. 313 * @return {boolean} True if all of the text content of |node| is whitespace, 314 * otherwise false. 315 */ 316 cvox.SelectionUtil.isAllWs = function(node) { 317 // Use ECMA-262 Edition 3 String and RegExp features 318 return !(/[^\t\n\r ]/.test(node.data)); 319 }; 320 321 322 /** 323 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 324 * Determine if a node should be ignored by the iterator functions. 325 * 326 * @param {Node} node An object implementing the DOM1 |Node| interface. 327 * @return {boolean} True if the node is: 328 * 1) A |Text| node that is all whitespace 329 * 2) A |Comment| node 330 * and otherwise false. 331 */ 332 333 cvox.SelectionUtil.isIgnorable = function(node) { 334 return (node.nodeType == 8) || // A comment node 335 ((node.nodeType == 3) && 336 cvox.SelectionUtil.isAllWs(node)); // a text node, all ws 337 }; 338 339 /** 340 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 341 * Version of |previousSibling| that skips nodes that are entirely 342 * whitespace or comments. (Normally |previousSibling| is a property 343 * of all DOM nodes that gives the sibling node, the node that is 344 * a child of the same parent, that occurs immediately before the 345 * reference node.) 346 * 347 * @param {Node} sib The reference node. 348 * @return {Node} Either: 349 * 1) The closest previous sibling to |sib| that is not 350 * ignorable according to |isIgnorable|, or 351 * 2) null if no such node exists. 352 */ 353 cvox.SelectionUtil.nodeBefore = function(sib) { 354 while ((sib = sib.previousSibling)) { 355 if (!cvox.SelectionUtil.isIgnorable(sib)) { 356 return sib; 357 } 358 } 359 return null; 360 }; 361 362 /** 363 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 364 * Version of |nextSibling| that skips nodes that are entirely 365 * whitespace or comments. 366 * 367 * @param {Node} sib The reference node. 368 * @return {Node} Either: 369 * 1) The closest next sibling to |sib| that is not 370 * ignorable according to |isIgnorable|, or 371 * 2) null if no such node exists. 372 */ 373 cvox.SelectionUtil.nodeAfter = function(sib) { 374 while ((sib = sib.nextSibling)) { 375 if (!cvox.SelectionUtil.isIgnorable(sib)) { 376 return sib; 377 } 378 } 379 return null; 380 }; 381 382 /** 383 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 384 * Version of |lastChild| that skips nodes that are entirely 385 * whitespace or comments. (Normally |lastChild| is a property 386 * of all DOM nodes that gives the last of the nodes contained 387 * directly in the reference node.) 388 * 389 * @param {Node} par The reference node. 390 * @return {Node} Either: 391 * 1) The last child of |sib| that is not 392 * ignorable according to |isIgnorable|, or 393 * 2) null if no such node exists. 394 */ 395 cvox.SelectionUtil.lastChildNode = function(par) { 396 var res = par.lastChild; 397 while (res) { 398 if (!cvox.SelectionUtil.isIgnorable(res)) { 399 return res; 400 } 401 res = res.previousSibling; 402 } 403 return null; 404 }; 405 406 /** 407 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 408 * Version of |firstChild| that skips nodes that are entirely 409 * whitespace and comments. 410 * 411 * @param {Node} par The reference node. 412 * @return {Node} Either: 413 * 1) The first child of |sib| that is not 414 * ignorable according to |isIgnorable|, or 415 * 2) null if no such node exists. 416 */ 417 cvox.SelectionUtil.firstChildNode = function(par) { 418 var res = par.firstChild; 419 while (res) { 420 if (!cvox.SelectionUtil.isIgnorable(res)) { 421 return res; 422 } 423 res = res.nextSibling; 424 } 425 return null; 426 }; 427 428 /** 429 * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM 430 * Version of |data| that doesn't include whitespace at the beginning 431 * and end and normalizes all whitespace to a single space. (Normally 432 * |data| is a property of text nodes that gives the text of the node.) 433 * 434 * @param {Node} txt The text node whose data should be returned. 435 * @return {string} A string giving the contents of the text node with 436 * whitespace collapsed. 437 */ 438 cvox.SelectionUtil.dataOf = function(txt) { 439 var data = txt.data; 440 // Use ECMA-262 Edition 3 String and RegExp features 441 data = data.replace(/[\t\n\r ]+/g, ' '); 442 if (data.charAt(0) == ' ') { 443 data = data.substring(1, data.length); 444 } 445 if (data.charAt(data.length - 1) == ' ') { 446 data = data.substring(0, data.length - 1); 447 } 448 return data; 449 }; 450 451 /** 452 * Returns true if the selection has content from at least one node 453 * that has the specified tagName. 454 * 455 * @param {Selection} sel The selection. 456 * @param {string} tagName Tagname that the selection should be checked for. 457 * @return {boolean} True if the selection has content from at least one node 458 * with the specified tagName. 459 */ 460 cvox.SelectionUtil.hasContentWithTag = function(sel, tagName) { 461 if (!sel || !sel.anchorNode || !sel.focusNode) { 462 return false; 463 } 464 if (sel.anchorNode.tagName && (sel.anchorNode.tagName == tagName)) { 465 return true; 466 } 467 if (sel.focusNode.tagName && (sel.focusNode.tagName == tagName)) { 468 return true; 469 } 470 if (sel.anchorNode.parentNode.tagName && 471 (sel.anchorNode.parentNode.tagName == tagName)) { 472 return true; 473 } 474 if (sel.focusNode.parentNode.tagName && 475 (sel.focusNode.parentNode.tagName == tagName)) { 476 return true; 477 } 478 var docFrag = sel.getRangeAt(0).cloneContents(); 479 var span = document.createElement('span'); 480 span.appendChild(docFrag); 481 return (span.getElementsByTagName(tagName).length > 0); 482 }; 483 484 /** 485 * Selects text within a text node. 486 * 487 * Note that the input node MUST be of type TEXT; otherwise, the offset 488 * count would not mean # of characters - this is because of the way Range 489 * works in JavaScript. 490 * 491 * @param {Node} textNode The text node to select text within. 492 * @param {number} start The start of the selection. 493 * @param {number} end The end of the selection. 494 */ 495 cvox.SelectionUtil.selectText = function(textNode, start, end) { 496 var newRange = document.createRange(); 497 newRange.setStart(textNode, start); 498 newRange.setEnd(textNode, end); 499 var sel = window.getSelection(); 500 sel.removeAllRanges(); 501 sel.addRange(newRange); 502 }; 503 504 /** 505 * Selects all the text in a given node. 506 * 507 * @param {Node} node The target node. 508 */ 509 cvox.SelectionUtil.selectAllTextInNode = function(node) { 510 var newRange = document.createRange(); 511 newRange.setStart(node, 0); 512 newRange.setEndAfter(node); 513 var sel = window.getSelection(); 514 sel.removeAllRanges(); 515 sel.addRange(newRange); 516 }; 517 518 /** 519 * Collapses the selection to the start. If nothing is selected, 520 * selects the beginning of the given node. 521 * 522 * @param {Node} node The target node. 523 */ 524 cvox.SelectionUtil.collapseToStart = function(node) { 525 var sel = window.getSelection(); 526 var cursorNode = sel.anchorNode; 527 var cursorOffset = sel.anchorOffset; 528 if (cursorNode == null) { 529 cursorNode = node; 530 cursorOffset = 0; 531 } 532 var newRange = document.createRange(); 533 newRange.setStart(cursorNode, cursorOffset); 534 newRange.setEnd(cursorNode, cursorOffset); 535 sel.removeAllRanges(); 536 sel.addRange(newRange); 537 }; 538 539 /** 540 * Collapses the selection to the end. If nothing is selected, 541 * selects the end of the given node. 542 * 543 * @param {Node} node The target node. 544 */ 545 cvox.SelectionUtil.collapseToEnd = function(node) { 546 var sel = window.getSelection(); 547 var cursorNode = sel.focusNode; 548 var cursorOffset = sel.focusOffset; 549 if (cursorNode == null) { 550 cursorNode = node; 551 cursorOffset = 0; 552 } 553 var newRange = document.createRange(); 554 newRange.setStart(cursorNode, cursorOffset); 555 newRange.setEnd(cursorNode, cursorOffset); 556 sel.removeAllRanges(); 557 sel.addRange(newRange); 558 }; 559 560 /** 561 * Retrieves all the text within a selection. 562 * 563 * Note that this can be different than simply using the string from 564 * window.getSelection() as this will account for IMG nodes, etc. 565 * 566 * @return {string} The string of text contained in the current selection. 567 */ 568 cvox.SelectionUtil.getText = function() { 569 var sel = window.getSelection(); 570 if (cvox.SelectionUtil.hasContentWithTag(sel, 'IMG')) { 571 var text = ''; 572 var docFrag = sel.getRangeAt(0).cloneContents(); 573 var span = document.createElement('span'); 574 span.appendChild(docFrag); 575 var leafNodes = cvox.XpathUtil.getLeafNodes(span); 576 for (var i = 0, node; node = leafNodes[i]; i++) { 577 text = text + ' ' + cvox.DomUtil.getName(node); 578 } 579 return text; 580 } else { 581 return this.getSelectionText_(); 582 } 583 }; 584 585 /** 586 * Returns the selection as text instead of a selection object. Note that this 587 * function must be used in place of getting text directly from the DOM 588 * if you want i18n tests to pass. 589 * 590 * @return {string} The text. 591 */ 592 cvox.SelectionUtil.getSelectionText_ = function() { 593 return '' + window.getSelection(); 594 }; 595 596 597 /** 598 * Returns a range as text instead of a selection object. Note that this 599 * function must be used in place of getting text directly from the DOM 600 * if you want i18n tests to pass. 601 * 602 * @param {Range} range A range. 603 * @return {string} The text. 604 */ 605 cvox.SelectionUtil.getRangeText = function(range) { 606 if (range) 607 return range.cloneContents().textContent.replace(/\s+/g, ' '); 608 else 609 return ''; 610 }; 611