Skip to content
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

Merged
merged 1 commit into from
Oct 4, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 111 additions & 49 deletions src/renderers/dom/shared/ReactDOMSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Copy link
Collaborator

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.

let next = null;

while (true) {
if (
node === anchorNode &&
(anchorOffset === 0 || node.nodeType === TEXT_NODE)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

node.firstChild can never be undefined?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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,
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New allocation per update? 😲

}

Expand Down Expand Up @@ -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();
Expand All @@ -149,6 +208,9 @@ var ReactDOMSelection = {
*/
getOffsets: getModernOffsets,

// For tests.
getModernOffsetsFromPoints: getModernOffsetsFromPoints,

/**
* @param {DOMElement|DOMTextNode} node
* @param {object} offsets
Expand Down
Loading