blob: 21ba2118de19fc126ca52364de6f5b11c8008ac9 [file] [log] [blame]
// Copyright 2007 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* @fileoverview Utilities for working with text ranges in HTML documents.
*
* @author robbyw@google.com (Robby Walker)
*/
goog.provide('goog.dom.TextRange');
goog.require('goog.array');
goog.require('goog.dom');
goog.require('goog.dom.AbstractRange');
goog.require('goog.dom.RangeType');
goog.require('goog.dom.SavedRange');
goog.require('goog.dom.TagName');
goog.require('goog.dom.TextRangeIterator');
goog.require('goog.dom.browserrange');
goog.require('goog.string');
goog.require('goog.userAgent');
/**
* Create a new text selection with no properties. Do not use this constructor:
* use one of the goog.dom.Range.createFrom* methods instead.
* @constructor
* @extends {goog.dom.AbstractRange}
* @final
*/
goog.dom.TextRange = function() {
};
goog.inherits(goog.dom.TextRange, goog.dom.AbstractRange);
/**
* Create a new range wrapper from the given browser range object. Do not use
* this method directly - please use goog.dom.Range.createFrom* instead.
* @param {Range|TextRange} range The browser range object.
* @param {boolean=} opt_isReversed Whether the focus node is before the anchor
* node.
* @return {!goog.dom.TextRange} A range wrapper object.
*/
goog.dom.TextRange.createFromBrowserRange = function(range, opt_isReversed) {
return goog.dom.TextRange.createFromBrowserRangeWrapper_(
goog.dom.browserrange.createRange(range), opt_isReversed);
};
/**
* Create a new range wrapper from the given browser range wrapper.
* @param {goog.dom.browserrange.AbstractRange} browserRange The browser range
* wrapper.
* @param {boolean=} opt_isReversed Whether the focus node is before the anchor
* node.
* @return {!goog.dom.TextRange} A range wrapper object.
* @private
*/
goog.dom.TextRange.createFromBrowserRangeWrapper_ = function(browserRange,
opt_isReversed) {
var range = new goog.dom.TextRange();
// Initialize the range as a browser range wrapper type range.
range.browserRangeWrapper_ = browserRange;
range.isReversed_ = !!opt_isReversed;
return range;
};
/**
* Create a new range wrapper that selects the given node's text. Do not use
* this method directly - please use goog.dom.Range.createFrom* instead.
* @param {Node} node The node to select.
* @param {boolean=} opt_isReversed Whether the focus node is before the anchor
* node.
* @return {!goog.dom.TextRange} A range wrapper object.
*/
goog.dom.TextRange.createFromNodeContents = function(node, opt_isReversed) {
return goog.dom.TextRange.createFromBrowserRangeWrapper_(
goog.dom.browserrange.createRangeFromNodeContents(node),
opt_isReversed);
};
/**
* Create a new range wrapper that selects the area between the given nodes,
* accounting for the given offsets. Do not use this method directly - please
* use goog.dom.Range.createFrom* instead.
* @param {Node} anchorNode The node to start with.
* @param {number} anchorOffset The offset within the node to start.
* @param {Node} focusNode The node to end with.
* @param {number} focusOffset The offset within the node to end.
* @return {!goog.dom.TextRange} A range wrapper object.
*/
goog.dom.TextRange.createFromNodes = function(anchorNode, anchorOffset,
focusNode, focusOffset) {
var range = new goog.dom.TextRange();
range.isReversed_ = /** @suppress {missingRequire} */ (
goog.dom.Range.isReversed(anchorNode, anchorOffset,
focusNode, focusOffset));
// Avoid selecting terminal elements directly
if (goog.dom.isElement(anchorNode) && !goog.dom.canHaveChildren(anchorNode)) {
var parent = anchorNode.parentNode;
anchorOffset = goog.array.indexOf(parent.childNodes, anchorNode);
anchorNode = parent;
}
if (goog.dom.isElement(focusNode) && !goog.dom.canHaveChildren(focusNode)) {
var parent = focusNode.parentNode;
focusOffset = goog.array.indexOf(parent.childNodes, focusNode);
focusNode = parent;
}
// Initialize the range as a W3C style range.
if (range.isReversed_) {
range.startNode_ = focusNode;
range.startOffset_ = focusOffset;
range.endNode_ = anchorNode;
range.endOffset_ = anchorOffset;
} else {
range.startNode_ = anchorNode;
range.startOffset_ = anchorOffset;
range.endNode_ = focusNode;
range.endOffset_ = focusOffset;
}
return range;
};
// Representation 1: a browser range wrapper.
/**
* The browser specific range wrapper. This can be null if one of the other
* representations of the range is specified.
* @type {goog.dom.browserrange.AbstractRange?}
* @private
*/
goog.dom.TextRange.prototype.browserRangeWrapper_ = null;
// Representation 2: two endpoints specified as nodes + offsets
/**
* The start node of the range. This can be null if one of the other
* representations of the range is specified.
* @type {Node}
* @private
*/
goog.dom.TextRange.prototype.startNode_ = null;
/**
* The start offset of the range. This can be null if one of the other
* representations of the range is specified.
* @type {?number}
* @private
*/
goog.dom.TextRange.prototype.startOffset_ = null;
/**
* The end node of the range. This can be null if one of the other
* representations of the range is specified.
* @type {Node}
* @private
*/
goog.dom.TextRange.prototype.endNode_ = null;
/**
* The end offset of the range. This can be null if one of the other
* representations of the range is specified.
* @type {?number}
* @private
*/
goog.dom.TextRange.prototype.endOffset_ = null;
/**
* Whether the focus node is before the anchor node.
* @type {boolean}
* @private
*/
goog.dom.TextRange.prototype.isReversed_ = false;
// Method implementations
/**
* @return {!goog.dom.TextRange} A clone of this range.
* @override
*/
goog.dom.TextRange.prototype.clone = function() {
var range = new goog.dom.TextRange();
range.browserRangeWrapper_ =
this.browserRangeWrapper_ && this.browserRangeWrapper_.clone();
range.startNode_ = this.startNode_;
range.startOffset_ = this.startOffset_;
range.endNode_ = this.endNode_;
range.endOffset_ = this.endOffset_;
range.isReversed_ = this.isReversed_;
return range;
};
/** @override */
goog.dom.TextRange.prototype.getType = function() {
return goog.dom.RangeType.TEXT;
};
/** @override */
goog.dom.TextRange.prototype.getBrowserRangeObject = function() {
return this.getBrowserRangeWrapper_().getBrowserRange();
};
/** @override */
goog.dom.TextRange.prototype.setBrowserRangeObject = function(nativeRange) {
// Test if it's a control range by seeing if a control range only method
// exists.
if (goog.dom.AbstractRange.isNativeControlRange(nativeRange)) {
return false;
}
this.browserRangeWrapper_ = goog.dom.browserrange.createRange(
nativeRange);
this.clearCachedValues_();
return true;
};
/**
* Clear all cached values.
* @private
*/
goog.dom.TextRange.prototype.clearCachedValues_ = function() {
this.startNode_ = this.startOffset_ = this.endNode_ = this.endOffset_ = null;
};
/** @override */
goog.dom.TextRange.prototype.getTextRangeCount = function() {
return 1;
};
/** @override */
goog.dom.TextRange.prototype.getTextRange = function(i) {
return this;
};
/**
* @return {!goog.dom.browserrange.AbstractRange} The range wrapper object.
* @private
*/
goog.dom.TextRange.prototype.getBrowserRangeWrapper_ = function() {
return this.browserRangeWrapper_ ||
(this.browserRangeWrapper_ = goog.dom.browserrange.createRangeFromNodes(
this.getStartNode(), this.getStartOffset(),
this.getEndNode(), this.getEndOffset()));
};
/** @override */
goog.dom.TextRange.prototype.getContainer = function() {
return this.getBrowserRangeWrapper_().getContainer();
};
/** @override */
goog.dom.TextRange.prototype.getStartNode = function() {
return this.startNode_ ||
(this.startNode_ = this.getBrowserRangeWrapper_().getStartNode());
};
/** @override */
goog.dom.TextRange.prototype.getStartOffset = function() {
return this.startOffset_ != null ? this.startOffset_ :
(this.startOffset_ = this.getBrowserRangeWrapper_().getStartOffset());
};
/** @override */
goog.dom.TextRange.prototype.getStartPosition = function() {
return this.isReversed() ?
this.getBrowserRangeWrapper_().getEndPosition() :
this.getBrowserRangeWrapper_().getStartPosition();
};
/** @override */
goog.dom.TextRange.prototype.getEndNode = function() {
return this.endNode_ ||
(this.endNode_ = this.getBrowserRangeWrapper_().getEndNode());
};
/** @override */
goog.dom.TextRange.prototype.getEndOffset = function() {
return this.endOffset_ != null ? this.endOffset_ :
(this.endOffset_ = this.getBrowserRangeWrapper_().getEndOffset());
};
/** @override */
goog.dom.TextRange.prototype.getEndPosition = function() {
return this.isReversed() ?
this.getBrowserRangeWrapper_().getStartPosition() :
this.getBrowserRangeWrapper_().getEndPosition();
};
/**
* Moves a TextRange to the provided nodes and offsets.
* @param {Node} startNode The node to start with.
* @param {number} startOffset The offset within the node to start.
* @param {Node} endNode The node to end with.
* @param {number} endOffset The offset within the node to end.
* @param {boolean} isReversed Whether the range is reversed.
*/
goog.dom.TextRange.prototype.moveToNodes = function(startNode, startOffset,
endNode, endOffset,
isReversed) {
this.startNode_ = startNode;
this.startOffset_ = startOffset;
this.endNode_ = endNode;
this.endOffset_ = endOffset;
this.isReversed_ = isReversed;
this.browserRangeWrapper_ = null;
};
/** @override */
goog.dom.TextRange.prototype.isReversed = function() {
return this.isReversed_;
};
/** @override */
goog.dom.TextRange.prototype.containsRange = function(otherRange,
opt_allowPartial) {
var otherRangeType = otherRange.getType();
if (otherRangeType == goog.dom.RangeType.TEXT) {
return this.getBrowserRangeWrapper_().containsRange(
otherRange.getBrowserRangeWrapper_(), opt_allowPartial);
} else if (otherRangeType == goog.dom.RangeType.CONTROL) {
var elements = otherRange.getElements();
var fn = opt_allowPartial ? goog.array.some : goog.array.every;
return fn(elements, function(el) {
return this.containsNode(el, opt_allowPartial);
}, this);
}
return false;
};
/**
* Tests if the given node is in a document.
* @param {Node} node The node to check.
* @return {boolean} Whether the given node is in the given document.
*/
goog.dom.TextRange.isAttachedNode = function(node) {
if (goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(9)) {
var returnValue = false;
/** @preserveTry */
try {
returnValue = node.parentNode;
} catch (e) {
// IE sometimes throws Invalid Argument errors when a node is detached.
// Note: trying to return a value from the above try block can cause IE
// to crash. It is necessary to use the local returnValue
}
return !!returnValue;
} else {
return goog.dom.contains(node.ownerDocument.body, node);
}
};
/** @override */
goog.dom.TextRange.prototype.isRangeInDocument = function() {
// Ensure any cached nodes are in the document. IE also allows ranges to
// become detached, so we check if the range is still in the document as
// well for IE.
return (!this.startNode_ ||
goog.dom.TextRange.isAttachedNode(this.startNode_)) &&
(!this.endNode_ ||
goog.dom.TextRange.isAttachedNode(this.endNode_)) &&
(!(goog.userAgent.IE && !goog.userAgent.isDocumentModeOrHigher(9)) ||
this.getBrowserRangeWrapper_().isRangeInDocument());
};
/** @override */
goog.dom.TextRange.prototype.isCollapsed = function() {
return this.getBrowserRangeWrapper_().isCollapsed();
};
/** @override */
goog.dom.TextRange.prototype.getText = function() {
return this.getBrowserRangeWrapper_().getText();
};
/** @override */
goog.dom.TextRange.prototype.getHtmlFragment = function() {
// TODO(robbyw): Generalize the code in browserrange so it is static and
// just takes an iterator. This would mean we don't always have to create a
// browser range.
return this.getBrowserRangeWrapper_().getHtmlFragment();
};
/** @override */
goog.dom.TextRange.prototype.getValidHtml = function() {
return this.getBrowserRangeWrapper_().getValidHtml();
};
/** @override */
goog.dom.TextRange.prototype.getPastableHtml = function() {
// TODO(robbyw): Get any attributes the table or tr has.
var html = this.getValidHtml();
if (html.match(/^\s*<td\b/i)) {
// Match html starting with a TD.
html = '<table><tbody><tr>' + html + '</tr></tbody></table>';
} else if (html.match(/^\s*<tr\b/i)) {
// Match html starting with a TR.
html = '<table><tbody>' + html + '</tbody></table>';
} else if (html.match(/^\s*<tbody\b/i)) {
// Match html starting with a TBODY.
html = '<table>' + html + '</table>';
} else if (html.match(/^\s*<li\b/i)) {
// Match html starting with an LI.
var container = this.getContainer();
var tagType = goog.dom.TagName.UL;
while (container) {
if (container.tagName == goog.dom.TagName.OL) {
tagType = goog.dom.TagName.OL;
break;
} else if (container.tagName == goog.dom.TagName.UL) {
break;
}
container = container.parentNode;
}
html = goog.string.buildString('<', tagType, '>', html, '</', tagType, '>');
}
return html;
};
/**
* Returns a TextRangeIterator over the contents of the range. Regardless of
* the direction of the range, the iterator will move in document order.
* @param {boolean=} opt_keys Unused for this iterator.
* @return {!goog.dom.TextRangeIterator} An iterator over tags in the range.
* @override
*/
goog.dom.TextRange.prototype.__iterator__ = function(opt_keys) {
return new goog.dom.TextRangeIterator(this.getStartNode(),
this.getStartOffset(), this.getEndNode(), this.getEndOffset());
};
// RANGE ACTIONS
/** @override */
goog.dom.TextRange.prototype.select = function() {
this.getBrowserRangeWrapper_().select(this.isReversed_);
};
/** @override */
goog.dom.TextRange.prototype.removeContents = function() {
this.getBrowserRangeWrapper_().removeContents();
this.clearCachedValues_();
};
/**
* Surrounds the text range with the specified element (on Mozilla) or with a
* clone of the specified element (on IE). Returns a reference to the
* surrounding element if the operation was successful; returns null if the
* operation failed.
* @param {Element} element The element with which the selection is to be
* surrounded.
* @return {Element} The surrounding element (same as the argument on Mozilla,
* but not on IE), or null if unsuccessful.
*/
goog.dom.TextRange.prototype.surroundContents = function(element) {
var output = this.getBrowserRangeWrapper_().surroundContents(element);
this.clearCachedValues_();
return output;
};
/** @override */
goog.dom.TextRange.prototype.insertNode = function(node, before) {
var output = this.getBrowserRangeWrapper_().insertNode(node, before);
this.clearCachedValues_();
return output;
};
/** @override */
goog.dom.TextRange.prototype.surroundWithNodes = function(startNode, endNode) {
this.getBrowserRangeWrapper_().surroundWithNodes(startNode, endNode);
this.clearCachedValues_();
};
// SAVE/RESTORE
/** @override */
goog.dom.TextRange.prototype.saveUsingDom = function() {
return new goog.dom.DomSavedTextRange_(this);
};
// RANGE MODIFICATION
/** @override */
goog.dom.TextRange.prototype.collapse = function(toAnchor) {
var toStart = this.isReversed() ? !toAnchor : toAnchor;
if (this.browserRangeWrapper_) {
this.browserRangeWrapper_.collapse(toStart);
}
if (toStart) {
this.endNode_ = this.startNode_;
this.endOffset_ = this.startOffset_;
} else {
this.startNode_ = this.endNode_;
this.startOffset_ = this.endOffset_;
}
// Collapsed ranges can't be reversed
this.isReversed_ = false;
};
// SAVED RANGE OBJECTS
/**
* A SavedRange implementation using DOM endpoints.
* @param {goog.dom.AbstractRange} range The range to save.
* @constructor
* @extends {goog.dom.SavedRange}
* @private
*/
goog.dom.DomSavedTextRange_ = function(range) {
goog.dom.DomSavedTextRange_.base(this, 'constructor');
/**
* The anchor node.
* @type {Node}
* @private
*/
this.anchorNode_ = range.getAnchorNode();
/**
* The anchor node offset.
* @type {number}
* @private
*/
this.anchorOffset_ = range.getAnchorOffset();
/**
* The focus node.
* @type {Node}
* @private
*/
this.focusNode_ = range.getFocusNode();
/**
* The focus node offset.
* @type {number}
* @private
*/
this.focusOffset_ = range.getFocusOffset();
};
goog.inherits(goog.dom.DomSavedTextRange_, goog.dom.SavedRange);
/**
* @return {!goog.dom.AbstractRange} The restored range.
* @override
*/
goog.dom.DomSavedTextRange_.prototype.restoreInternal = function() {
return /** @suppress {missingRequire} */ (
goog.dom.Range.createFromNodes(this.anchorNode_, this.anchorOffset_,
this.focusNode_, this.focusOffset_));
};
/** @override */
goog.dom.DomSavedTextRange_.prototype.disposeInternal = function() {
goog.dom.DomSavedTextRange_.superClass_.disposeInternal.call(this);
this.anchorNode_ = null;
this.focusNode_ = null;
};