-
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
Minimally support iframes (nested browsing contexts) in selection event handling #12037
Changes from 21 commits
6940e33
3f3c1d7
b08a270
51be426
3714067
66b8766
75bc65e
5a61b66
f752792
a8fcf05
0003681
fef8519
799ee39
6e3d4b7
d1ad016
3c32963
3f7b5c5
05d8969
a776570
26e9d82
4db5c44
ccf0329
2edf1f8
db02b65
75b9992
0f1de45
95b2aef
d997ba8
e63391c
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 |
---|---|---|
|
@@ -16,6 +16,7 @@ | |
<title>React App</title> | ||
<script src="https://unpkg.com/prop-types@15.5.6/prop-types.js"></script> | ||
<script src="https://unpkg.com/expect@1.20.2/umd/expect.min.js"></script> | ||
<script src="https://unpkg.com/immutable@3.8.2/dist/immutable.js"></script> | ||
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. @wilsonhyng I believe we don’t need immutable.js in the fixtures anymore now that we’ve removed the draft.js dependency. @aweary Please correct me if I’m wrong. 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. Correct! |
||
</head> | ||
<body> | ||
<div id="root"></div> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
const React = window.React; | ||
const ReactDOM = window.ReactDOM; | ||
|
||
class IframePortal extends React.Component { | ||
iframeRef = null; | ||
|
||
handleRef = ref => { | ||
if (ref !== this.iframeRef) { | ||
this.iframeRef = ref; | ||
if (ref) { | ||
if (ref.contentDocument && this.props.head) { | ||
ref.contentDocument.head.innerHTML = this.props.head; | ||
} | ||
// Re-render must take place in the next tick (Firefox) | ||
setTimeout(() => { | ||
this.forceUpdate(); | ||
}); | ||
} | ||
} | ||
}; | ||
|
||
render() { | ||
const ref = this.iframeRef; | ||
let portal = null; | ||
if (ref && ref.contentDocument) { | ||
portal = ReactDOM.createPortal( | ||
this.props.children, | ||
ref.contentDocument.body | ||
); | ||
} | ||
|
||
return ( | ||
<div> | ||
<iframe | ||
style={{border: 'none', height: this.props.height}} | ||
ref={this.handleRef} | ||
/> | ||
{portal} | ||
</div> | ||
); | ||
} | ||
} | ||
|
||
class IframeSubtree extends React.Component { | ||
warned = false; | ||
render() { | ||
if (!this.warned) { | ||
console.error( | ||
`IFrame has not yet been implemented for React v${React.version}` | ||
); | ||
this.warned = true; | ||
} | ||
return <div>{this.props.children}</div>; | ||
} | ||
} | ||
|
||
export default (ReactDOM.createPortal ? IframePortal : IframeSubtree); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
import TestCase from '../../TestCase'; | ||
import Iframe from '../../Iframe'; | ||
const React = window.React; | ||
|
||
class OnSelectIframe extends React.Component { | ||
state = {count: 0, value: 'Select Me!'}; | ||
|
||
_onSelect = event => { | ||
this.setState(({count}) => ({count: count + 1})); | ||
}; | ||
|
||
_onChange = event => { | ||
this.setState({value: event.target.value}); | ||
}; | ||
|
||
render() { | ||
const {count, value} = this.state; | ||
return ( | ||
<Iframe height={60}> | ||
Selection Event Count: {count} | ||
<input | ||
type="text" | ||
onSelect={this._onSelect} | ||
value={value} | ||
onChange={this._onChange} | ||
/> | ||
</Iframe> | ||
); | ||
} | ||
} | ||
|
||
export default class OnSelectEventTestCase extends React.Component { | ||
render() { | ||
return ( | ||
<TestCase | ||
title="onSelect events within iframes" | ||
description="onSelect events should fire for elements rendered inside iframes"> | ||
<TestCase.Steps> | ||
<li>Highlight some of the text in the input below</li> | ||
<li>Move the cursor around using the arrow keys</li> | ||
</TestCase.Steps> | ||
<TestCase.ExpectedResult> | ||
The displayed count should increase as you highlight or move the | ||
cursor | ||
</TestCase.ExpectedResult> | ||
<OnSelectIframe /> | ||
</TestCase> | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import TestCase from '../../TestCase'; | ||
import Iframe from '../../Iframe'; | ||
const React = window.React; | ||
|
||
export default class ReorderedInputsTestCase extends React.Component { | ||
state = {count: 0}; | ||
|
||
componentDidMount() { | ||
this.interval = setInterval(() => { | ||
this.setState({count: this.state.count + 1}); | ||
}, 2000); | ||
} | ||
|
||
componentWillUnmount() { | ||
clearInterval(this.interval); | ||
} | ||
|
||
renderInputs() { | ||
const inputs = [ | ||
<input key={1} defaultValue="Foo" />, | ||
<input key={2} defaultValue="Bar" />, | ||
]; | ||
if (this.state.count % 2 === 0) { | ||
inputs.reverse(); | ||
} | ||
return inputs; | ||
} | ||
|
||
render() { | ||
return ( | ||
<TestCase title="Reordered input elements in iframes" description=""> | ||
<TestCase.Steps> | ||
<li>The two inputs below swap positions every two seconds</li> | ||
<li>Select the text in either of them</li> | ||
<li>Wait for the swap to occur</li> | ||
</TestCase.Steps> | ||
<TestCase.ExpectedResult> | ||
The selection you made should be maintained | ||
</TestCase.ExpectedResult> | ||
<Iframe height={50}>{this.renderInputs()}</Iframe> | ||
</TestCase> | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import FixtureSet from '../../FixtureSet'; | ||
import ReorderedInputsTestCase from './ReorderedInputsTestCase'; | ||
import OnSelectEventTestCase from './OnSelectEventTestCase'; | ||
const React = window.React; | ||
|
||
export default function SelectionEvents() { | ||
return ( | ||
<FixtureSet | ||
title="Selection Restoration" | ||
description=" | ||
When React commits changes it may perform operations which cause existing | ||
selection state to be lost. This is manually managed by reading the | ||
selection state before commits and then restoring it afterwards. | ||
"> | ||
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. 👍 |
||
<ReorderedInputsTestCase /> | ||
<OnSelectEventTestCase /> | ||
</FixtureSet> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
import semver from 'semver'; | ||
|
||
/** | ||
* Take a version from the window query string and load a specific | ||
* version of React. | ||
|
@@ -42,9 +44,11 @@ export default function loadReact() { | |
let version = query.version || 'local'; | ||
|
||
if (version !== 'local') { | ||
const {major, minor, prerelease} = semver(version); | ||
const [preReleaseStage, preReleaseVersion] = prerelease; | ||
// The file structure was updated in 16. This wasn't the case for alphas. | ||
// Load the old module location for anything less than 16 RC | ||
if (parseInt(version, 10) >= 16 && version.indexOf('alpha') < 0) { | ||
if (major >= 16 && !(minor === 0 && preReleaseStage === 'alpha')) { | ||
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. 👍 |
||
REACT_PATH = | ||
'https://unpkg.com/react@' + version + '/umd/react.development.js'; | ||
DOM_PATH = | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,7 +14,9 @@ import {TEXT_NODE} from '../shared/HTMLNodeType'; | |
* @return {?object} | ||
*/ | ||
export function getOffsets(outerNode) { | ||
const selection = window.getSelection && window.getSelection(); | ||
const win = | ||
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. Nit: let's not read |
||
(outerNode.ownerDocument && outerNode.ownerDocument.defaultView) || window; | ||
const selection = win.getSelection && win.getSelection(); | ||
|
||
if (!selection || selection.rangeCount === 0) { | ||
return null; | ||
|
@@ -150,11 +152,13 @@ export function getModernOffsetsFromPoints( | |
* @param {object} offsets | ||
*/ | ||
export function setOffsets(node, offsets) { | ||
if (!window.getSelection) { | ||
const doc = node.ownerDocument || document; | ||
const win = doc ? doc.defaultView : window; | ||
if (!win.getSelection) { | ||
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. In the case of IE8, and possibly other very old browsers. We can remove the early return if that no longer matters. 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. That is fine by me. Sound good, @aweary? |
||
return; | ||
} | ||
|
||
const selection = window.getSelection(); | ||
const selection = win.getSelection(); | ||
const length = node[getTextContentAccessor()].length; | ||
let start = Math.min(offsets.start, length); | ||
let end = offsets.end === undefined ? start : Math.min(offsets.end, length); | ||
|
@@ -180,7 +184,7 @@ export function setOffsets(node, offsets) { | |
) { | ||
return; | ||
} | ||
const range = document.createRange(); | ||
const range = doc.createRange(); | ||
range.setStart(startMarker.node, startMarker.offset); | ||
selection.removeAllRanges(); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -49,7 +49,25 @@ function containsNode(outerNode, innerNode) { | |
} | ||
|
||
function isInDocument(node) { | ||
return containsNode(document.documentElement, node); | ||
return ( | ||
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. Why do we need to check if 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. The value that gets passed in comes from 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. We don't use 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. @gaearon We can update export default function getActiveElement(doc: ?Document): Element {
doc = doc || document;
try {
return doc.activeElement || doc.body;
} catch (e) {
return doc.body;
}
} But strictly speaking, flow will still complain that export default function getActiveElement(doc: ?Document): Element {
doc = doc || document;
const body = doc.body || doc.createElement('body');
try {
return doc.activeElement || body;
} catch (e) {
return body;
}
} Do you have a preferred approach? 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 want to lean towards the first, but I keep reasoning out of it in favor of the second. Body could be null, and it really it should never happen. But I've been surprised too much before :). With the second example, do you need a try/catch? Also: do you anticipate any problems with code downstream working with a document body that isn't attached? 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. @nhunzaker No try/catch needed for the second example, and the ReactInputSelection plugin won’t have any issues; the code is already setup to handle detached DOM elements for a case where the active element becomes detached between when it is first read and cached and after React finishes committing an update. The second option has grown on me; I suggested it thinking it was silly, but now feel like it’s pretty reasonable. If we go with that one, should we add a comment explaining that document.body can be 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.
Let's go with it 👍 |
||
node.ownerDocument && | ||
containsNode(node.ownerDocument.documentElement, node) | ||
); | ||
} | ||
|
||
function getActiveElementDeep() { | ||
let win = window; | ||
let element = getActiveElement(); | ||
while (element instanceof win.HTMLIFrameElement) { | ||
try { | ||
win = element.contentDocument.defaultView; | ||
} catch (e) { | ||
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. What exactly are we catching here? Is it 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. If you just try to access the contentDocument of an 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. Could you add a comment to this line describing such a scenario? |
||
return element; | ||
} | ||
element = getActiveElement(win.document); | ||
} | ||
return element; | ||
} | ||
|
||
/** | ||
|
@@ -80,7 +98,7 @@ export function hasSelectionCapabilities(elem) { | |
} | ||
|
||
export function getSelectionInformation() { | ||
const focusedElem = getActiveElement(); | ||
const focusedElem = getActiveElementDeep(); | ||
return { | ||
focusedElem: focusedElem, | ||
selectionRange: hasSelectionCapabilities(focusedElem) | ||
|
@@ -95,7 +113,7 @@ export function getSelectionInformation() { | |
* nodes and place them back in, resulting in focus being lost. | ||
*/ | ||
export function restoreSelection(priorSelectionInformation) { | ||
const curFocusedElem = getActiveElement(); | ||
const curFocusedElem = getActiveElementDeep(); | ||
const priorFocusedElem = priorSelectionInformation.focusedElem; | ||
const priorSelectionRange = priorSelectionInformation.selectionRange; | ||
if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) { | ||
|
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.
@aweary Just double checking: we can remove this dependency now, right?
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.
Correct!