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..79414cfaede --- /dev/null +++ b/src/renderers/dom/client/__tests__/ReactInputSelection-test.js @@ -0,0 +1,227 @@ +/** + * 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); + }; + 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, + }); + + 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', () => { + 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.focusedElement).toBe(input); + expect(selectionInfo.activeElements[0].element).toBe(input); + expect(selectionInfo.activeElements[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.focusedElement).toBe(input); + expect(selectionInfo.activeElements[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; + }); + }); +}); diff --git a/src/renderers/dom/shared/ReactDOMSelection.js b/src/renderers/dom/shared/ReactDOMSelection.js index aff9e894bba..cbce488ebef 100644 --- a/src/renderers/dom/shared/ReactDOMSelection.js +++ b/src/renderers/dom/shared/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/shared/ReactInputSelection.js b/src/renderers/dom/shared/ReactInputSelection.js index f95b5b2552a..35414595f0a 100644 --- a/src/renderers/dom/shared/ReactInputSelection.js +++ b/src/renderers/dom/shared/ReactInputSelection.js @@ -18,7 +18,82 @@ 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; +} + +function getElementsWithSelections(acc, win) { + acc = acc || []; + win = win || window; + var doc; + try { + doc = win.document; + if (!doc) { + return acc; + } + } catch (e) { + return acc; + } + var element = null; + if (win.getSelection) { + var selection = win.getSelection(); + if (selection) { + var startNode = selection.anchorNode; + var endNode = selection.focusNode; + var startOffset = selection.anchorOffset; + var endOffset = selection.focusOffset; + if (startNode && startNode.childNodes.length) { + if (startNode.childNodes[startOffset] === endNode.childNodes[endOffset]) { + element = startNode.childNodes[startOffset]; + } + } else { + element = startNode; + } + } + } else if (doc.selection) { + var range = doc.selection.createRange(); + element = range.parentElement(); + } + if (ReactInputSelection.hasSelectionCapabilities(element)) { + acc = acc.concat(element); + } + return Array.prototype.reduce.call(win.frames, getElementsWithSelections, acc); +} + +function focusNodePreservingScroll(element) { + // Focusing a node can change the scroll position, which is undesirable + const ancestors = []; + let ancestor = element; + while ((ancestor = ancestor.parentNode)) { + if (ancestor.nodeType === 1) { + ancestors.push({ + element: ancestor, + left: ancestor.scrollLeft, + top: ancestor.scrollTop, + }); + } + } + + focusNode(element); + + for (let i = 0; i < ancestors.length; i++) { + const info = ancestors[i]; + info.element.scrollLeft = info.left; + info.element.scrollTop = info.top; + } } /** @@ -39,13 +114,15 @@ var ReactInputSelection = { }, getSelectionInformation: function() { - var focusedElem = getActiveElement(); + var focusedElement = getFocusedElement(); return { - focusedElem: focusedElem, - selectionRange: - ReactInputSelection.hasSelectionCapabilities(focusedElem) ? - ReactInputSelection.getSelection(focusedElem) : - null, + focusedElement: focusedElement, + activeElements: getElementsWithSelections().map(function(element) { + return { + element: element, + selectionRange: ReactInputSelection.getSelection(element), + }; + }), }; }, @@ -55,38 +132,25 @@ var ReactInputSelection = { * nodes and place them back in, resulting in focus being lost. */ restoreSelection: function(priorSelectionInformation) { - var curFocusedElem = getActiveElement(); - var priorFocusedElem = priorSelectionInformation.focusedElem; - var priorSelectionRange = priorSelectionInformation.selectionRange; - if (curFocusedElem !== priorFocusedElem && - isInDocument(priorFocusedElem)) { - if (ReactInputSelection.hasSelectionCapabilities(priorFocusedElem)) { - ReactInputSelection.setSelection( - priorFocusedElem, - priorSelectionRange - ); - } - - // Focusing a node can change the scroll position, which is undesirable - const ancestors = []; - let ancestor = priorFocusedElem; - while ((ancestor = ancestor.parentNode)) { - if (ancestor.nodeType === 1) { - ancestors.push({ - element: ancestor, - left: ancestor.scrollLeft, - top: ancestor.scrollTop, - }); + priorSelectionInformation.activeElements.forEach(function(activeElement) { + var element = activeElement.element; + if (isInDocument(element) && + getActiveElement(element.ownerDocument) !== element) { + if (ReactInputSelection.hasSelectionCapabilities(element)) { + ReactInputSelection.setSelection( + element, + activeElement.selectionRange + ); + focusNodePreservingScroll(element); } } + }); - focusNode(priorFocusedElem); - - for (let i = 0; i < ancestors.length; i++) { - const info = ancestors[i]; - info.element.scrollLeft = info.left; - info.element.scrollTop = info.top; - } + var curFocusedElement = getFocusedElement(); + var priorFocusedElement = priorSelectionInformation.focusedElement; + if (curFocusedElement !== priorFocusedElement && + isInDocument(priorFocusedElement)) { + focusNodePreservingScroll(priorFocusedElement); } }, @@ -105,10 +169,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) { @@ -126,8 +190,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/shared/eventPlugins/SelectEventPlugin.js b/src/renderers/dom/shared/eventPlugins/SelectEventPlugin.js index 831391dd448..5e034dd9050 100644 --- a/src/renderers/dom/shared/eventPlugins/SelectEventPlugin.js +++ b/src/renderers/dom/shared/eventPlugins/SelectEventPlugin.js @@ -73,16 +73,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, @@ -96,6 +99,7 @@ function getSelection(node) { * Poll selection to see whether it's changed. * * @param {object} nativeEvent + * @param {object} nativeEventTarget * @return {?SyntheticEvent} */ function constructSelectEvent(nativeEvent, nativeEventTarget) { @@ -103,9 +107,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; }