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.
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.
13 * 5. Move by character.
16 goog
.provide('cvox.TraverseContent');
18 goog
.require('cvox.CursorSelection');
19 goog
.require('cvox.DomUtil');
20 goog
.require('cvox.SelectionUtil');
21 goog
.require('cvox.TraverseUtil');
24 * Moves a selection around a document or within a provided DOM object.
27 * @param {Node=} domObj a DOM node (optional).
29 cvox
.TraverseContent = function(domObj
) {
31 this.currentDomObj
= domObj
;
33 this.currentDomObj
= document
.body
;
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).
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
));
47 // Ignoring this error so that it will not break everything else.
48 window
.console
.log('Error: Unselectable node:');
49 window
.console
.log(domObj
);
52 goog
.addSingletonGetter(cvox
.TraverseContent
);
55 * Whether the last navigated selection only contained whitespace.
58 cvox
.TraverseContent
.prototype.lastSelectionWasWhitespace
= false;
61 * Whether we should skip whitespace when traversing individual characters.
64 cvox
.TraverseContent
.prototype.skipWhitespace
= false;
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.
73 cvox
.TraverseContent
.prototype.skipInvalidSelections
= true;
76 * If line and sentence navigation should break at <a> links.
79 cvox
.TraverseContent
.prototype.breakAtLinks
= true;
82 * The string constant for character granularity.
86 cvox
.TraverseContent
.kCharacter
= 'character';
89 * The string constant for word granularity.
93 cvox
.TraverseContent
.kWord
= 'word';
96 * The string constant for sentence granularity.
100 cvox
.TraverseContent
.kSentence
= 'sentence';
103 * The string constant for line granularity.
107 cvox
.TraverseContent
.kLine
= 'line';
110 * The string constant for paragraph granularity.
114 cvox
.TraverseContent
.kParagraph
= 'paragraph';
117 * A constant array of all granularities.
118 * @type {Array<string>}
121 cvox
.TraverseContent
.kAllGrains
=
122 [cvox
.TraverseContent
.kParagraph
,
123 cvox
.TraverseContent
.kSentence
,
124 cvox
.TraverseContent
.kLine
,
125 cvox
.TraverseContent
.kWord
,
126 cvox
.TraverseContent
.kCharacter
];
129 * Set the current position to match the current WebKit selection.
131 cvox
.TraverseContent
.prototype.syncToSelection = function() {
132 this.normalizeSelection();
134 var selection
= window
.getSelection();
135 if (!selection
|| !selection
.anchorNode
|| !selection
.focusNode
) {
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
));
147 * Set the start and end cursors to the selection.
148 * @param {cvox.CursorSelection} sel The selection.
150 cvox
.TraverseContent
.prototype.syncToCursorSelection = function(sel
) {
151 this.startCursor_
= sel
.start
.clone();
152 this.endCursor_
= sel
.end
.clone();
156 * Get the cursor selection.
157 * @return {cvox.CursorSelection} The selection.
159 cvox
.TraverseContent
.prototype.getCurrentCursorSelection = function() {
160 return new cvox
.CursorSelection(this.startCursor_
, this.endCursor_
);
164 * Set the WebKit selection based on the current position.
166 cvox
.TraverseContent
.prototype.updateSelection = function() {
167 cvox
.TraverseUtil
.setSelection(this.startCursor_
, this.endCursor_
);
168 cvox
.SelectionUtil
.scrollToSelection(window
.getSelection());
172 * Get the current position as a range.
173 * @return {Range} The current range.
175 cvox
.TraverseContent
.prototype.getCurrentRange = function() {
176 var range
= document
.createRange();
178 range
.setStart(this.startCursor_
.node
, this.startCursor_
.index
);
179 range
.setEnd(this.endCursor_
.node
, this.endCursor_
.index
);
181 console
.log('Invalid range ');
187 * Get the current text content as a string.
188 * @return {string} The current spanned content.
190 cvox
.TraverseContent
.prototype.getCurrentText = function() {
191 return cvox
.SelectionUtil
.getRangeText(this.getCurrentRange());
195 * Collapse to the end of the range.
197 cvox
.TraverseContent
.prototype.collapseToEnd = function() {
198 this.startCursor_
= this.endCursor_
.clone();
202 * Collapse to the start of the range.
204 cvox
.TraverseContent
.prototype.collapseToStart = function() {
205 this.endCursor_
= this.startCursor_
.clone();
209 * Moves selection forward.
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.
217 cvox
.TraverseContent
.prototype.moveNext = function(grain
) {
218 var breakTags
= this.getBreakTags();
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
;
225 var range
= this.getCurrentRange();
226 if (!cvox
.SelectionUtil
.isRangeValid(range
)) {
227 skipWhitespace
= true;
230 var elementsEntered
= [];
231 var elementsLeft
= [];
234 if (grain
=== cvox
.TraverseContent
.kSentence
) {
235 str
= cvox
.TraverseUtil
.getNextSentence(
236 this.startCursor_
, this.endCursor_
, elementsEntered
, elementsLeft
,
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
,
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
,
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
,
263 // We reached the end of the document.
267 range
= this.getCurrentRange();
268 var isInvalid
= !range
.getBoundingClientRect();
269 } while (this.skipInvalidSelections
&& isInvalid
);
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;
279 while (!cvox
.SelectionUtil
.isRangeValid(this.getCurrentRange())) {
280 if (this.moveNext(grain
) == null) {
286 this.lastSelectionWasWhitespace
= false;
289 return this.getCurrentText();
294 * Moves selection backward.
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.
302 cvox
.TraverseContent
.prototype.movePrev = function(grain
) {
303 var breakTags
= this.getBreakTags();
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
;
310 var range
= this.getCurrentRange();
311 if (!cvox
.SelectionUtil
.isRangeValid(range
)) {
312 skipWhitespace
= true;
315 var elementsEntered
= [];
316 var elementsLeft
= [];
319 if (grain
=== cvox
.TraverseContent
.kSentence
) {
320 str
= cvox
.TraverseUtil
.getPreviousSentence(
321 this.startCursor_
, this.endCursor_
, elementsEntered
, elementsLeft
,
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
,
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
,
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
,
348 // We reached the end of the document.
352 range
= this.getCurrentRange();
353 var isInvalid
= !range
.getBoundingClientRect();
354 } while (this.skipInvalidSelections
&& isInvalid
);
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;
364 while (!cvox
.SelectionUtil
.isRangeValid(this.getCurrentRange())) {
365 if (this.movePrev(grain
) == null) {
371 this.lastSelectionWasWhitespace
= false;
374 return this.getCurrentText();
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.
382 * @return {Object} An associative array mapping a tag name to true if
383 * it should break a sentence or line.
385 cvox
.TraverseContent
.prototype.getBreakTags = function() {
387 'A': this.breakAtLinks
,
394 * Selects the next element of the document or within the provided DOM object.
395 * Scrolls the window as appropriate.
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.
404 cvox
.TraverseContent
.prototype.nextElement = function(grain
, domObj
) {
405 if (domObj
!= null) {
406 this.currentDomObj
= domObj
;
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
))) {
423 * Selects the previous element of the document or within the provided DOM
424 * object. Scrolls the window as appropriate.
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.
433 cvox
.TraverseContent
.prototype.prevElement = function(grain
, domObj
) {
434 if (domObj
!= null) {
435 this.currentDomObj
= domObj
;
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
))) {
451 * Make sure that exactly one item is selected. If there's no selection,
452 * set the selection to the start of the document.
454 cvox
.TraverseContent
.prototype.normalizeSelection = function() {
455 var selection
= window
.getSelection();
456 if (selection
.rangeCount
< 1) {
457 // Before the user has clicked a freshly-loaded page
459 var range
= document
.createRange();
460 range
.setStart(this.currentDomObj
, 0);
461 range
.setEnd(this.currentDomObj
, 0);
463 selection
.removeAllRanges();
464 selection
.addRange(range
);
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
));
475 * Resets the selection.
477 * @param {Node=} domObj a DOM node. Optional.
480 cvox
.TraverseContent
.prototype.reset = function(domObj
) {
481 window
.getSelection().removeAllRanges();