selection_util.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 * @fileoverview A collection of JavaScript utilities used to improve selection
7 * at different granularities.
8 */
9
10
11goog.provide('cvox.SelectionUtil');
12
13goog.require('cvox.DomUtil');
14goog.require('cvox.XpathUtil');
15
16/**
17 * Utilities for improving selection.
18 * @constructor
19 */
20cvox.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 */
30cvox.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 */
57cvox.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 */
90cvox.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 */
139cvox.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 */
160cvox.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 */
187cvox.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 */
199cvox.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 */
213cvox.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 */
229cvox.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 */
261cvox.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 */
316cvox.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
333cvox.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 */
353cvox.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 */
373cvox.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 */
395cvox.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 */
417cvox.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 */
438cvox.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 */
460cvox.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 */
495cvox.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 */
509cvox.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 */
524cvox.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 */
545cvox.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 */
568cvox.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 */
592cvox.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 */
605cvox.SelectionUtil.getRangeText = function(range) {
606  if (range)
607    return range.cloneContents().textContent.replace(/\s+/g, ' ');
608  else
609    return '';
610};
611