Add ReactDOMSelection module
Add a DOM based selection module. This can both be used on its own and in ReactInputSelection where our current implementation is lacking. There is still some inconsistency with how IE and modern browsers handle block nodes. Should be OK if we are just getting and setting and not trying to set selection based on character offsets.
Josh Duck committed
Sep 23, 2013 at 16:16 UTC
8f15eea910c097893c7bdc24725cf7ceff93cf32
4 files changed
+358
-77
src/core/ReactDOMSelection.js
new
+161
@@ -0,0 +1,161 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @providesModule ReactDOMSelection
17
+ */
18
+
19
+"use strict";
20
+
21
+var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
22
+var getTextContentAccessor = require('getTextContentAccessor');
23
+
24
+/**
25
+ * Get the appropriate anchor and focus node/offset pairs for IE.
26
+ *
27
+ * The catch here is that IE's selection API doesn't provide information
28
+ * about whether the selection is forward or backward, so we have to
29
+ * behave as though it's always forward.
30
+ *
31
+ * IE text differs from modern selection in that it behaves as though
32
+ * block elements end with a new line. This means character offsets will
33
+ * differ between the two APIs.
34
+ *
35
+ * @param {DOMElement} node
36
+ */
37
+function getIESelection(node) {
38
+ var selection = document.selection;
39
+ var selectedRange = selection.createRange();
40
+ var selectedLength = selectedRange.text.length;
41
+
42
+ // Duplicate selection so we can move range without breaking user selection.
43
+ var fromStart = selectedRange.duplicate();
44
+ fromStart.moveToElementText(node);
45
+ fromStart.setEndPoint('EndToStart', selectedRange);
46
+
47
+ var startOffset = fromStart.text.length;
48
+ var endOffset = startOffset + selectedLength;
49
+
50
+ return {
51
+ start: startOffset,
52
+ end: endOffset
53
+ };
54
+}
55
+
56
+/**
57
+ * @param {DOMElement} node
58
+ */
59
+function getModernSelection(node) {
60
+ var selection = global.getSelection();
61
+ var anchorNode = selection.anchorNode;
62
+ var anchorOffset = selection.anchorOffset;
63
+ var focusNode = selection.focusNode;
64
+ var focusOffset = selection.focusOffset;
65
+
66
+ var currentRange = selection.getRangeAt(0);
67
+ var rangeLength = currentRange.toString().length;
68
+
69
+ var tempRange = currentRange.cloneRange();
70
+ tempRange.selectNodeContents(node);
71
+ tempRange.setEnd(currentRange.startContainer, currentRange.startOffset);
72
+
73
+ var start = tempRange.toString().length;
74
+ var end = start + rangeLength;
75
+
76
+ // Detect whether the selection is backward.
77
+ var detectionRange = document.createRange();
78
+ detectionRange.setStart(anchorNode, anchorOffset);
79
+ detectionRange.setEnd(focusNode, focusOffset);
80
+ var isBackward = detectionRange.collapsed;
81
+ detectionRange.detach();
82
+
83
+ return {
84
+ start: isBackward ? end : start,
85
+ end: isBackward ? start : end
86
+ };
87
+}
88
+
89
+/**
90
+ * @param {DOMElement|DOMTextNode} node
91
+ * @param {object} offsets
92
+ */
93
+function setIESelection(node, offsets) {
94
+ var range = document.selection.createRange().duplicate();
95
+ var start, end;
96
+
97
+ if (typeof offsets.end === 'undefined') {
98
+ start = offsets.start;
99
+ end = start;
100
+ } else if (offsets.start > offsets.end) {
101
+ start = offsets.end;
102
+ end = offsets.start;
103
+ } else {
104
+ start = offsets.start;
105
+ end = offsets.end;
106
+ }
107
+
108
+ range.moveToElementText(node);
109
+ range.moveStart('character', start);
110
+ range.setEndPoint('EndToStart', range);
111
+ range.moveEnd('character', end - start);
112
+ range.select();
113
+}
114
+
115
+/**
116
+ * In modern non-IE browsers, we can support both forward and backward
117
+ * selections.
118
+ *
119
+ * Note: IE10+ supports the Selection object, but it does not support
120
+ * the `extend` method, which means that even in modern IE, it's not possible
121
+ * to programatically create a backward selection. Thus, for all IE
122
+ * versions, we use the old IE API to create our selections.
123
+ *
124
+ * @param {DOMElement|DOMTextNode} node
125
+ * @param {object} offsets
126
+ */
127
+function setModernSelection(node, offsets) {
128
+ var selection = global.getSelection();
129
+
130
+ var length = node[getTextContentAccessor()].length;
131
+ var start = Math.min(offsets.start, length);
132
+ var end = typeof offsets.end === 'undefined' ?
133
+ start : Math.min(offsets.end, length);
134
+
135
+ var startMarker = getNodeForCharacterOffset(node, start);
136
+ var endMarker = getNodeForCharacterOffset(node, end);
137
+
138
+ if (startMarker && endMarker) {
139
+ var range = document.createRange();
140
+ range.setStart(startMarker.node, startMarker.offset);
141
+ selection.removeAllRanges();
142
+ selection.addRange(range);
143
+ selection.extend(endMarker.node, endMarker.offset);
144
+ range.detach();
145
+ }
146
+}
147
+
148
+var ReactDOMSelection = {
149
+ /**
150
+ * @param {DOMElement} node
151
+ */
152
+ get: document.selection ? getIESelection : getModernSelection,
153
+
154
+ /**
155
+ * @param {DOMElement|DOMTextNode} node
156
+ * @param {object} offsets
157
+ */
158
+ set: document.selection ? setIESelection : setModernSelection
159
+};
160
+
161
+module.exports = ReactDOMSelection;
src/core/ReactInputSelection.js
+33
-77
@@ -18,7 +18,7 @@
18
19
"use strict";
20
21
-var getTextContentAccessor = require('getTextContentAccessor');
21
+var ReactDOMSelection = require('ReactDOMSelection');
22
23
// It is not safe to read the document.activeElement property in IE if there's
24
// nothing focused.
@@ -88,103 +88,59 @@ var ReactInputSelection = {
88
* -@return {start: selectionStart, end: selectionEnd}
89
*/
90
getSelection: function(input) {
91
- var range;
92
- if (input.contentEditable === 'true' && window.getSelection) {
93
- var selection = window.getSelection();
94
- if (selection.rangeCount > 0) {
95
- range = selection.getRangeAt(0);
96
- var commonAncestor = range.commonAncestorContainer;
97
- if (commonAncestor && commonAncestor.nodeType === 3) {
98
- commonAncestor = commonAncestor.parentNode;
99
- }
100
- if (commonAncestor === input) {
101
- return {start: range.startOffset, end: range.endOffset};
102
- }
103
- }
104
- return {start: 0, end: 0};
105
- }
106
-
107
- if (!document.selection) {
108
- // Mozilla, Safari, etc.
109
- return {start: input.selectionStart, end: input.selectionEnd};
110
- }
111
-
112
- range = document.selection.createRange();
113
- if (range.parentElement() !== input) {
114
- // There can only be one selection per document in IE, so if the
115
- // containing element of the document's selection isn't our text field,
116
- // our text field must have no selection.
117
- return {start: 0, end: 0};
118
- }
119
-
120
- var value = input.value || input[getTextContentAccessor()];
121
- var length = value.length;
91
+ var selection;
92
123
- if (input.nodeName === 'INPUT') {
124
- return {
125
- start: -range.moveStart('character', -length),
126
- end: -range.moveEnd('character', -length)
93
+ if ('selectionStart' in input) {
94
+ // Modern browser with input or textarea.
95
+ selection = {
96
+ start: input.selectionStart,
97
+ end: input.selectionEnd
98
};
99
+ } else if (document.selection && input.nodeName === 'INPUT') {
100
+ // IE8 input.
101
+ var range = document.selection.createRange();
102
+ // There can only be one selection per document in IE, so it must
103
+ // be in our element.
104
+ if (range.parentElement() === input) {
105
+ selection = {
106
+ start: -range.moveStart('character', -input.value.length),
107
+ end: -range.moveEnd('character', -input.value.length)
108
+ };
109
+ }
110
} else {
129
- var range2 = range.duplicate();
130
- range2.moveToElementText(input);
131
- range2.setEndPoint('StartToEnd', range);
132
- var end = length - range2.text.length;
133
- range2.setEndPoint('StartToStart', range);
134
- return {
135
- start: length - range2.text.length,
136
- end: end
137
- };
111
+ // Content editable or old IE textarea.
112
+ selection = ReactDOMSelection.get(input);
113
}
114
+
115
+ return selection || {start: 0, end: 0};
116
},
117
118
/**
119
* @setSelection: Sets the selection bounds of a textarea or input and focuses
120
* the input.
121
* -@input Set selection bounds of this input or textarea
145
- * -@rangeObj Object of same form that is returned from get*
122
+ * -@offsets Object of same form that is returned from get*
123
*/
147
- setSelection: function(input, rangeObj) {
148
- var range;
149
- var start = rangeObj.start;
150
- var end = rangeObj.end;
124
+ setSelection: function(input, offsets) {
125
+ var start = offsets.start;
126
+ var end = offsets.end;
127
if (typeof end === 'undefined') {
128
end = start;
129
}
154
- if (document.selection) {
155
- // IE is inconsistent about character offsets when it comes to carriage
156
- // returns, so we need to manually take them into account
157
- if (input.tagName === 'TEXTAREA') {
158
- var cr_before =
159
- (input.value.slice(0, start).match(/\r/g) || []).length;
160
- var cr_inside =
161
- (input.value.slice(start, end).match(/\r/g) || []).length;
162
- start -= cr_before;
163
- end -= cr_before + cr_inside;
164
- }
165
- range = input.createTextRange();
130
+
131
+ if ('selectionStart' in input) {
132
+ input.selectionStart = start;
133
+ input.selectionEnd = Math.min(end, input.value.length);
134
+ } else if (document.selection && input.nodeName === 'INPUT') {
135
+ var range = input.createTextRange();
136
range.collapse(true);
137
range.moveStart('character', start);
138
range.moveEnd('character', end - start);
139
range.select();
140
} else {
171
- if (input.contentEditable === 'true') {
172
- if (input.childNodes.length === 1) {
173
- range = document.createRange();
174
- range.setStart(input.childNodes[0], start);
175
- range.setEnd(input.childNodes[0], end);
176
- var sel = window.getSelection();
177
- sel.removeAllRanges();
178
- sel.addRange(range);
179
- }
180
- } else {
181
- input.selectionStart = start;
182
- input.selectionEnd = Math.min(end, input.value.length);
183
- input.focus();
184
- }
141
+ ReactDOMSelection.set(input, offsets);
142
}
143
}
187
-
144
};
145
146
module.exports = ReactInputSelection;
src/dom/__tests__/getNodeForCharacterOffset-test.js
new
+84
@@ -0,0 +1,84 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @jsx React.DOM
17
+ * @emails react-core
18
+ */
19
+
20
+/*jslint evil: true */
21
+
22
+"use strict";
23
+
24
+var getTestDocument = require('getTestDocument');
25
+
26
+var getNodeForCharacterOffset = require('getNodeForCharacterOffset');
27
+
28
+// Create node from HTML string
29
+function createNode(html) {
30
+ var node = (getTestDocument() || document).createElement('div');
31
+ node.innerHTML = html;
32
+ return node;
33
+}
34
+
35
+// Check getNodeForCharacterOffset return value matches expected result.
36
+function expectNodeOffset(result, textContent, nodeOffset) {
37
+ expect(result.node.textContent).toBe(textContent);
38
+ expect(result.offset).toBe(nodeOffset);
39
+}
40
+
41
+describe('getNodeForCharacterOffset', function() {
42
+ it('should handle siblings', function() {
43
+ var node = createNode('<i>123</i><i>456</i><i>789</i>');
44
+
45
+ expectNodeOffset(getNodeForCharacterOffset(node, 0), '123', 0);
46
+ expectNodeOffset(getNodeForCharacterOffset(node, 4), '456', 1);
47
+ });
48
+
49
+ it('should handle trailing chars', function() {
50
+ var node = createNode('<i>123</i><i>456</i><i>789</i>');
51
+
52
+ expectNodeOffset(getNodeForCharacterOffset(node, 3), '123', 3);
53
+ expectNodeOffset(getNodeForCharacterOffset(node, 9), '789', 3);
54
+ });
55
+
56
+ it('should handle trees', function() {
57
+ var node = createNode(
58
+ '<i>' +
59
+ '<i>1</i>' +
60
+ '<i>' +
61
+ '<i>' +
62
+ '<i>2</i>' +
63
+ '<i></i>' +
64
+ '</i>' +
65
+ '</i>' +
66
+ '<i>' +
67
+ '3' +
68
+ '<i>45</i>' +
69
+ '</i>' +
70
+ '</i>'
71
+ );
72
+
73
+ expectNodeOffset(getNodeForCharacterOffset(node, 3), '3', 1);
74
+ expectNodeOffset(getNodeForCharacterOffset(node, 5), '45', 2);
75
+ expect(getNodeForCharacterOffset(node, 10)).toBeUndefined();
76
+ });
77
+
78
+ it('should handle non-existent offset', function() {
79
+ var node = createNode('<i>123</i>');
80
+
81
+ expect(getNodeForCharacterOffset(node, -1)).toBeUndefined();
82
+ expect(getNodeForCharacterOffset(node, 4)).toBeUndefined();
83
+ });
84
+});
src/dom/getNodeForCharacterOffset.js
new
+80
@@ -0,0 +1,80 @@
1
+/**
2
+ * Copyright 2013 Facebook, Inc.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ *
16
+ * @providesModule getNodeForCharacterOffset
17
+ */
18
+
19
+"use strict";
20
+
21
+/**
22
+ * Given any node return the first leaf node without children.
23
+ *
24
+ * @param {DOMElement|DOMTextNode} node
25
+ * @return {DOMElement|DOMTextNode}
26
+ */
27
+function getLeafNode(node) {
28
+ while (node && node.firstChild) {
29
+ node = node.firstChild;
30
+ }
31
+ return node;
32
+}
33
+
34
+/**
35
+ * Get the next sibling within a container. This will walk up the
36
+ * DOM if a node's siblings have been exhausted.
37
+ *
38
+ * @param {DOMElement|DOMTextNode} node
39
+ * @return {?DOMElement|DOMTextNode}
40
+ */
41
+function getSiblingNode(node) {
42
+ while (node) {
43
+ if (node.nextSibling) {
44
+ return node.nextSibling;
45
+ }
46
+ node = node.parentNode;
47
+ }
48
+}
49
+
50
+/**
51
+ * Get object describing the nodes which contain characters at offset.
52
+ *
53
+ * @param {DOMElement|DOMTextNode} root
54
+ * @param {number} offset
55
+ * @return {?object}
56
+ */
57
+function getNodeForCharacterOffset(root, offset) {
58
+ var node = getLeafNode(root);
59
+ var nodeStart = 0;
60
+ var nodeEnd = 0;
61
+
62
+ while (node) {
63
+ if (node.nodeType == 3) {
64
+ nodeEnd = nodeStart + node.textContent.length;
65
+
66
+ if (nodeStart <= offset && nodeEnd >= offset) {
67
+ return {
68
+ node: node,
69
+ offset: offset - nodeStart
70
+ };
71
+ }
72
+
73
+ nodeStart = nodeEnd;
74
+ }
75
+
76
+ node = getLeafNode(getSiblingNode(node));
77
+ }
78
+}
79
+
80
+module.exports = getNodeForCharacterOffset;