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;