diff --git a/fixtures/dom/package.json b/fixtures/dom/package.json index 333c566c7ded9..ef92dbfe50e4c 100644 --- a/fixtures/dom/package.json +++ b/fixtures/dom/package.json @@ -12,7 +12,7 @@ "query-string": "^4.2.3", "react": "^15.4.1", "react-dom": "^15.4.1", - "semver": "^5.3.0" + "semver": "^5.5.0" }, "scripts": { "start": "react-scripts start", diff --git a/fixtures/dom/src/components/Fixture.js b/fixtures/dom/src/components/Fixture.js index ddf8b97231714..3643a65c36751 100644 --- a/fixtures/dom/src/components/Fixture.js +++ b/fixtures/dom/src/components/Fixture.js @@ -1,4 +1,4 @@ -const PropTypes = window.PropTypes; +import PropTypes from 'prop-types'; const React = window.React; const propTypes = { diff --git a/fixtures/dom/src/components/Header.js b/fixtures/dom/src/components/Header.js index c78eaa6abf40a..7ce25c6c4d720 100644 --- a/fixtures/dom/src/components/Header.js +++ b/fixtures/dom/src/components/Header.js @@ -66,6 +66,7 @@ class Header extends React.Component { Media Events Pointer Events Mouse Events + Selection Events diff --git a/fixtures/dom/src/components/Iframe.js b/fixtures/dom/src/components/Iframe.js new file mode 100644 index 0000000000000..db5c4b25e1485 --- /dev/null +++ b/fixtures/dom/src/components/Iframe.js @@ -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 ( + + + {portal} + + ); + } +} + +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 {this.props.children}; + } +} + +export default (ReactDOM.createPortal ? IframePortal : IframeSubtree); diff --git a/fixtures/dom/src/components/fixtures/index.js b/fixtures/dom/src/components/fixtures/index.js index 4bbb20180c206..3107f535f9e69 100644 --- a/fixtures/dom/src/components/fixtures/index.js +++ b/fixtures/dom/src/components/fixtures/index.js @@ -13,6 +13,7 @@ import CustomElementFixtures from './custom-elements'; import MediaEventsFixtures from './media-events'; import PointerEventsFixtures from './pointer-events'; import MouseEventsFixtures from './mouse-events'; +import SelectionEventsFixtures from './selection-events'; const React = window.React; @@ -52,6 +53,8 @@ function FixturesPage() { return ; case '/mouse-events': return ; + case '/selection-events': + return ; default: return Please select a test fixture.; } diff --git a/fixtures/dom/src/components/fixtures/selection-events/OnSelectEventTestCase.js b/fixtures/dom/src/components/fixtures/selection-events/OnSelectEventTestCase.js new file mode 100644 index 0000000000000..beafd4e45320e --- /dev/null +++ b/fixtures/dom/src/components/fixtures/selection-events/OnSelectEventTestCase.js @@ -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 ( + + Selection Event Count: {count} + + + ); + } +} + +export default class OnSelectEventTestCase extends React.Component { + render() { + return ( + + + Highlight some of the text in the input below + Move the cursor around using the arrow keys + + + The displayed count should increase as you highlight or move the + cursor + + + + ); + } +} diff --git a/fixtures/dom/src/components/fixtures/selection-events/ReorderedInputsTestCase.js b/fixtures/dom/src/components/fixtures/selection-events/ReorderedInputsTestCase.js new file mode 100644 index 0000000000000..124c94b0eb871 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/selection-events/ReorderedInputsTestCase.js @@ -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 = [ + , + , + ]; + if (this.state.count % 2 === 0) { + inputs.reverse(); + } + return inputs; + } + + render() { + return ( + + + The two inputs below swap positions every two seconds + Select the text in either of them + Wait for the swap to occur + + + The selection you made should be maintained + + {this.renderInputs()} + + ); + } +} diff --git a/fixtures/dom/src/components/fixtures/selection-events/index.js b/fixtures/dom/src/components/fixtures/selection-events/index.js new file mode 100644 index 0000000000000..10b33c290e6bb --- /dev/null +++ b/fixtures/dom/src/components/fixtures/selection-events/index.js @@ -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 ( + + + + + ); +} diff --git a/fixtures/dom/src/react-loader.js b/fixtures/dom/src/react-loader.js index 6d108b0c28a83..29afd0196cd5f 100644 --- a/fixtures/dom/src/react-loader.js +++ b/fixtures/dom/src/react-loader.js @@ -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')) { REACT_PATH = 'https://unpkg.com/react@' + version + '/umd/react.development.js'; DOM_PATH = diff --git a/fixtures/dom/yarn.lock b/fixtures/dom/yarn.lock index 3fb98bd9f456b..188f76fb40214 100644 --- a/fixtures/dom/yarn.lock +++ b/fixtures/dom/yarn.lock @@ -2153,6 +2153,14 @@ dotenv@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" +draft-js@^0.10.5: + version "0.10.5" + resolved "https://registry.yarnpkg.com/draft-js/-/draft-js-0.10.5.tgz#bfa9beb018fe0533dbb08d6675c371a6b08fa742" + dependencies: + fbjs "^0.8.15" + immutable "~3.7.4" + object-assign "^4.1.0" + duplexer2@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1" @@ -2672,7 +2680,7 @@ fbjs@^0.8.1, fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.9" -fbjs@^0.8.16: +fbjs@^0.8.15, fbjs@^0.8.16: version "0.8.16" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db" dependencies: @@ -3302,6 +3310,10 @@ ignore@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.3.tgz#432352e57accd87ab3110e82d3fea0e47812156d" +immutable@~3.7.4: + version "3.7.6" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.7.6.tgz#13b4d3cb12befa15482a26fe1b2ebae640071e4b" + imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" @@ -5878,6 +5890,10 @@ semver@^5.0.3: version "5.4.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e" +semver@^5.5.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" + send@0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/send/-/send-0.14.1.tgz#a954984325392f51532a7760760e459598c89f7a" diff --git a/packages/react-dom/src/client/ReactDOMSelection.js b/packages/react-dom/src/client/ReactDOMSelection.js index 2fe966df8216a..e0b90d8b8a2ad 100644 --- a/packages/react-dom/src/client/ReactDOMSelection.js +++ b/packages/react-dom/src/client/ReactDOMSelection.js @@ -14,7 +14,9 @@ import {TEXT_NODE} from '../shared/HTMLNodeType'; * @return {?object} */ export function getOffsets(outerNode) { - const selection = window.getSelection && window.getSelection(); + const {ownerDocument} = outerNode; + const win = (ownerDocument && ownerDocument.defaultView) || window; + const selection = win.getSelection && win.getSelection(); if (!selection || selection.rangeCount === 0) { return null; @@ -150,11 +152,9 @@ export function getModernOffsetsFromPoints( * @param {object} offsets */ export function setOffsets(node, offsets) { - if (!window.getSelection) { - return; - } - - const selection = window.getSelection(); + const doc = node.ownerDocument || document; + const win = doc ? doc.defaultView : window; + 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 +180,7 @@ export function setOffsets(node, offsets) { ) { return; } - const range = document.createRange(); + const range = doc.createRange(); range.setStart(startMarker.node, startMarker.offset); selection.removeAllRanges(); diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 95337b3f47cb4..3f4a6938da6dd 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -10,24 +10,8 @@ import getActiveElement from './getActiveElement'; import * as ReactDOMSelection from './ReactDOMSelection'; import {ELEMENT_NODE, TEXT_NODE} from '../shared/HTMLNodeType'; -// TODO: this code is originally inlined from fbjs. -// It is likely that we don't actually need all these checks -// for the particular use case in this file. -function isNode(object) { - const doc = object ? object.ownerDocument || object : document; - const defaultView = doc.defaultView || window; - return !!( - object && - (typeof defaultView.Node === 'function' - ? object instanceof defaultView.Node - : typeof object === 'object' && - typeof object.nodeType === 'number' && - typeof object.nodeName === 'string') - ); -} - -function isTextNode(object) { - return isNode(object) && object.nodeType === TEXT_NODE; +function isTextNode(node) { + return node && node.nodeType === TEXT_NODE; } function containsNode(outerNode, innerNode) { @@ -49,7 +33,27 @@ function containsNode(outerNode, innerNode) { } function isInDocument(node) { - return containsNode(document.documentElement, node); + return ( + node && + node.ownerDocument && + containsNode(node.ownerDocument.documentElement, node) + ); +} + +function getActiveElementDeep() { + let win = window; + let element = getActiveElement(); + while (element instanceof win.HTMLIFrameElement) { + // Accessing the contentDocument of a HTMLIframeElement can cause the browser + // to throw, e.g. if it has a cross-origin src attribute + try { + win = element.contentDocument.defaultView; + } catch (e) { + return element; + } + element = getActiveElement(win.document); + } + return element; } /** @@ -80,7 +84,7 @@ export function hasSelectionCapabilities(elem) { } export function getSelectionInformation() { - const focusedElem = getActiveElement(); + const focusedElem = getActiveElementDeep(); return { focusedElem: focusedElem, selectionRange: hasSelectionCapabilities(focusedElem) @@ -95,7 +99,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)) { diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index 82d7bcd942f51..68f059cab600b 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -72,8 +72,10 @@ function getSelection(node) { start: node.selectionStart, end: node.selectionEnd, }; - } else if (window.getSelection) { - const selection = window.getSelection(); + } else { + const win = + (node.ownerDocument && node.ownerDocument.defaultView) || window; + const selection = win.getSelection(); return { anchorNode: selection.anchorNode, anchorOffset: selection.anchorOffset, @@ -83,10 +85,25 @@ function getSelection(node) { } } +/** + * Get document associated with the event target. + * + * @param {object} nativeEventTarget + * @return {Document} + */ +function getEventTargetDocument(eventTarget) { + return eventTarget.window === eventTarget + ? eventTarget.document + : eventTarget.nodeType === DOCUMENT_NODE + ? eventTarget + : eventTarget.ownerDocument; +} + /** * Poll selection to see whether it's changed. * * @param {object} nativeEvent + * @param {object} nativeEventTarget * @return {?SyntheticEvent} */ function constructSelectEvent(nativeEvent, nativeEventTarget) { @@ -94,10 +111,12 @@ function constructSelectEvent(nativeEvent, nativeEventTarget) { // selection (this matches native `select` event behavior). In HTML5, select // fires only on input and textarea thus if there's no focused element we // won't dispatch. + const doc = getEventTargetDocument(nativeEventTarget); + if ( mouseDown || activeElement == null || - activeElement !== getActiveElement() + activeElement !== getActiveElement(doc) ) { return null; } @@ -148,12 +167,7 @@ const SelectEventPlugin = { nativeEvent, nativeEventTarget, ) { - const doc = - nativeEventTarget.window === nativeEventTarget - ? nativeEventTarget.document - : nativeEventTarget.nodeType === DOCUMENT_NODE - ? nativeEventTarget - : nativeEventTarget.ownerDocument; + const doc = getEventTargetDocument(nativeEventTarget); // Track whether all listeners exists for this plugin. If none exist, we do // not extract events. See #3639. if (!doc || !isListeningToAllDependencies('onSelect', doc)) {
Please select a test fixture.