-
Notifications
You must be signed in to change notification settings - Fork 47.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rewrite ReactDOMSelection to use fewer ranges #9992
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 <input type="number">. 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 <input type="number">. 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this basically counts as zeugma. My code is poetry. |
||
) { | ||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, only null or an actual Node. |
||
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, | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. New allocation per update? 😲 |
||
} | ||
|
||
|
@@ -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 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think V8 (at least the old one) deopts in some cases when it thinks it might be an infinite loop. We have more of these though so we can probably do a pass over all of them and work around it, if that's the case.