diff --git a/src/core/ReactDOMSelection.js b/src/core/ReactDOMSelection.js
new file mode 100644
index 0000000000..d36acf08d0
--- /dev/null
+++ b/src/core/ReactDOMSelection.js
@@ -0,0 +1,161 @@
+/**
+ * Copyright 2013 Facebook, Inc.
+ *
+ * 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.
+ *
+ * @providesModule ReactDOMSelection
+ */
+
+"use strict";
+
+var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
+var getTextContentAccessor = require('getTextContentAccessor');
+
+/**
+ * Get the appropriate anchor and focus node/offset pairs for IE.
+ *
+ * The catch here is that IE's selection API doesn't provide information
+ * about whether the selection is forward or backward, so we have to
+ * behave as though it's always forward.
+ *
+ * IE text differs from modern selection in that it behaves as though
+ * block elements end with a new line. This means character offsets will
+ * differ between the two APIs.
+ *
+ * @param {DOMElement} node
+ */
+function getIESelection(node) {
+ var selection = document.selection;
+ var selectedRange = selection.createRange();
+ var selectedLength = selectedRange.text.length;
+
+ // Duplicate selection so we can move range without breaking user selection.
+ var fromStart = selectedRange.duplicate();
+ fromStart.moveToElementText(node);
+ fromStart.setEndPoint('EndToStart', selectedRange);
+
+ var startOffset = fromStart.text.length;
+ var endOffset = startOffset + selectedLength;
+
+ return {
+ start: startOffset,
+ end: endOffset
+ };
+}
+
+/**
+ * @param {DOMElement} node
+ */
+function getModernSelection(node) {
+ var selection = global.getSelection();
+ var anchorNode = selection.anchorNode;
+ var anchorOffset = selection.anchorOffset;
+ var focusNode = selection.focusNode;
+ var focusOffset = selection.focusOffset;
+
+ var currentRange = selection.getRangeAt(0);
+ var rangeLength = currentRange.toString().length;
+
+ var tempRange = currentRange.cloneRange();
+ tempRange.selectNodeContents(node);
+ tempRange.setEnd(currentRange.startContainer, currentRange.startOffset);
+
+ var start = tempRange.toString().length;
+ var end = start + rangeLength;
+
+ // Detect whether the selection is backward.
+ var detectionRange = document.createRange();
+ detectionRange.setStart(anchorNode, anchorOffset);
+ detectionRange.setEnd(focusNode, focusOffset);
+ var isBackward = detectionRange.collapsed;
+ detectionRange.detach();
+
+ return {
+ start: isBackward ? end : start,
+ end: isBackward ? start : end
+ };
+}
+
+/**
+ * @param {DOMElement|DOMTextNode} node
+ * @param {object} offsets
+ */
+function setIESelection(node, offsets) {
+ var range = document.selection.createRange().duplicate();
+ var start, end;
+
+ if (typeof offsets.end === 'undefined') {
+ start = offsets.start;
+ end = start;
+ } else if (offsets.start > offsets.end) {
+ start = offsets.end;
+ end = offsets.start;
+ } else {
+ start = offsets.start;
+ end = offsets.end;
+ }
+
+ range.moveToElementText(node);
+ range.moveStart('character', start);
+ range.setEndPoint('EndToStart', range);
+ range.moveEnd('character', end - start);
+ range.select();
+}
+
+/**
+ * In modern non-IE browsers, we can support both forward and backward
+ * selections.
+ *
+ * Note: IE10+ supports the Selection object, but it does not support
+ * the `extend` method, which means that even in modern IE, it's not possible
+ * to programatically create a backward selection. Thus, for all IE
+ * versions, we use the old IE API to create our selections.
+ *
+ * @param {DOMElement|DOMTextNode} node
+ * @param {object} offsets
+ */
+function setModernSelection(node, offsets) {
+ var selection = global.getSelection();
+
+ var length = node[getTextContentAccessor()].length;
+ var start = Math.min(offsets.start, length);
+ var end = typeof offsets.end === 'undefined' ?
+ start : Math.min(offsets.end, length);
+
+ var startMarker = getNodeForCharacterOffset(node, start);
+ var endMarker = getNodeForCharacterOffset(node, end);
+
+ if (startMarker && endMarker) {
+ var range = document.createRange();
+ range.setStart(startMarker.node, startMarker.offset);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ selection.extend(endMarker.node, endMarker.offset);
+ range.detach();
+ }
+}
+
+var ReactDOMSelection = {
+ /**
+ * @param {DOMElement} node
+ */
+ get: document.selection ? getIESelection : getModernSelection,
+
+ /**
+ * @param {DOMElement|DOMTextNode} node
+ * @param {object} offsets
+ */
+ set: document.selection ? setIESelection : setModernSelection
+};
+
+module.exports = ReactDOMSelection;
diff --git a/src/core/ReactInputSelection.js b/src/core/ReactInputSelection.js
index 7ee469e50e..3875653e16 100644
--- a/src/core/ReactInputSelection.js
+++ b/src/core/ReactInputSelection.js
@@ -18,7 +18,7 @@
"use strict";
-var getTextContentAccessor = require('getTextContentAccessor');
+var ReactDOMSelection = require('ReactDOMSelection');
// It is not safe to read the document.activeElement property in IE if there's
// nothing focused.
@@ -88,103 +88,59 @@ var ReactInputSelection = {
* -@return {start: selectionStart, end: selectionEnd}
*/
getSelection: function(input) {
- var range;
- if (input.contentEditable === 'true' && window.getSelection) {
- var selection = window.getSelection();
- if (selection.rangeCount > 0) {
- range = selection.getRangeAt(0);
- var commonAncestor = range.commonAncestorContainer;
- if (commonAncestor && commonAncestor.nodeType === 3) {
- commonAncestor = commonAncestor.parentNode;
- }
- if (commonAncestor === input) {
- return {start: range.startOffset, end: range.endOffset};
- }
+ var selection;
+
+ if ('selectionStart' in input) {
+ // Modern browser with input or textarea.
+ selection = {
+ start: input.selectionStart,
+ end: input.selectionEnd
+ };
+ } else if (document.selection && input.nodeName === 'INPUT') {
+ // IE8 input.
+ var range = document.selection.createRange();
+ // There can only be one selection per document in IE, so it must
+ // be in our element.
+ if (range.parentElement() === input) {
+ selection = {
+ start: -range.moveStart('character', -input.value.length),
+ end: -range.moveEnd('character', -input.value.length)
+ };
}
- return {start: 0, end: 0};
- }
-
- if (!document.selection) {
- // Mozilla, Safari, etc.
- return {start: input.selectionStart, end: input.selectionEnd};
- }
-
- range = document.selection.createRange();
- if (range.parentElement() !== input) {
- // There can only be one selection per document in IE, so if the
- // containing element of the document's selection isn't our text field,
- // our text field must have no selection.
- return {start: 0, end: 0};
- }
-
- var value = input.value || input[getTextContentAccessor()];
- var length = value.length;
-
- if (input.nodeName === 'INPUT') {
- return {
- start: -range.moveStart('character', -length),
- end: -range.moveEnd('character', -length)
- };
} else {
- var range2 = range.duplicate();
- range2.moveToElementText(input);
- range2.setEndPoint('StartToEnd', range);
- var end = length - range2.text.length;
- range2.setEndPoint('StartToStart', range);
- return {
- start: length - range2.text.length,
- end: end
- };
+ // Content editable or old IE textarea.
+ selection = ReactDOMSelection.get(input);
}
+
+ return selection || {start: 0, end: 0};
},
/**
* @setSelection: Sets the selection bounds of a textarea or input and focuses
* the input.
* -@input Set selection bounds of this input or textarea
- * -@rangeObj Object of same form that is returned from get*
+ * -@offsets Object of same form that is returned from get*
*/
- setSelection: function(input, rangeObj) {
- var range;
- var start = rangeObj.start;
- var end = rangeObj.end;
+ setSelection: function(input, offsets) {
+ var start = offsets.start;
+ var end = offsets.end;
if (typeof end === 'undefined') {
end = start;
}
- if (document.selection) {
- // IE is inconsistent about character offsets when it comes to carriage
- // returns, so we need to manually take them into account
- if (input.tagName === 'TEXTAREA') {
- var cr_before =
- (input.value.slice(0, start).match(/\r/g) || []).length;
- var cr_inside =
- (input.value.slice(start, end).match(/\r/g) || []).length;
- start -= cr_before;
- end -= cr_before + cr_inside;
- }
- range = input.createTextRange();
+
+ if ('selectionStart' in input) {
+ input.selectionStart = start;
+ input.selectionEnd = Math.min(end, input.value.length);
+ } else if (document.selection && input.nodeName === 'INPUT') {
+ var range = input.createTextRange();
range.collapse(true);
range.moveStart('character', start);
range.moveEnd('character', end - start);
range.select();
} else {
- if (input.contentEditable === 'true') {
- if (input.childNodes.length === 1) {
- range = document.createRange();
- range.setStart(input.childNodes[0], start);
- range.setEnd(input.childNodes[0], end);
- var sel = window.getSelection();
- sel.removeAllRanges();
- sel.addRange(range);
- }
- } else {
- input.selectionStart = start;
- input.selectionEnd = Math.min(end, input.value.length);
- input.focus();
- }
+ ReactDOMSelection.set(input, offsets);
}
}
-
};
module.exports = ReactInputSelection;
diff --git a/src/dom/__tests__/getNodeForCharacterOffset-test.js b/src/dom/__tests__/getNodeForCharacterOffset-test.js
new file mode 100644
index 0000000000..1b7b0559de
--- /dev/null
+++ b/src/dom/__tests__/getNodeForCharacterOffset-test.js
@@ -0,0 +1,84 @@
+/**
+ * Copyright 2013 Facebook, Inc.
+ *
+ * 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.
+ *
+ * @jsx React.DOM
+ * @emails react-core
+ */
+
+/*jslint evil: true */
+
+"use strict";
+
+var getTestDocument = require('getTestDocument');
+
+var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
+
+// Create node from HTML string
+function createNode(html) {
+ var node = (getTestDocument() || document).createElement('div');
+ node.innerHTML = html;
+ return node;
+}
+
+// Check getNodeForCharacterOffset return value matches expected result.
+function expectNodeOffset(result, textContent, nodeOffset) {
+ expect(result.node.textContent).toBe(textContent);
+ expect(result.offset).toBe(nodeOffset);
+}
+
+describe('getNodeForCharacterOffset', function() {
+ it('should handle siblings', function() {
+ var node = createNode('123456789');
+
+ expectNodeOffset(getNodeForCharacterOffset(node, 0), '123', 0);
+ expectNodeOffset(getNodeForCharacterOffset(node, 4), '456', 1);
+ });
+
+ it('should handle trailing chars', function() {
+ var node = createNode('123456789');
+
+ expectNodeOffset(getNodeForCharacterOffset(node, 3), '123', 3);
+ expectNodeOffset(getNodeForCharacterOffset(node, 9), '789', 3);
+ });
+
+ it('should handle trees', function() {
+ var node = createNode(
+ '' +
+ '1' +
+ '' +
+ '' +
+ '2' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '3' +
+ '45' +
+ '' +
+ ''
+ );
+
+ expectNodeOffset(getNodeForCharacterOffset(node, 3), '3', 1);
+ expectNodeOffset(getNodeForCharacterOffset(node, 5), '45', 2);
+ expect(getNodeForCharacterOffset(node, 10)).toBeUndefined();
+ });
+
+ it('should handle non-existent offset', function() {
+ var node = createNode('123');
+
+ expect(getNodeForCharacterOffset(node, -1)).toBeUndefined();
+ expect(getNodeForCharacterOffset(node, 4)).toBeUndefined();
+ });
+});
diff --git a/src/dom/getNodeForCharacterOffset.js b/src/dom/getNodeForCharacterOffset.js
new file mode 100644
index 0000000000..4b01450e63
--- /dev/null
+++ b/src/dom/getNodeForCharacterOffset.js
@@ -0,0 +1,80 @@
+/**
+ * Copyright 2013 Facebook, Inc.
+ *
+ * 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.
+ *
+ * @providesModule getNodeForCharacterOffset
+ */
+
+"use strict";
+
+/**
+ * Given any node return the first leaf node without children.
+ *
+ * @param {DOMElement|DOMTextNode} node
+ * @return {DOMElement|DOMTextNode}
+ */
+function getLeafNode(node) {
+ while (node && node.firstChild) {
+ node = node.firstChild;
+ }
+ return node;
+}
+
+/**
+ * Get the next sibling within a container. This will walk up the
+ * DOM if a node's siblings have been exhausted.
+ *
+ * @param {DOMElement|DOMTextNode} node
+ * @return {?DOMElement|DOMTextNode}
+ */
+function getSiblingNode(node) {
+ while (node) {
+ if (node.nextSibling) {
+ return node.nextSibling;
+ }
+ node = node.parentNode;
+ }
+}
+
+/**
+ * Get object describing the nodes which contain characters at offset.
+ *
+ * @param {DOMElement|DOMTextNode} root
+ * @param {number} offset
+ * @return {?object}
+ */
+function getNodeForCharacterOffset(root, offset) {
+ var node = getLeafNode(root);
+ var nodeStart = 0;
+ var nodeEnd = 0;
+
+ while (node) {
+ if (node.nodeType == 3) {
+ nodeEnd = nodeStart + node.textContent.length;
+
+ if (nodeStart <= offset && nodeEnd >= offset) {
+ return {
+ node: node,
+ offset: offset - nodeStart
+ };
+ }
+
+ nodeStart = nodeEnd;
+ }
+
+ node = getLeafNode(getSiblingNode(node));
+ }
+}
+
+module.exports = getNodeForCharacterOffset;