-
Notifications
You must be signed in to change notification settings - Fork 47k
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
Support iframes (nested browsing contexts) in selection event handling #9184
Changes from 15 commits
e1d4110
ed0cc8a
8f11b2c
83cfb55
374d525
f6f9e35
6ac703f
cec8c3f
0163795
d777546
7c59a77
131357e
2d4ac46
5755556
b3998eb
174375c
39a9589
82443f7
b25279d
026e8f0
689d7c1
074ff51
0744710
c71601b
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 |
---|---|---|
@@ -0,0 +1,254 @@ | ||
/** | ||
* Copyright (c) 2013-present, Facebook, Inc. | ||
* | ||
* This source code is licensed under the MIT license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
* | ||
* @emails react-core | ||
*/ | ||
|
||
'use strict'; | ||
|
||
var React = require('react'); | ||
var ReactDOM = require('react-dom'); | ||
var ReactTestUtils = require('react-dom/test-utils'); | ||
var ReactInputSelection = require('../client/ReactInputSelection'); | ||
|
||
describe('ReactInputSelection', () => { | ||
var textValue = 'the text contents'; | ||
var createAndMountElement = (type, props, children) => { | ||
var element = React.createElement(type, props, children); | ||
var instance = ReactTestUtils.renderIntoDocument(element); | ||
return ReactDOM.findDOMNode(instance); | ||
}; | ||
var makeGetSelection = (win = window) => () => ({ | ||
anchorNode: win.document.activeElement, | ||
focusNode: win.document.activeElement, | ||
anchorOffset: | ||
win.document.activeElement && win.document.activeElement.selectionStart, | ||
focusOffset: | ||
win.document.activeElement && win.document.activeElement.selectionEnd, | ||
}); | ||
|
||
describe('hasSelectionCapabilities', () => { | ||
it('returns true for textareas', () => { | ||
var textarea = document.createElement('textarea'); | ||
expect(ReactInputSelection.hasSelectionCapabilities(textarea)).toBe(true); | ||
}); | ||
|
||
it('returns true for text inputs', () => { | ||
var inputText = document.createElement('input'); | ||
var inputReadOnly = document.createElement('input'); | ||
inputReadOnly.readOnly = 'true'; | ||
var inputNumber = document.createElement('input'); | ||
inputNumber.type = 'number'; | ||
var inputEmail = document.createElement('input'); | ||
inputEmail.type = 'email'; | ||
var inputPassword = document.createElement('input'); | ||
inputPassword.type = 'password'; | ||
var inputHidden = document.createElement('input'); | ||
inputHidden.type = 'hidden'; | ||
|
||
expect(ReactInputSelection.hasSelectionCapabilities(inputText)).toBe( | ||
true, | ||
); | ||
expect(ReactInputSelection.hasSelectionCapabilities(inputReadOnly)).toBe( | ||
true, | ||
); | ||
expect(ReactInputSelection.hasSelectionCapabilities(inputNumber)).toBe( | ||
false, | ||
); | ||
expect(ReactInputSelection.hasSelectionCapabilities(inputEmail)).toBe( | ||
false, | ||
); | ||
expect(ReactInputSelection.hasSelectionCapabilities(inputPassword)).toBe( | ||
false, | ||
); | ||
expect(ReactInputSelection.hasSelectionCapabilities(inputHidden)).toBe( | ||
false, | ||
); | ||
}); | ||
|
||
it('returns true for contentEditable elements', () => { | ||
var div = document.createElement('div'); | ||
div.contentEditable = 'true'; | ||
var body = document.createElement('body'); | ||
body.contentEditable = 'true'; | ||
var input = document.createElement('input'); | ||
input.contentEditable = 'true'; | ||
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. Huh. I didn't know you could do this. 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. |
||
var select = document.createElement('select'); | ||
select.contentEditable = 'true'; | ||
|
||
expect(ReactInputSelection.hasSelectionCapabilities(div)).toBe(true); | ||
expect(ReactInputSelection.hasSelectionCapabilities(body)).toBe(true); | ||
expect(ReactInputSelection.hasSelectionCapabilities(input)).toBe(true); | ||
expect(ReactInputSelection.hasSelectionCapabilities(select)).toBe(true); | ||
}); | ||
|
||
it('returns false for any other type of HTMLElement', () => { | ||
var select = document.createElement('select'); | ||
var iframe = document.createElement('iframe'); | ||
|
||
expect(ReactInputSelection.hasSelectionCapabilities(select)).toBe(false); | ||
expect(ReactInputSelection.hasSelectionCapabilities(iframe)).toBe(false); | ||
}); | ||
}); | ||
|
||
describe('getSelection', () => { | ||
it('gets selection offsets from a textarea or input', () => { | ||
var input = createAndMountElement('input', {defaultValue: textValue}); | ||
input.setSelectionRange(6, 11); | ||
expect(ReactInputSelection.getSelection(input)).toEqual({ | ||
start: 6, | ||
end: 11, | ||
}); | ||
|
||
var textarea = createAndMountElement('textarea', { | ||
defaultValue: textValue, | ||
}); | ||
textarea.setSelectionRange(6, 11); | ||
expect(ReactInputSelection.getSelection(textarea)).toEqual({ | ||
start: 6, | ||
end: 11, | ||
}); | ||
}); | ||
|
||
it('gets selection offsets from a contentEditable element', () => { | ||
var node = createAndMountElement('div', null, textValue); | ||
node.selectionStart = 6; | ||
node.selectionEnd = 11; | ||
expect(ReactInputSelection.getSelection(node)).toEqual({ | ||
start: 6, | ||
end: 11, | ||
}); | ||
}); | ||
|
||
it('gets selection offsets as start: 0, end: 0 if no selection', () => { | ||
var node = createAndMountElement('select'); | ||
expect(ReactInputSelection.getSelection(node)).toEqual({ | ||
start: 0, | ||
end: 0, | ||
}); | ||
}); | ||
|
||
it('gets selection on inputs in iframes', () => { | ||
const iframe = document.createElement('iframe'); | ||
document.body.appendChild(iframe); | ||
const input = document.createElement('input'); | ||
input.value = textValue; | ||
iframe.contentDocument.body.appendChild(input); | ||
input.select(); | ||
expect(input.selectionStart).toEqual(0); | ||
expect(input.selectionEnd).toEqual(textValue.length); | ||
|
||
document.body.removeChild(iframe); | ||
}); | ||
}); | ||
|
||
describe('setSelection', () => { | ||
it('sets selection offsets on textareas and inputs', () => { | ||
var input = createAndMountElement('input', {defaultValue: textValue}); | ||
ReactInputSelection.setSelection(input, {start: 1, end: 10}); | ||
expect(input.selectionStart).toEqual(1); | ||
expect(input.selectionEnd).toEqual(10); | ||
|
||
var textarea = createAndMountElement('textarea', { | ||
defaultValue: textValue, | ||
}); | ||
ReactInputSelection.setSelection(textarea, {start: 1, end: 10}); | ||
expect(textarea.selectionStart).toEqual(1); | ||
expect(textarea.selectionEnd).toEqual(10); | ||
}); | ||
|
||
it('sets selection on inputs in iframes', () => { | ||
const iframe = document.createElement('iframe'); | ||
document.body.appendChild(iframe); | ||
const input = document.createElement('input'); | ||
input.value = textValue; | ||
iframe.contentDocument.body.appendChild(input); | ||
ReactInputSelection.setSelection(input, {start: 1, end: 10}); | ||
expect(input.selectionStart).toEqual(1); | ||
expect(input.selectionEnd).toEqual(10); | ||
|
||
document.body.removeChild(iframe); | ||
}); | ||
}); | ||
|
||
describe('getSelectionInformation/restoreSelection', () => { | ||
it('gets and restores selection for inputs that get remounted', () => { | ||
// Mock window getSelection if needed | ||
var originalGetSelection = window.getSelection; | ||
window.getSelection = window.getSelection || makeGetSelection(window); | ||
var input = document.createElement('input'); | ||
input.value = textValue; | ||
document.body.appendChild(input); | ||
input.focus(); | ||
input.selectionStart = 1; | ||
input.selectionEnd = 10; | ||
var selectionInfo = ReactInputSelection.getSelectionInformation(); | ||
expect(selectionInfo.activeElement).toBe(input); | ||
expect(selectionInfo.elementSelections[0].element).toBe(input); | ||
expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ | ||
start: 1, | ||
end: 10, | ||
}); | ||
expect(document.activeElement).toBe(input); | ||
input.setSelectionRange(0, 0); | ||
document.body.removeChild(input); | ||
expect(document.activeElement).not.toBe(input); | ||
expect(input.selectionStart).not.toBe(1); | ||
expect(input.selectionEnd).not.toBe(10); | ||
document.body.appendChild(input); | ||
ReactInputSelection.restoreSelection(selectionInfo); | ||
expect(document.activeElement).toBe(input); | ||
expect(input.selectionStart).toBe(1); | ||
expect(input.selectionEnd).toBe(10); | ||
|
||
document.body.removeChild(input); | ||
window.getSelection = originalGetSelection; | ||
}); | ||
|
||
it('gets and restores selection for inputs in an iframe that get remounted', () => { | ||
var iframe = document.createElement('iframe'); | ||
document.body.appendChild(iframe); | ||
var iframeDoc = iframe.contentDocument; | ||
var iframeWin = iframeDoc.defaultView; | ||
// Mock window and iframe getSelection if needed | ||
var originalGetSelection = window.getSelection; | ||
var originalIframeGetSelection = iframeWin.getSelection; | ||
window.getSelection = window.getSelection || makeGetSelection(window); | ||
iframeWin.getSelection = | ||
iframeWin.getSelection || makeGetSelection(iframeWin); | ||
|
||
var input = document.createElement('input'); | ||
input.value = textValue; | ||
iframeDoc.body.appendChild(input); | ||
input.focus(); | ||
input.selectionStart = 1; | ||
input.selectionEnd = 10; | ||
var selectionInfo = ReactInputSelection.getSelectionInformation(); | ||
expect(selectionInfo.activeElement === input).toBe(true); | ||
expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ | ||
start: 1, | ||
end: 10, | ||
}); | ||
expect(document.activeElement).toBe(iframe); | ||
expect(iframeDoc.activeElement).toBe(input); | ||
|
||
input.setSelectionRange(0, 0); | ||
iframeDoc.body.removeChild(input); | ||
expect(iframeDoc.activeElement).not.toBe(input); | ||
expect(input.selectionStart).not.toBe(1); | ||
expect(input.selectionEnd).not.toBe(10); | ||
iframeDoc.body.appendChild(input); | ||
ReactInputSelection.restoreSelection(selectionInfo); | ||
expect(iframeDoc.activeElement).toBe(input); | ||
expect(input.selectionStart).toBe(1); | ||
expect(input.selectionEnd).toBe(10); | ||
|
||
document.body.removeChild(iframe); | ||
window.getSelection = originalGetSelection; | ||
iframeWin.getSelection = originalIframeGetSelection; | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,11 @@ import {TEXT_NODE} from '../shared/HTMLNodeType'; | |
* @return {?object} | ||
*/ | ||
export function getOffsets(outerNode) { | ||
var selection = window.getSelection && window.getSelection(); | ||
var win = window; | ||
if (outerNode.ownerDocument && outerNode.ownerDocument.defaultView) { | ||
win = outerNode.ownerDocument.defaultView; | ||
} | ||
var selection = win.getSelection && win.getSelection(); | ||
|
||
if (!selection || selection.rangeCount === 0) { | ||
return null; | ||
|
@@ -153,11 +157,13 @@ export function getModernOffsetsFromPoints( | |
* @param {object} offsets | ||
*/ | ||
export function setOffsets(node, offsets) { | ||
if (!window.getSelection) { | ||
var doc = node.ownerDocument || document; | ||
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. When is ownerDocument ever undefined? 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. According to the MDN page about the
That doesn’t refer to a node that isn’t in the DOM, which will still have a reference to the
So now that you mention it, this check could be updated to handle However, maybe 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. Cool. Thank you. That was extremely helpful! |
||
|
||
if (!doc.defaultView.getSelection) { | ||
return; | ||
} | ||
|
||
var selection = window.getSelection(); | ||
var selection = doc.defaultView.getSelection(); | ||
var length = node[getTextContentAccessor()].length; | ||
var start = Math.min(offsets.start, length); | ||
var end = offsets.end === undefined ? start : Math.min(offsets.end, length); | ||
|
@@ -183,7 +189,7 @@ export function setOffsets(node, offsets) { | |
) { | ||
return; | ||
} | ||
var range = document.createRange(); | ||
var range = doc.createRange(); | ||
range.setStart(startMarker.node, startMarker.offset); | ||
selection.removeAllRanges(); | ||
|
||
|
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.
These feel like they could be separate tests.
Also: Why do
email
,password
, andnumber
return false? Is this because they don't support the text selection api?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.
That’s a great question! That’s what the existing function enforces, so I figured I’d capture that behavior in the tests:
But I’m not sure it’s desired, because those input types can definitely have selections. The
hasSelectionCapabilities
check could be changed to something like: