diff --git a/src/renderers/dom/shared/ReactDOMSelection.js b/src/renderers/dom/shared/ReactDOMSelection.js index 4aaa9e86dd8f8..1fbc0e4069f8b 100644 --- a/src/renderers/dom/shared/ReactDOMSelection.js +++ b/src/renderers/dom/shared/ReactDOMSelection.js @@ -11,23 +11,16 @@ 'use strict'; +var {TEXT_NODE} = require('HTMLNodeType'); + var getNodeForCharacterOffset = require('getNodeForCharacterOffset'); var getTextContentAccessor = require('getTextContentAccessor'); /** - * While `isCollapsed` is available on the Selection object and `collapsed` - * is available on the Range object, IE11 sometimes gets them wrong. - * If the anchor/focus nodes and offsets are the same, the range is collapsed. - */ -function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) { - return anchorNode === focusNode && anchorOffset === focusOffset; -} - -/** - * @param {DOMElement} node + * @param {DOMElement} outerNode * @return {?object} */ -function getModernOffsets(node) { +function getModernOffsets(outerNode) { var selection = window.getSelection && window.getSelection(); if (!selection || selection.rangeCount === 0) { @@ -39,59 +32,116 @@ function getModernOffsets(node) { var focusNode = selection.focusNode; var focusOffset = selection.focusOffset; - var currentRange = selection.getRangeAt(0); - - // In Firefox, range.startContainer and range.endContainer can be "anonymous - // divs", e.g. the up/down buttons on an . Anonymous - // divs do not seem to expose properties, triggering a "Permission denied - // error" if any of its properties are accessed. The only seemingly possible - // way to avoid erroring is to access a property that typically works for - // non-anonymous divs and catch any error that may otherwise arise. See + // In Firefox, anchorNode and focusNode can be "anonymous divs", e.g. the + // up/down buttons on an . Anonymous divs do not seem to + // expose properties, triggering a "Permission denied error" if any of its + // properties are accessed. The only seemingly possible way to avoid erroring + // is to access a property that typically works for non-anonymous divs and + // catch any error that may otherwise arise. See // https://bugzilla.mozilla.org/show_bug.cgi?id=208427 try { /* eslint-disable no-unused-expressions */ - currentRange.startContainer.nodeType; - currentRange.endContainer.nodeType; + anchorNode.nodeType; + focusNode.nodeType; /* eslint-enable no-unused-expressions */ } catch (e) { return null; } - // If the node and offset values are the same, the selection is collapsed. - // `Selection.isCollapsed` is available natively, but IE sometimes gets - // this value wrong. - var isSelectionCollapsed = isCollapsed( - selection.anchorNode, - selection.anchorOffset, - selection.focusNode, - selection.focusOffset, + return getModernOffsetsFromPoints( + outerNode, + anchorNode, + anchorOffset, + focusNode, + focusOffset, ); +} - var rangeLength = isSelectionCollapsed ? 0 : currentRange.toString().length; - - var tempRange = currentRange.cloneRange(); - tempRange.selectNodeContents(node); - tempRange.setEnd(currentRange.startContainer, currentRange.startOffset); +/** + * Returns {start, end} where `start` is the character/codepoint index of + * (anchorNode, anchorOffset) within the textContent of `outerNode`, and + * `end` is the index of (focusNode, focusOffset). + * + * Returns null if you pass in garbage input but we should probably just crash. + */ +function getModernOffsetsFromPoints( + outerNode, + anchorNode, + anchorOffset, + focusNode, + focusOffset, +) { + let length = 0; + let start = -1; + let end = -1; + let indexWithinAnchor = 0; + let indexWithinFocus = 0; + let node = outerNode; + let parentNode = null; + + outer: while (true) { + let next = null; + + while (true) { + if ( + node === anchorNode && + (anchorOffset === 0 || node.nodeType === TEXT_NODE) + ) { + start = length + anchorOffset; + } + if ( + node === focusNode && + (focusOffset === 0 || node.nodeType === TEXT_NODE) + ) { + end = length + focusOffset; + } + + if (node.nodeType === TEXT_NODE) { + length += node.nodeValue.length; + } + + if ((next = node.firstChild) === null) { + break; + } + // Moving from `node` to its first child `next`. + parentNode = node; + node = next; + } - var isTempRangeCollapsed = isCollapsed( - tempRange.startContainer, - tempRange.startOffset, - tempRange.endContainer, - tempRange.endOffset, - ); + while (true) { + if (node === outerNode) { + // If `outerNode` has children, this is always the second time visiting + // it. If it has no children, this is still the first loop, and the only + // valid selection is anchorNode and focusNode both equal to this node + // and both offsets 0, in which case we will have handled above. + break outer; + } + if (parentNode === anchorNode && ++indexWithinAnchor === anchorOffset) { + start = length; + } + if (parentNode === focusNode && ++indexWithinFocus === focusOffset) { + end = length; + } + if ((next = node.nextSibling) !== null) { + break; + } + node = parentNode; + parentNode = node.parentNode; + } - var start = isTempRangeCollapsed ? 0 : tempRange.toString().length; - var end = start + rangeLength; + // Moving from `node` to its next sibling `next`. + node = next; + } - // Detect whether the selection is backward. - var detectionRange = document.createRange(); - detectionRange.setStart(anchorNode, anchorOffset); - detectionRange.setEnd(focusNode, focusOffset); - var isBackward = detectionRange.collapsed; + if (start === -1 || end === -1) { + // This should never happen. (Would happen if the anchor/focus nodes aren't + // actually inside the passed-in node.) + return null; + } return { - start: isBackward ? end : start, - end: isBackward ? start : end, + start: start, + end: end, }; } @@ -129,6 +179,15 @@ function setModernOffsets(node, offsets) { var endMarker = getNodeForCharacterOffset(node, end); if (startMarker && endMarker) { + if ( + selection.rangeCount === 1 && + selection.anchorNode === startMarker.node && + selection.anchorOffset === startMarker.offset && + selection.focusNode === endMarker.node && + selection.focusOffset === endMarker.offset + ) { + return; + } var range = document.createRange(); range.setStart(startMarker.node, startMarker.offset); selection.removeAllRanges(); @@ -149,6 +208,9 @@ var ReactDOMSelection = { */ getOffsets: getModernOffsets, + // For tests. + getModernOffsetsFromPoints: getModernOffsetsFromPoints, + /** * @param {DOMElement|DOMTextNode} node * @param {object} offsets diff --git a/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js b/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js new file mode 100644 index 0000000000000..0e6e54ee7f660 --- /dev/null +++ b/src/renderers/dom/shared/__tests__/ReactDOMSelection-test.js @@ -0,0 +1,206 @@ +/** + * Copyright 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +var React; +var ReactDOM; +var ReactDOMSelection; +var invariant; + +var getModernOffsetsFromPoints; + +describe('ReactDOMSelection', () => { + beforeEach(() => { + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMSelection = require('ReactDOMSelection'); + invariant = require('invariant'); + + ({getModernOffsetsFromPoints} = ReactDOMSelection); + }); + + // Simple implementation to compare correctness. React's old implementation of + // this logic used DOM Range objects and is available for manual testing at + // https://gist.github.com/sophiebits/2e6d571f4f10f33b62ea138a6e9c265c. + function simpleModernOffsetsFromPoints( + outerNode, + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ) { + let start; + let end; + let length = 0; + + function traverse(node) { + if (node.nodeType === Node.TEXT_NODE) { + if (node === anchorNode) { + start = length + anchorOffset; + } + if (node === focusNode) { + end = length + focusOffset; + } + length += node.nodeValue.length; + return; + } + + for (let i = 0; true; i++) { + if (node === anchorNode && i === anchorOffset) { + start = length; + } + if (node === focusNode && i === focusOffset) { + end = length; + } + if (i === node.childNodes.length) { + break; + } + let n = node.childNodes[i]; + traverse(n); + } + } + traverse(outerNode); + + invariant( + start !== null && end !== null, + 'Provided anchor/focus nodes were outside of root.', + ); + return {start, end}; + } + + // Complicated example derived from a real-world DOM tree. Has a bit of + // everything. + function getFixture() { + return ReactDOM.render( +
+
+
+
xxxxxxxxxxxxxxxxxxxx
+
+ x +
+
+ x +
+
+
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+
+
+
xxxxxxxxxxxxxxxxxx
+
+
+
+
+
+
+
+
+
+
xxxx
+
xxxxxxxxxxxxxxxxxxx
+
+
+
xxx
+
xxxxx
+
xxx
+
+
+
+
{['x', 'x', 'xxx']}
+
+
+
+
+
+
xxxxxx
+
+
, + document.createElement('div'), + ); + } + + it('returns correctly for base case', () => { + const node = document.createElement('div'); + expect(getModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({ + start: 0, + end: 0, + }); + expect(simpleModernOffsetsFromPoints(node, node, 0, node, 0)).toEqual({ + start: 0, + end: 0, + }); + }); + + it('returns correctly for fuzz test', () => { + const fixtureRoot = getFixture(); + const allNodes = [fixtureRoot].concat( + Array.from(fixtureRoot.querySelectorAll('*')), + ); + expect(allNodes.length).toBe(27); + allNodes.slice().forEach(element => { + // Add text nodes. + allNodes.push( + ...Array.from(element.childNodes).filter(n => n.nodeType === 3), + ); + }); + expect(allNodes.length).toBe(41); + + function randomNode() { + return allNodes[(Math.random() * allNodes.length) | 0]; + } + function randomOffset(node) { + return ( + (Math.random() * + (1 + + (node.nodeType === 3 ? node.nodeValue : node.childNodes).length)) | + 0 + ); + } + + for (let i = 0; i < 2000; i++) { + const anchorNode = randomNode(); + const anchorOffset = randomOffset(anchorNode); + const focusNode = randomNode(); + const focusOffset = randomOffset(focusNode); + + const offsets1 = getModernOffsetsFromPoints( + fixtureRoot, + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + const offsets2 = simpleModernOffsetsFromPoints( + fixtureRoot, + anchorNode, + anchorOffset, + focusNode, + focusOffset, + ); + if (JSON.stringify(offsets1) !== JSON.stringify(offsets2)) { + throw new Error( + JSON.stringify(offsets1) + + ' does not match ' + + JSON.stringify(offsets2) + + ' for anchorNode=allNodes[' + + allNodes.indexOf(anchorNode) + + '], anchorOffset=' + + anchorOffset + + ', focusNode=allNodes[' + + allNodes.indexOf(focusNode) + + '], focusOffset=' + + focusOffset, + ); + } + } + }); +});