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();