diff --git a/src/renderers/dom/client/ReactDOMSelection.js b/src/renderers/dom/client/ReactDOMSelection.js index aff9e894bba..cbce488ebef 100644 --- a/src/renderers/dom/client/ReactDOMSelection.js +++ b/src/renderers/dom/client/ReactDOMSelection.js @@ -40,7 +40,7 @@ function isCollapsed(anchorNode, anchorOffset, focusNode, focusOffset) { * @return {object} */ function getIEOffsets(node) { - var selection = document.selection; + var selection = node.ownerDocument.selection; var selectedRange = selection.createRange(); var selectedLength = selectedRange.text.length; @@ -63,7 +63,8 @@ function getIEOffsets(node) { * @return {?object} */ function getModernOffsets(node) { - var selection = window.getSelection && window.getSelection(); + var win = node.ownerDocument.defaultView; + var selection = win.getSelection && win.getSelection(); if (!selection || selection.rangeCount === 0) { return null; @@ -119,7 +120,7 @@ function getModernOffsets(node) { var end = start + rangeLength; // Detect whether the selection is backward. - var detectionRange = document.createRange(); + var detectionRange = node.ownerDocument.createRange(); detectionRange.setStart(anchorNode, anchorOffset); detectionRange.setEnd(focusNode, focusOffset); var isBackward = detectionRange.collapsed; @@ -135,7 +136,7 @@ function getModernOffsets(node) { * @param {object} offsets */ function setIEOffsets(node, offsets) { - var range = document.selection.createRange().duplicate(); + var range = node.ownerDocument.selection.createRange().duplicate(); var start, end; if (offsets.end === undefined) { @@ -169,11 +170,12 @@ function setIEOffsets(node, offsets) { * @param {object} offsets */ function setModernOffsets(node, offsets) { - if (!window.getSelection) { + var win = node.ownerDocument.defaultView; + if (!win.getSelection) { return; } - var selection = window.getSelection(); + var selection = win.getSelection(); var length = node[getTextContentAccessor()].length; var start = Math.min(offsets.start, length); var end = offsets.end === undefined ? @@ -191,7 +193,7 @@ function setModernOffsets(node, offsets) { var endMarker = getNodeForCharacterOffset(node, end); if (startMarker && endMarker) { - var range = document.createRange(); + var range = node.ownerDocument.createRange(); range.setStart(startMarker.node, startMarker.offset); selection.removeAllRanges(); diff --git a/src/renderers/dom/client/ReactInputSelection.js b/src/renderers/dom/client/ReactInputSelection.js index 46e6931a6db..99e96b2c715 100644 --- a/src/renderers/dom/client/ReactInputSelection.js +++ b/src/renderers/dom/client/ReactInputSelection.js @@ -18,7 +18,21 @@ var focusNode = require('focusNode'); var getActiveElement = require('getActiveElement'); function isInDocument(node) { - return containsNode(document.documentElement, node); + return containsNode(node.ownerDocument.documentElement, node); +} + +function getFocusedElement() { + var win = window; + var focusedElem = getActiveElement(); + while (focusedElem instanceof win.HTMLIFrameElement) { + try { + win = focusedElem.contentDocument.defaultView; + } catch (e) { + return focusedElem; + } + focusedElem = getActiveElement(win.document); + } + return focusedElem; } /** @@ -39,7 +53,7 @@ var ReactInputSelection = { }, getSelectionInformation: function() { - var focusedElem = getActiveElement(); + var focusedElem = getFocusedElement(); return { focusedElem: focusedElem, selectionRange: @@ -55,7 +69,7 @@ var ReactInputSelection = { * nodes and place them back in, resulting in focus being lost. */ restoreSelection: function(priorSelectionInformation) { - var curFocusedElem = getActiveElement(); + var curFocusedElem = getFocusedElement(); var priorFocusedElem = priorSelectionInformation.focusedElem; var priorSelectionRange = priorSelectionInformation.selectionRange; if (curFocusedElem !== priorFocusedElem && @@ -85,10 +99,10 @@ var ReactInputSelection = { start: input.selectionStart, end: input.selectionEnd, }; - } else if (document.selection && + } else if (input.ownerDocument.selection && (input.nodeName && input.nodeName.toLowerCase() === 'input')) { // IE8 input. - var range = document.selection.createRange(); + var range = input.ownerDocument.selection.createRange(); // There can only be one selection per document in IE, so it must // be in our element. if (range.parentElement() === input) { @@ -106,8 +120,8 @@ var ReactInputSelection = { }, /** - * @setSelection: Sets the selection bounds of a textarea or input and focuses - * the input. + * @setSelection: Sets the selection bounds of a textarea, input or + * contentEditable node. * -@input Set selection bounds of this input or textarea * -@offsets Object of same form that is returned from get* */ diff --git a/src/renderers/dom/client/__tests__/ReactInputSelection-test.js b/src/renderers/dom/client/__tests__/ReactInputSelection-test.js new file mode 100644 index 00000000000..4dc53b7c13d --- /dev/null +++ b/src/renderers/dom/client/__tests__/ReactInputSelection-test.js @@ -0,0 +1,177 @@ +/** + * Copyright 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +describe('ReactInputSelection', () => { + var React; + var ReactDOM; + var ReactTestUtils; + var 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); + }; + + beforeEach(() => { + jest.resetModuleRegistry(); + + React = require('React'); + ReactDOM = require('ReactDOM'); + ReactTestUtils = require('ReactTestUtils'); + ReactInputSelection = require('ReactInputSelection'); + }); + + 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'; + 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', () => { + var 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', () => { + var 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', () => { + 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.focusedElem).toBe(input); + expect(selectionInfo.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); + }); + }); +}); diff --git a/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js b/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js index a4467c72a28..a73c183d302 100644 --- a/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js +++ b/src/renderers/dom/client/eventPlugins/SelectEventPlugin.js @@ -71,16 +71,19 @@ function getSelection(node) { start: node.selectionStart, end: node.selectionEnd, }; - } else if (window.getSelection) { - var selection = window.getSelection(); + } + var doc = node.ownerDocument || node.document || node; + var win = doc.defaultView || doc.parentWindow; + if (win.getSelection) { + var selection = win.getSelection(); return { anchorNode: selection.anchorNode, anchorOffset: selection.anchorOffset, focusNode: selection.focusNode, focusOffset: selection.focusOffset, }; - } else if (document.selection) { - var range = document.selection.createRange(); + } else if (doc.selection) { + var range = doc.selection.createRange(); return { parentElement: range.parentElement(), text: range.text, @@ -94,6 +97,7 @@ function getSelection(node) { * Poll selection to see whether it's changed. * * @param {object} nativeEvent + * @param {object} nativeEventTarget * @return {?SyntheticEvent} */ function constructSelectEvent(nativeEvent, nativeEventTarget) { @@ -101,9 +105,10 @@ 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. + var doc = nativeEventTarget.ownerDocument || nativeEventTarget.document || nativeEventTarget; if (mouseDown || activeElement == null || - activeElement !== getActiveElement()) { + activeElement !== getActiveElement(doc)) { return null; }