// Copyright 2014 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /** * @fileoverview A collection of JavaScript utilities used to improve selection * at different granularities. */ goog.provide('cvox.SelectionUtil'); goog.require('cvox.DomUtil'); goog.require('cvox.XpathUtil'); /** * Utilities for improving selection. * @constructor */ cvox.SelectionUtil = function() {}; /** * Cleans up a paragraph selection acquired by extending forward. * In this context, a paragraph selection is 'clean' when the focus * node (the end of the selection) is not on a text node. * @param {Selection} sel The paragraph-length selection. * @return {boolean} True if the selection has been cleaned. * False if the selection cannot be cleaned without invalid extension. */ cvox.SelectionUtil.cleanUpParagraphForward = function(sel) { var expand = true; // nodeType:3 == TEXT_NODE while (sel.focusNode.nodeType == 3) { // Ending with a text node, which is incorrect. Keep extending forward. var fnode = sel.focusNode; var foffset = sel.focusOffset; sel.modify('extend', 'forward', 'sentence'); if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { // Nothing more to be done, cannot extend forward further. return false; } } return true; }; /** * Cleans up a paragraph selection acquired by extending backward. * In this context, a paragraph selection is 'clean' when the focus * node (the end of the selection) is not on a text node. * @param {Selection} sel The paragraph-length selection. * @return {boolean} True if the selection has been cleaned. * False if the selection cannot be cleaned without invalid extension. */ cvox.SelectionUtil.cleanUpParagraphBack = function(sel) { var expand = true; var fnode; var foffset; // nodeType:3 == TEXT_NODE while (sel.focusNode.nodeType == 3) { // Ending with a text node, which is incorrect. Keep extending backward. fnode = sel.focusNode; foffset = sel.focusOffset; sel.modify('extend', 'backward', 'sentence'); if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { // Nothing more to be done, cannot extend backward further. return true; } } return true; }; /** * Cleans up a sentence selection by extending forward. * In this context, a sentence selection is 'clean' when the focus * node (the end of the selection) is either: * - not on a text node * - on a text node that ends with a period or a space * @param {Selection} sel The sentence-length selection. * @return {boolean} True if the selection has been cleaned. * False if the selection cannot be cleaned without invalid extension. */ cvox.SelectionUtil.cleanUpSentence = function(sel) { var expand = true; var lastSelection; var lastSelectionOffset; while (expand) { // nodeType:3 == TEXT_NODE if (sel.focusNode.nodeType == 3) { // The focus node is of type text, check end for period var fnode = sel.focusNode; var foffset = sel.focusOffset; if (sel.rangeCount > 0 && sel.getRangeAt(0).endOffset > 0) { if (fnode.substringData(sel.getRangeAt(0).endOffset - 1, 1) == '.') { // Text node ends with period. return true; } else if (fnode.substringData(sel.getRangeAt(0).endOffset - 1, 1) == ' ') { // Text node ends with space. return true; } else { // Text node does not end with period or space. Extend forward. sel.modify('extend', 'forward', 'sentence'); if ((fnode == sel.focusNode) && (foffset == sel.focusOffset)) { // Nothing more to be done, cannot extend forward any further. return false; } } } else { return true; } } else { // Focus node is not text node, no further cleaning required. return true; } } return true; }; /** * Finds the starting position (height from top and left width) of a * selection in a document. * @param {Selection} sel The selection. * @return {Array} The coordinates [top, left] of the selection. */ cvox.SelectionUtil.findSelPosition = function(sel) { if (sel.rangeCount == 0) { return [0, 0]; } var clientRect = sel.getRangeAt(0).getBoundingClientRect(); if (!clientRect) { return [0, 0]; } var top = window.pageYOffset + clientRect.top; var left = window.pageXOffset + clientRect.left; return [top, left]; }; /** * Calculates the horizontal and vertical position of a node * @param {Node} targetNode The node. * @return {Array} The coordinates [top, left] of the node. */ cvox.SelectionUtil.findTopLeftPosition = function(targetNode) { var left = 0; var top = 0; var obj = targetNode; if (obj.offsetParent) { left = obj.offsetLeft; top = obj.offsetTop; obj = obj.offsetParent; while (obj !== null) { left += obj.offsetLeft; top += obj.offsetTop; obj = obj.offsetParent; } } return [top, left]; }; /** * Checks the contents of a selection for meaningful content. * @param {Selection} sel The selection. * @return {boolean} True if the selection is valid. False if the selection * contains only whitespace or is an empty string. */ cvox.SelectionUtil.isSelectionValid = function(sel) { var regExpWhiteSpace = new RegExp(/^\s+$/); return (! ((regExpWhiteSpace.test(sel.toString())) || (sel.toString() == ''))); }; /** * Checks the contents of a range for meaningful content. * @param {Range} range The range. * @return {boolean} True if the range is valid. False if the range * contains only whitespace or is an empty string. */ cvox.SelectionUtil.isRangeValid = function(range) { var text = range.cloneContents().textContent; var regExpWhiteSpace = new RegExp(/^\s+$/); return (! ((regExpWhiteSpace.test(text)) || (text == ''))); }; /** * Returns absolute top and left positions of an element. * * @param {!Node} node The element for which to compute the position. * @return {Array} Index 0 is the left; index 1 is the top. * @private */ cvox.SelectionUtil.findPos_ = function(node) { var curLeft = 0; var curTop = 0; if (node.offsetParent) { do { curLeft += node.offsetLeft; curTop += node.offsetTop; } while (node = node.offsetParent); } return [curLeft, curTop]; }; /** * Scrolls node in its parent node such the given node is visible. * @param {Node} focusNode The node. */ cvox.SelectionUtil.scrollElementsToView = function(focusNode) { // First, walk up the DOM until we find a node with a bounding rectangle. while (focusNode && !focusNode.getBoundingClientRect) { focusNode = focusNode.parentElement; } if (!focusNode) { return; } // Walk up the DOM, ensuring each element is visible inside its parent. var node = focusNode; var parentNode = node.parentElement; while (node != document.body && parentNode) { node.scrollTop = node.offsetTop; node.scrollLeft = node.offsetLeft; node = parentNode; parentNode = node.parentElement; } // Center the active element on the page once we know it's visible. var pos = cvox.SelectionUtil.findPos_(focusNode); window.scrollTo(pos[0] - window.innerWidth / 2, pos[1] - window.innerHeight / 2); }; /** * Scrolls the selection into view if it is out of view in the current window. * Inspired by workaround for already-on-screen elements @ * http:// * www.performantdesign.com/2009/08/26/scrollintoview-but-only-if-out-of-view/ * @param {Selection} sel The selection to be scrolled into view. */ cvox.SelectionUtil.scrollToSelection = function(sel) { if (sel.rangeCount == 0) { return; } // First, scroll all parent elements into view. Later, move the body // which works slightly differently. cvox.SelectionUtil.scrollElementsToView(sel.focusNode); var pos = cvox.SelectionUtil.findSelPosition(sel); var top = pos[0]; var left = pos[1]; var scrolledVertically = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop; var pageHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; var pageWidth = window.innerWidth || document.documentElement.innerWidth || document.body.clientWidth; if (left < pageWidth) { left = 0; } // window.scroll puts specified pixel in upper left of window if ((scrolledVertically + pageHeight) < top) { // Align with bottom of page var diff = top - pageHeight; window.scroll(left, diff + 100); } else if (top < scrolledVertically) { // Align with top of page window.scroll(left, top - 100); } }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Determine whether a node's text content is entirely whitespace. * * Throughout, whitespace is defined as one of the characters * "\t" TAB \u0009 * "\n" LF \u000A * "\r" CR \u000D * " " SPC \u0020 * * This does not use Javascript's "\s" because that includes non-breaking * spaces (and also some other characters). * * @param {Node} node A node implementing the |CharacterData| interface (i.e., * a |Text|, |Comment|, or |CDATASection| node. * @return {boolean} True if all of the text content of |node| is whitespace, * otherwise false. */ cvox.SelectionUtil.isAllWs = function(node) { // Use ECMA-262 Edition 3 String and RegExp features return !(/[^\t\n\r ]/.test(node.data)); }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Determine if a node should be ignored by the iterator functions. * * @param {Node} node An object implementing the DOM1 |Node| interface. * @return {boolean} True if the node is: * 1) A |Text| node that is all whitespace * 2) A |Comment| node * and otherwise false. */ cvox.SelectionUtil.isIgnorable = function(node) { return (node.nodeType == 8) || // A comment node ((node.nodeType == 3) && cvox.SelectionUtil.isAllWs(node)); // a text node, all ws }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Version of |previousSibling| that skips nodes that are entirely * whitespace or comments. (Normally |previousSibling| is a property * of all DOM nodes that gives the sibling node, the node that is * a child of the same parent, that occurs immediately before the * reference node.) * * @param {Node} sib The reference node. * @return {Node} Either: * 1) The closest previous sibling to |sib| that is not * ignorable according to |isIgnorable|, or * 2) null if no such node exists. */ cvox.SelectionUtil.nodeBefore = function(sib) { while ((sib = sib.previousSibling)) { if (!cvox.SelectionUtil.isIgnorable(sib)) { return sib; } } return null; }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Version of |nextSibling| that skips nodes that are entirely * whitespace or comments. * * @param {Node} sib The reference node. * @return {Node} Either: * 1) The closest next sibling to |sib| that is not * ignorable according to |isIgnorable|, or * 2) null if no such node exists. */ cvox.SelectionUtil.nodeAfter = function(sib) { while ((sib = sib.nextSibling)) { if (!cvox.SelectionUtil.isIgnorable(sib)) { return sib; } } return null; }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Version of |lastChild| that skips nodes that are entirely * whitespace or comments. (Normally |lastChild| is a property * of all DOM nodes that gives the last of the nodes contained * directly in the reference node.) * * @param {Node} par The reference node. * @return {Node} Either: * 1) The last child of |sib| that is not * ignorable according to |isIgnorable|, or * 2) null if no such node exists. */ cvox.SelectionUtil.lastChildNode = function(par) { var res = par.lastChild; while (res) { if (!cvox.SelectionUtil.isIgnorable(res)) { return res; } res = res.previousSibling; } return null; }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Version of |firstChild| that skips nodes that are entirely * whitespace and comments. * * @param {Node} par The reference node. * @return {Node} Either: * 1) The first child of |sib| that is not * ignorable according to |isIgnorable|, or * 2) null if no such node exists. */ cvox.SelectionUtil.firstChildNode = function(par) { var res = par.firstChild; while (res) { if (!cvox.SelectionUtil.isIgnorable(res)) { return res; } res = res.nextSibling; } return null; }; /** * This is from https://developer.mozilla.org/en/Whitespace_in_the_DOM * Version of |data| that doesn't include whitespace at the beginning * and end and normalizes all whitespace to a single space. (Normally * |data| is a property of text nodes that gives the text of the node.) * * @param {Node} txt The text node whose data should be returned. * @return {string} A string giving the contents of the text node with * whitespace collapsed. */ cvox.SelectionUtil.dataOf = function(txt) { var data = txt.data; // Use ECMA-262 Edition 3 String and RegExp features data = data.replace(/[\t\n\r ]+/g, ' '); if (data.charAt(0) == ' ') { data = data.substring(1, data.length); } if (data.charAt(data.length - 1) == ' ') { data = data.substring(0, data.length - 1); } return data; }; /** * Returns true if the selection has content from at least one node * that has the specified tagName. * * @param {Selection} sel The selection. * @param {string} tagName Tagname that the selection should be checked for. * @return {boolean} True if the selection has content from at least one node * with the specified tagName. */ cvox.SelectionUtil.hasContentWithTag = function(sel, tagName) { if (!sel || !sel.anchorNode || !sel.focusNode) { return false; } if (sel.anchorNode.tagName && (sel.anchorNode.tagName == tagName)) { return true; } if (sel.focusNode.tagName && (sel.focusNode.tagName == tagName)) { return true; } if (sel.anchorNode.parentNode.tagName && (sel.anchorNode.parentNode.tagName == tagName)) { return true; } if (sel.focusNode.parentNode.tagName && (sel.focusNode.parentNode.tagName == tagName)) { return true; } var docFrag = sel.getRangeAt(0).cloneContents(); var span = document.createElement('span'); span.appendChild(docFrag); return (span.getElementsByTagName(tagName).length > 0); }; /** * Selects text within a text node. * * Note that the input node MUST be of type TEXT; otherwise, the offset * count would not mean # of characters - this is because of the way Range * works in JavaScript. * * @param {Node} textNode The text node to select text within. * @param {number} start The start of the selection. * @param {number} end The end of the selection. */ cvox.SelectionUtil.selectText = function(textNode, start, end) { var newRange = document.createRange(); newRange.setStart(textNode, start); newRange.setEnd(textNode, end); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(newRange); }; /** * Selects all the text in a given node. * * @param {Node} node The target node. */ cvox.SelectionUtil.selectAllTextInNode = function(node) { var newRange = document.createRange(); newRange.setStart(node, 0); newRange.setEndAfter(node); var sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(newRange); }; /** * Collapses the selection to the start. If nothing is selected, * selects the beginning of the given node. * * @param {Node} node The target node. */ cvox.SelectionUtil.collapseToStart = function(node) { var sel = window.getSelection(); var cursorNode = sel.anchorNode; var cursorOffset = sel.anchorOffset; if (cursorNode == null) { cursorNode = node; cursorOffset = 0; } var newRange = document.createRange(); newRange.setStart(cursorNode, cursorOffset); newRange.setEnd(cursorNode, cursorOffset); sel.removeAllRanges(); sel.addRange(newRange); }; /** * Collapses the selection to the end. If nothing is selected, * selects the end of the given node. * * @param {Node} node The target node. */ cvox.SelectionUtil.collapseToEnd = function(node) { var sel = window.getSelection(); var cursorNode = sel.focusNode; var cursorOffset = sel.focusOffset; if (cursorNode == null) { cursorNode = node; cursorOffset = 0; } var newRange = document.createRange(); newRange.setStart(cursorNode, cursorOffset); newRange.setEnd(cursorNode, cursorOffset); sel.removeAllRanges(); sel.addRange(newRange); }; /** * Retrieves all the text within a selection. * * Note that this can be different than simply using the string from * window.getSelection() as this will account for IMG nodes, etc. * * @return {string} The string of text contained in the current selection. */ cvox.SelectionUtil.getText = function() { var sel = window.getSelection(); if (cvox.SelectionUtil.hasContentWithTag(sel, 'IMG')) { var text = ''; var docFrag = sel.getRangeAt(0).cloneContents(); var span = document.createElement('span'); span.appendChild(docFrag); var leafNodes = cvox.XpathUtil.getLeafNodes(span); for (var i = 0, node; node = leafNodes[i]; i++) { text = text + ' ' + cvox.DomUtil.getName(node); } return text; } else { return this.getSelectionText_(); } }; /** * Returns the selection as text instead of a selection object. Note that this * function must be used in place of getting text directly from the DOM * if you want i18n tests to pass. * * @return {string} The text. * @private */ cvox.SelectionUtil.getSelectionText_ = function() { return '' + window.getSelection(); }; /** * Returns a range as text instead of a selection object. Note that this * function must be used in place of getting text directly from the DOM * if you want i18n tests to pass. * * @param {Range} range A range. * @return {string} The text. */ cvox.SelectionUtil.getRangeText = function(range) { if (range) return range.cloneContents().textContent.replace(/\s+/g, ' '); else return ''; };