Home | History | Annotate | Download | only in common
      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