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