From e1d4110339e0e033280902fa4a7b7981ed400165 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 24 Oct 2017 14:56:40 -0700 Subject: [PATCH 01/18] Use event target doc as getActiveElement context --- packages/react-dom/src/events/SelectEventPlugin.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index d3023ae8b04b8..b6cf23998e8be 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -86,6 +86,7 @@ function getSelection(node) { * Poll selection to see whether it's changed. * * @param {object} nativeEvent + * @param {object} nativeEventTarget * @return {?SyntheticEvent} */ function constructSelectEvent(nativeEvent, nativeEventTarget) { @@ -93,10 +94,15 @@ 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; } From ed0cc8a5febf3540687476bd35ff70fb90025982 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Mon, 30 Oct 2017 09:16:38 -0700 Subject: [PATCH 02/18] =?UTF-8?q?Use=20node=E2=80=99s=20window=20and=20doc?= =?UTF-8?q?ument=20when=20possible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../react-dom/src/client/ReactDOMSelection.js | 14 ++++++++---- .../react-dom/src/events/SelectEventPlugin.js | 22 ++++++++++++------- .../src/events/SyntheticClipboardEvent.js | 8 ++++--- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/react-dom/src/client/ReactDOMSelection.js b/packages/react-dom/src/client/ReactDOMSelection.js index 886a4af6f49e0..51a5de8a051a8 100644 --- a/packages/react-dom/src/client/ReactDOMSelection.js +++ b/packages/react-dom/src/client/ReactDOMSelection.js @@ -16,7 +16,11 @@ var {TEXT_NODE} = require('../shared/HTMLNodeType'); * @return {?object} */ function getModernOffsets(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 @@ function getModernOffsetsFromPoints( * @param {object} offsets */ function setModernOffsets(node, offsets) { - if (!window.getSelection) { + var doc = node.ownerDocument || document; + + 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 @@ function setModernOffsets(node, offsets) { ) { return; } - var range = document.createRange(); + var range = doc.createRange(); range.setStart(startMarker.node, startMarker.offset); selection.removeAllRanges(); diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index b6cf23998e8be..9789d60a47c0f 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -71,14 +71,20 @@ function getSelection(node) { start: node.selectionStart, end: node.selectionEnd, }; - } else if (window.getSelection) { - var selection = window.getSelection(); - return { - anchorNode: selection.anchorNode, - anchorOffset: selection.anchorOffset, - focusNode: selection.focusNode, - focusOffset: selection.focusOffset, - }; + } else { + var win = window; + if (node.ownerDocument && node.ownerDocument.defaultView) { + win = node.ownerDocument.defaultView; + } + if (win.getSelection) { + var selection = win.getSelection(); + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + }; + } } } diff --git a/packages/react-dom/src/events/SyntheticClipboardEvent.js b/packages/react-dom/src/events/SyntheticClipboardEvent.js index d73860b669059..e33689f6e0d43 100644 --- a/packages/react-dom/src/events/SyntheticClipboardEvent.js +++ b/packages/react-dom/src/events/SyntheticClipboardEvent.js @@ -15,9 +15,11 @@ var SyntheticEvent = require('events/SyntheticEvent'); */ var ClipboardEventInterface = { clipboardData: function(event) { - return 'clipboardData' in event - ? event.clipboardData - : window.clipboardData; + if ('clipboardData' in event) { + return event.clipboardData; + } + var doc = (event.target && event.target.ownerDocument) || document; + return doc.defaultView.clipboardData; }, }; From 8f11b2c567b029634ad5c2bdc6a1b86ac192889a Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 4 Oct 2016 08:09:22 -0700 Subject: [PATCH 03/18] Make getting/setting selection check iframe contents If an input inside a same-domain iframe is the actual activeElement, this change will make the ReactInputSelection module get from and restore selection to that input, rather than the iframe element itself --- .../src/client/ReactInputSelection.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 605f7ac7684d1..c60769f5d9c23 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -18,6 +18,20 @@ function isInDocument(node) { return containsNode(document.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; +} + /** * @ReactInputSelection: React input selection module. Based on Selection.js, * but modified to be suitable for react and has a couple of bug fixes (doesn't @@ -36,7 +50,7 @@ var ReactInputSelection = { }, getSelectionInformation: function() { - var focusedElem = getActiveElement(); + var focusedElem = getFocusedElement(); return { focusedElem: focusedElem, selectionRange: ReactInputSelection.hasSelectionCapabilities(focusedElem) @@ -51,7 +65,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 && isInDocument(priorFocusedElem)) { From 83cfb558d85070b7eda64e38ea9d047e063bc075 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 31 Oct 2017 21:08:56 -0700 Subject: [PATCH 04/18] Add tests for ReactInputSelection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Had to remove some tests because they rely on DOM functionality that jsdom doesn’t yet support, like getSelection and iframe activeElement --- .../src/__tests__/ReactInputSelection-test.js | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 packages/react-dom/src/__tests__/ReactInputSelection-test.js diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js new file mode 100644 index 0000000000000..038b533cc7a4d --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -0,0 +1,169 @@ +/** + * 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'; + +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); + }; + + 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); + }); + }); +}); From 374d52500633a23116105c5d0eadb1844fc6ed4d Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Mon, 10 Oct 2016 00:18:55 -0700 Subject: [PATCH 05/18] Adapt restoreSelection to work for all activeElements --- .../src/__tests__/ReactInputSelection-test.js | 5 +- .../src/client/ReactInputSelection.js | 114 +++++++++++++----- 2 files changed, 86 insertions(+), 33 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 038b533cc7a4d..ed54e75b17832 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -149,8 +149,9 @@ describe('ReactInputSelection', () => { input.selectionStart = 1; input.selectionEnd = 10; var selectionInfo = ReactInputSelection.getSelectionInformation(); - expect(selectionInfo.focusedElem).toBe(input); - expect(selectionInfo.selectionRange).toEqual({start: 1, end: 10}); + 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); diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index c60769f5d9c23..3b634b16a3637 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -15,7 +15,7 @@ var ReactDOMSelection = require('./ReactDOMSelection'); var {ELEMENT_NODE} = require('../shared/HTMLNodeType'); function isInDocument(node) { - return containsNode(document.documentElement, node); + return node.ownerDocument && containsNode(node.ownerDocument.documentElement, node); } function getFocusedElement() { @@ -32,6 +32,62 @@ function getFocusedElement() { return focusedElem; } +function getElementsWithSelections(acc, win) { + acc = acc || []; + win = win || window; + var doc; + try { + doc = win.document; + } catch (e) { + return acc; + } + var element = null; + if (win.getSelection) { + var selection = win.getSelection(); + 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 === ELEMENT_NODE) { + 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; + } +} + /** * @ReactInputSelection: React input selection module. Based on Selection.js, * but modified to be suitable for react and has a couple of bug fixes (doesn't @@ -50,12 +106,17 @@ var ReactInputSelection = { }, getSelectionInformation: function() { - var focusedElem = getFocusedElement(); + 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.hasSelectionCapabilities(element) + ? ReactInputSelection.getSelection(element) + : null, + }; + }), }; }, @@ -65,34 +126,25 @@ var ReactInputSelection = { * nodes and place them back in, resulting in focus being lost. */ restoreSelection: function(priorSelectionInformation) { - var curFocusedElem = getFocusedElement(); - 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 === ELEMENT_NODE) { - 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); } }, From f6f9e352c761f601bcd26dd3a14766bf89155534 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Mon, 10 Oct 2016 21:05:35 -0700 Subject: [PATCH 06/18] Tests for getting / restoring selections across iframes --- .../src/__tests__/ReactInputSelection-test.js | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index ed54e75b17832..5a981a5ce6d87 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -23,6 +23,12 @@ describe('ReactInputSelection', () => { 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', () => { @@ -100,7 +106,7 @@ describe('ReactInputSelection', () => { }); it('gets selection on inputs in iframes', () => { - var iframe = document.createElement('iframe'); + const iframe = document.createElement('iframe'); document.body.appendChild(iframe); const input = document.createElement('input'); input.value = textValue; @@ -127,7 +133,7 @@ describe('ReactInputSelection', () => { }); it('sets selection on inputs in iframes', () => { - var iframe = document.createElement('iframe'); + const iframe = document.createElement('iframe'); document.body.appendChild(iframe); const input = document.createElement('input'); input.value = textValue; @@ -142,6 +148,9 @@ describe('ReactInputSelection', () => { 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); @@ -165,6 +174,46 @@ describe('ReactInputSelection', () => { 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 === input).toBe(true); + 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; }); }); }); From 6ac703fb31bc99e5a8d36805f59bec64dba6ca7c Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Wed, 15 Mar 2017 06:37:17 -0700 Subject: [PATCH 07/18] Add guards for Firefox and Safari compatibility Addresses https://github.com/facebook/react/pull/7936#issuecomment-270568409 and https://github.com/summernote/summernote/issues/1057 --- .../src/client/ReactInputSelection.js | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 3b634b16a3637..0fe79078f193b 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -38,22 +38,27 @@ function getElementsWithSelections(acc, win) { var doc; try { doc = win.document; + if (!doc) { + return acc; + } } catch (e) { return acc; } var element = null; if (win.getSelection) { var selection = win.getSelection(); - 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; + 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(); From cec8c3f8ef037a893838790b2f94221c0214605d Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 31 Oct 2017 21:13:02 -0700 Subject: [PATCH 08/18] Prettier --- .../src/__tests__/ReactInputSelection-test.js | 71 ++++++++++++++----- .../src/client/ReactInputSelection.js | 36 ++++++---- 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 5a981a5ce6d87..b8e9d56c4c5f4 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -26,8 +26,10 @@ describe('ReactInputSelection', () => { 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, + anchorOffset: win.document.activeElement && + win.document.activeElement.selectionStart, + focusOffset: win.document.activeElement && + win.document.activeElement.selectionEnd, }); describe('hasSelectionCapabilities', () => { @@ -49,12 +51,24 @@ describe('ReactInputSelection', () => { 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); + 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', () => { @@ -86,23 +100,37 @@ describe('ReactInputSelection', () => { 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}); + expect(ReactInputSelection.getSelection(input)).toEqual({ + start: 6, + end: 11, + }); - var textarea = createAndMountElement('textarea', {defaultValue: textValue}); + var textarea = createAndMountElement('textarea', { + defaultValue: textValue, + }); textarea.setSelectionRange(6, 11); - expect(ReactInputSelection.getSelection(textarea)).toEqual({start: 6, end: 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}); + 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}); + expect(ReactInputSelection.getSelection(node)).toEqual({ + start: 0, + end: 0, + }); }); it('gets selection on inputs in iframes', () => { @@ -126,7 +154,9 @@ describe('ReactInputSelection', () => { expect(input.selectionStart).toEqual(1); expect(input.selectionEnd).toEqual(10); - var textarea = createAndMountElement('textarea', {defaultValue: textValue}); + var textarea = createAndMountElement('textarea', { + defaultValue: textValue, + }); ReactInputSelection.setSelection(textarea, {start: 1, end: 10}); expect(textarea.selectionStart).toEqual(1); expect(textarea.selectionEnd).toEqual(10); @@ -160,7 +190,10 @@ describe('ReactInputSelection', () => { 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(selectionInfo.activeElements[0].selectionRange).toEqual({ + start: 1, + end: 10, + }); expect(document.activeElement).toBe(input); input.setSelectionRange(0, 0); document.body.removeChild(input); @@ -186,7 +219,8 @@ describe('ReactInputSelection', () => { var originalGetSelection = window.getSelection; var originalIframeGetSelection = iframeWin.getSelection; window.getSelection = window.getSelection || makeGetSelection(window); - iframeWin.getSelection = iframeWin.getSelection || makeGetSelection(iframeWin); + iframeWin.getSelection = + iframeWin.getSelection || makeGetSelection(iframeWin); var input = document.createElement('input'); input.value = textValue; @@ -196,7 +230,10 @@ describe('ReactInputSelection', () => { input.selectionEnd = 10; var selectionInfo = ReactInputSelection.getSelectionInformation(); expect(selectionInfo.focusedElement === input).toBe(true); - expect(selectionInfo.activeElements[0].selectionRange).toEqual({start: 1, end: 10}); + expect(selectionInfo.activeElements[0].selectionRange).toEqual({ + start: 1, + end: 10, + }); expect(document.activeElement).toBe(iframe); expect(iframeDoc.activeElement).toBe(input); diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 0fe79078f193b..9189b4a87cfcf 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -15,7 +15,9 @@ var ReactDOMSelection = require('./ReactDOMSelection'); var {ELEMENT_NODE} = require('../shared/HTMLNodeType'); function isInDocument(node) { - return node.ownerDocument && containsNode(node.ownerDocument.documentElement, node); + return ( + node.ownerDocument && containsNode(node.ownerDocument.documentElement, node) + ); } function getFocusedElement() { @@ -53,12 +55,14 @@ function getElementsWithSelections(acc, win) { var startOffset = selection.anchorOffset; var endOffset = selection.focusOffset; if (startNode && startNode.childNodes.length) { - if (startNode.childNodes[startOffset] === endNode.childNodes[endOffset]) { - element = startNode.childNodes[startOffset]; - } + if ( + startNode.childNodes[startOffset] === endNode.childNodes[endOffset] + ) { + element = startNode.childNodes[startOffset]; + } } else { - element = startNode; - } + element = startNode; + } } } else if (doc.selection) { var range = doc.selection.createRange(); @@ -67,7 +71,11 @@ function getElementsWithSelections(acc, win) { if (ReactInputSelection.hasSelectionCapabilities(element)) { acc = acc.concat(element); } - return Array.prototype.reduce.call(win.frames, getElementsWithSelections, acc); + return Array.prototype.reduce.call( + win.frames, + getElementsWithSelections, + acc, + ); } function focusNodePreservingScroll(element) { @@ -133,12 +141,14 @@ var ReactInputSelection = { restoreSelection: function(priorSelectionInformation) { priorSelectionInformation.activeElements.forEach(function(activeElement) { var element = activeElement.element; - if (isInDocument(element) && - getActiveElement(element.ownerDocument) !== element) { + if ( + isInDocument(element) && + getActiveElement(element.ownerDocument) !== element + ) { if (ReactInputSelection.hasSelectionCapabilities(element)) { ReactInputSelection.setSelection( element, - activeElement.selectionRange + activeElement.selectionRange, ); focusNodePreservingScroll(element); } @@ -147,8 +157,10 @@ var ReactInputSelection = { var curFocusedElement = getFocusedElement(); var priorFocusedElement = priorSelectionInformation.focusedElement; - if (curFocusedElement !== priorFocusedElement && - isInDocument(priorFocusedElement)) { + if ( + curFocusedElement !== priorFocusedElement && + isInDocument(priorFocusedElement) + ) { focusNodePreservingScroll(priorFocusedElement); } }, From 016379538e12a527e5164d60c224dc2beb188f3f Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Tue, 31 Oct 2017 21:14:58 -0700 Subject: [PATCH 09/18] Update test file header --- .../react-dom/src/__tests__/ReactInputSelection-test.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index b8e9d56c4c5f4..1c4497ceb363d 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -1,10 +1,8 @@ /** - * Copyright 2015-present, Facebook, Inc. - * All rights reserved. + * Copyright (c) 2013-present, Facebook, Inc. * - * 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. + * 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 */ From d777546e873d6b4e8b52e4db10cdbc379949e2a9 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Wed, 1 Nov 2017 08:18:56 -0700 Subject: [PATCH 10/18] Avoid causing focus/blur issues from restoreSelection Also, improve variable naming and make helpers more efficient (for loop instead of Array.prototype.reduce, refactor to avoid unnecessary .map()) --- .../src/__tests__/ReactInputSelection-test.js | 10 +-- .../src/client/ReactInputSelection.js | 69 +++++++++---------- 2 files changed, 39 insertions(+), 40 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 1c4497ceb363d..76890e9fc4537 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -186,9 +186,9 @@ describe('ReactInputSelection', () => { 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({ + expect(selectionInfo.activeElement).toBe(input); + expect(selectionInfo.elementSelections[0].element).toBe(input); + expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ start: 1, end: 10, }); @@ -227,8 +227,8 @@ describe('ReactInputSelection', () => { input.selectionStart = 1; input.selectionEnd = 10; var selectionInfo = ReactInputSelection.getSelectionInformation(); - expect(selectionInfo.focusedElement === input).toBe(true); - expect(selectionInfo.activeElements[0].selectionRange).toEqual({ + expect(selectionInfo.activeElement === input).toBe(true); + expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ start: 1, end: 10, }); diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 9189b4a87cfcf..7914e6fcb6bd2 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -20,18 +20,18 @@ function isInDocument(node) { ); } -function getFocusedElement() { +function getActiveElementDeep() { var win = window; - var focusedElem = getActiveElement(); - while (focusedElem instanceof win.HTMLIFrameElement) { + var element = getActiveElement(); + while (element instanceof win.HTMLIFrameElement) { try { - win = focusedElem.contentDocument.defaultView; + win = element.contentDocument.defaultView; } catch (e) { - return focusedElem; + return element; } - focusedElem = getActiveElement(win.document); + element = getActiveElement(win.document); } - return focusedElem; + return element; } function getElementsWithSelections(acc, win) { @@ -68,14 +68,19 @@ function getElementsWithSelections(acc, win) { var range = doc.selection.createRange(); element = range.parentElement(); } + if (ReactInputSelection.hasSelectionCapabilities(element)) { - acc = acc.concat(element); + acc = acc.concat({ + element: element, + selectionRange: ReactInputSelection.getSelection(element), + }); } - return Array.prototype.reduce.call( - win.frames, - getElementsWithSelections, - acc, - ); + + for (var i = 0; i < win.frames.length; i++) { + acc = getElementsWithSelections(acc, win.frames[i]); + } + + return acc; } function focusNodePreservingScroll(element) { @@ -119,17 +124,9 @@ var ReactInputSelection = { }, getSelectionInformation: function() { - var focusedElement = getFocusedElement(); return { - focusedElement: focusedElement, - activeElements: getElementsWithSelections().map(function(element) { - return { - element: element, - selectionRange: ReactInputSelection.hasSelectionCapabilities(element) - ? ReactInputSelection.getSelection(element) - : null, - }; - }), + activeElement: getActiveElementDeep(), + elementSelections: getElementsWithSelections(), }; }, @@ -139,29 +136,31 @@ var ReactInputSelection = { * nodes and place them back in, resulting in focus being lost. */ restoreSelection: function(priorSelectionInformation) { - priorSelectionInformation.activeElements.forEach(function(activeElement) { - var element = activeElement.element; + var priorActiveElement = priorSelectionInformation.activeElement; + if ( + !isInDocument(priorActiveElement) || + priorActiveElement === priorActiveElement.ownerDocument.body + ) { + return; + } + priorSelectionInformation.elementSelections.forEach(function(selection) { + var element = selection.element; if ( isInDocument(element) && getActiveElement(element.ownerDocument) !== element ) { - if (ReactInputSelection.hasSelectionCapabilities(element)) { - ReactInputSelection.setSelection( - element, - activeElement.selectionRange, - ); + ReactInputSelection.setSelection(element, selection.selectionRange); + if (element !== priorActiveElement) { focusNodePreservingScroll(element); } } }); - var curFocusedElement = getFocusedElement(); - var priorFocusedElement = priorSelectionInformation.focusedElement; if ( - curFocusedElement !== priorFocusedElement && - isInDocument(priorFocusedElement) + getActiveElementDeep() !== priorActiveElement && + isInDocument(priorActiveElement) ) { - focusNodePreservingScroll(priorFocusedElement); + focusNodePreservingScroll(priorActiveElement); } }, From 7c59a775cd0cdd1082f04510917f8bac45c82b23 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Wed, 1 Nov 2017 08:24:31 -0700 Subject: [PATCH 11/18] Add early return for restoring selection common case If there is only a single element with selection (most common case) and that element is still the active element, skip the extra work --- .../react-dom/src/client/ReactInputSelection.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 7914e6fcb6bd2..f249425f8b6b7 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -137,13 +137,19 @@ var ReactInputSelection = { */ restoreSelection: function(priorSelectionInformation) { var priorActiveElement = priorSelectionInformation.activeElement; + var elementSelections = priorSelectionInformation.elementSelections; + var curActiveElement = getActiveElementDeep(); + var isActiveElementOnlySelection = + elementSelections.length === 1 && + elementSelections[0] === priorActiveElement; if ( !isInDocument(priorActiveElement) || - priorActiveElement === priorActiveElement.ownerDocument.body + priorActiveElement === priorActiveElement.ownerDocument.body || + (isActiveElementOnlySelection && curActiveElement === priorActiveElement) ) { return; } - priorSelectionInformation.elementSelections.forEach(function(selection) { + elementSelections.forEach(function(selection) { var element = selection.element; if ( isInDocument(element) && @@ -152,12 +158,13 @@ var ReactInputSelection = { ReactInputSelection.setSelection(element, selection.selectionRange); if (element !== priorActiveElement) { focusNodePreservingScroll(element); + curActiveElement = element; } } }); if ( - getActiveElementDeep() !== priorActiveElement && + curActiveElement !== priorActiveElement && isInDocument(priorActiveElement) ) { focusNodePreservingScroll(priorActiveElement); From 2d4ac46a95cdc48591e25e4ca2a7d976784c3b2d Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Wed, 15 Nov 2017 20:03:39 -0800 Subject: [PATCH 12/18] Prefer active element as element with selection --- .../src/client/ReactInputSelection.js | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 79200a03faca4..14b28cb244e67 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -44,27 +44,30 @@ function getElementsWithSelections(acc, win) { } 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]; + var element = getActiveElement(doc); + // Use getSelection if no activeElement with selection capabilities + if (!hasSelectionCapabilities(element)) { + 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 { - element = startNode; } + } else if (doc.selection) { + var range = doc.selection.createRange(); + element = range.parentElement(); } - } else if (doc.selection) { - var range = doc.selection.createRange(); - element = range.parentElement(); } if (hasSelectionCapabilities(element)) { From b3998ebd981ffd55d57ea5a748e3f35516a1eaa2 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Thu, 16 Nov 2017 11:21:20 -0800 Subject: [PATCH 13/18] Prettier --- .../react-dom/src/__tests__/ReactInputSelection-test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 76890e9fc4537..71bfe1500f451 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -24,10 +24,10 @@ describe('ReactInputSelection', () => { 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, + anchorOffset: + win.document.activeElement && win.document.activeElement.selectionStart, + focusOffset: + win.document.activeElement && win.document.activeElement.selectionEnd, }); describe('hasSelectionCapabilities', () => { From 174375cf24cda80d3fe7da3573472d015434c870 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Fri, 17 Nov 2017 07:28:33 -0800 Subject: [PATCH 14/18] Prevent restoreSelection overwriting active element --- packages/react-dom/src/client/ReactInputSelection.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index f214ddf0ab8f2..267e41737e35b 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -45,8 +45,8 @@ function getElementsWithSelections(acc, win) { return acc; } let element = getActiveElement(doc); - // Use getSelection if no activeElement with selection capabilities - if (!hasSelectionCapabilities(element)) { + // Use getSelection if activeElement is the document body + if (element === doc.body) { if (win.getSelection) { const selection = win.getSelection(); if (selection) { From 39a9589618767a3d1ce488479a8bc1957ed49079 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Fri, 17 Nov 2017 07:37:41 -0800 Subject: [PATCH 15/18] Code review-based cleanup and improvements --- .../src/client/ReactInputSelection.js | 5 +--- .../react-dom/src/events/SelectEventPlugin.js | 26 ++++++++++++------- .../src/events/SyntheticClipboardEvent.js | 8 +++--- 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 267e41737e35b..c121c5a686318 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -164,10 +164,7 @@ export function restoreSelection(priorSelectionInformation) { } }); - if ( - curActiveElement !== priorActiveElement && - isInDocument(priorActiveElement) - ) { + if (curActiveElement !== priorActiveElement) { focusNodePreservingScroll(priorActiveElement); } } diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index 36ca76f96f1fa..d6f1b5ee0110b 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -81,6 +81,20 @@ 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. * @@ -93,10 +107,7 @@ 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; + var doc = getEventTargetDocument(nativeEventTarget); if ( mouseDown || @@ -152,12 +163,7 @@ var SelectEventPlugin = { nativeEvent, nativeEventTarget, ) { - var doc = - nativeEventTarget.window === nativeEventTarget - ? nativeEventTarget.document - : nativeEventTarget.nodeType === DOCUMENT_NODE - ? nativeEventTarget - : nativeEventTarget.ownerDocument; + var 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)) { diff --git a/packages/react-dom/src/events/SyntheticClipboardEvent.js b/packages/react-dom/src/events/SyntheticClipboardEvent.js index afd8c3768efa2..ebe2a3edd93b2 100644 --- a/packages/react-dom/src/events/SyntheticClipboardEvent.js +++ b/packages/react-dom/src/events/SyntheticClipboardEvent.js @@ -13,11 +13,9 @@ import SyntheticEvent from 'events/SyntheticEvent'; */ var ClipboardEventInterface = { clipboardData: function(event) { - if ('clipboardData' in event) { - return event.clipboardData; - } - var doc = (event.target && event.target.ownerDocument) || document; - return doc.defaultView.clipboardData; + return 'clipboardData' in event + ? event.clipboardData + : window.clipboardData; }, }; From 82443f7738205ec6ee29bc1f5701089a9c4fadf2 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Sun, 19 Nov 2017 10:04:45 -0800 Subject: [PATCH 16/18] Detect all valid selection-capable input types --- .../src/__tests__/ReactInputSelection-test.js | 65 +++++++++++-------- .../src/client/ReactInputSelection.js | 17 ++++- 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 71bfe1500f451..543fabb9953bb 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -36,37 +36,50 @@ describe('ReactInputSelection', () => { expect(ReactInputSelection.hasSelectionCapabilities(textarea)).toBe(true); }); - it('returns true for text inputs', () => { - var inputText = document.createElement('input'); + it('returns true for inputs that can support text selection ranges', () => { + [ + 'date', + 'datetime-local', + 'email', + 'month', + 'number', + 'password', + 'search', + 'tel', + 'text', + 'time', + 'url', + 'week', + ].forEach(type => { + const input = document.createElement('input'); + input.type = type; + expect(ReactInputSelection.hasSelectionCapabilities(input)).toBe(true); + }); + 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 false for non-text-selectable inputs', () => { + [ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit', + ].forEach(type => { + const input = document.createElement('input'); + input.type = type; + expect(ReactInputSelection.hasSelectionCapabilities(input)).toBe(false); + }); }); it('returns true for contentEditable elements', () => { diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index c121c5a686318..87a8edff82255 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -113,12 +113,25 @@ function focusNodePreservingScroll(element) { * assume buttons have range selections allowed). * Input selection module for React. */ - +const selectionCapableTypes = [ + 'date', + 'datetime-local', + 'email', + 'month', + 'number', + 'password', + 'search', + 'tel', + 'text', + 'time', + 'url', + 'week', +]; export function hasSelectionCapabilities(elem) { const nodeName = elem && elem.nodeName && elem.nodeName.toLowerCase(); return ( nodeName && - ((nodeName === 'input' && elem.type === 'text') || + ((nodeName === 'input' && selectionCapableTypes.includes(elem.type)) || nodeName === 'textarea' || elem.contentEditable === 'true') ); From 074ff5178a00a5226159ed470632cfefaf791262 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Sat, 30 Dec 2017 11:37:15 -0800 Subject: [PATCH 17/18] Guard against null (based on failing test) See https://circleci.com/gh/facebook/react/8411#tests/containers/1 --- packages/react-dom/src/client/ReactInputSelection.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 255075ec9ea56..a991540527bda 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -13,7 +13,9 @@ import {ELEMENT_NODE} from '../shared/HTMLNodeType'; function isInDocument(node) { return ( - node.ownerDocument && containsNode(node.ownerDocument.documentElement, node) + node && + node.ownerDocument && + containsNode(node.ownerDocument.documentElement, node) ); } From 07447109230c452c6e87a656e6d94fcbbfe28ce1 Mon Sep 17 00:00:00 2001 From: Andrew Patton Date: Sat, 30 Dec 2017 16:11:16 -0800 Subject: [PATCH 18/18] Linting (var :arrow_right: const) --- .../src/__tests__/ReactInputSelection-test.js | 66 +++++++++---------- .../react-dom/src/client/ReactDOMSelection.js | 2 +- .../react-dom/src/events/SelectEventPlugin.js | 2 +- 3 files changed, 35 insertions(+), 35 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactInputSelection-test.js b/packages/react-dom/src/__tests__/ReactInputSelection-test.js index 543fabb9953bb..f49d4ba729587 100644 --- a/packages/react-dom/src/__tests__/ReactInputSelection-test.js +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -9,19 +9,19 @@ 'use strict'; -var React = require('react'); -var ReactDOM = require('react-dom'); -var ReactTestUtils = require('react-dom/test-utils'); -var ReactInputSelection = require('../client/ReactInputSelection'); +const React = require('react'); +const ReactDOM = require('react-dom'); +const ReactTestUtils = require('react-dom/test-utils'); +const 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); + const textValue = 'the text contents'; + const createAndMountElement = (type, props, children) => { + const element = React.createElement(type, props, children); + const instance = ReactTestUtils.renderIntoDocument(element); return ReactDOM.findDOMNode(instance); }; - var makeGetSelection = (win = window) => () => ({ + const makeGetSelection = (win = window) => () => ({ anchorNode: win.document.activeElement, focusNode: win.document.activeElement, anchorOffset: @@ -32,7 +32,7 @@ describe('ReactInputSelection', () => { describe('hasSelectionCapabilities', () => { it('returns true for textareas', () => { - var textarea = document.createElement('textarea'); + const textarea = document.createElement('textarea'); expect(ReactInputSelection.hasSelectionCapabilities(textarea)).toBe(true); }); @@ -56,7 +56,7 @@ describe('ReactInputSelection', () => { expect(ReactInputSelection.hasSelectionCapabilities(input)).toBe(true); }); - var inputReadOnly = document.createElement('input'); + const inputReadOnly = document.createElement('input'); inputReadOnly.readOnly = 'true'; expect(ReactInputSelection.hasSelectionCapabilities(inputReadOnly)).toBe( true, @@ -83,13 +83,13 @@ describe('ReactInputSelection', () => { }); it('returns true for contentEditable elements', () => { - var div = document.createElement('div'); + const div = document.createElement('div'); div.contentEditable = 'true'; - var body = document.createElement('body'); + const body = document.createElement('body'); body.contentEditable = 'true'; - var input = document.createElement('input'); + const input = document.createElement('input'); input.contentEditable = 'true'; - var select = document.createElement('select'); + const select = document.createElement('select'); select.contentEditable = 'true'; expect(ReactInputSelection.hasSelectionCapabilities(div)).toBe(true); @@ -99,8 +99,8 @@ describe('ReactInputSelection', () => { }); it('returns false for any other type of HTMLElement', () => { - var select = document.createElement('select'); - var iframe = document.createElement('iframe'); + const select = document.createElement('select'); + const iframe = document.createElement('iframe'); expect(ReactInputSelection.hasSelectionCapabilities(select)).toBe(false); expect(ReactInputSelection.hasSelectionCapabilities(iframe)).toBe(false); @@ -109,14 +109,14 @@ describe('ReactInputSelection', () => { describe('getSelection', () => { it('gets selection offsets from a textarea or input', () => { - var input = createAndMountElement('input', {defaultValue: textValue}); + const input = createAndMountElement('input', {defaultValue: textValue}); input.setSelectionRange(6, 11); expect(ReactInputSelection.getSelection(input)).toEqual({ start: 6, end: 11, }); - var textarea = createAndMountElement('textarea', { + const textarea = createAndMountElement('textarea', { defaultValue: textValue, }); textarea.setSelectionRange(6, 11); @@ -127,7 +127,7 @@ describe('ReactInputSelection', () => { }); it('gets selection offsets from a contentEditable element', () => { - var node = createAndMountElement('div', null, textValue); + const node = createAndMountElement('div', null, textValue); node.selectionStart = 6; node.selectionEnd = 11; expect(ReactInputSelection.getSelection(node)).toEqual({ @@ -137,7 +137,7 @@ describe('ReactInputSelection', () => { }); it('gets selection offsets as start: 0, end: 0 if no selection', () => { - var node = createAndMountElement('select'); + const node = createAndMountElement('select'); expect(ReactInputSelection.getSelection(node)).toEqual({ start: 0, end: 0, @@ -160,12 +160,12 @@ describe('ReactInputSelection', () => { describe('setSelection', () => { it('sets selection offsets on textareas and inputs', () => { - var input = createAndMountElement('input', {defaultValue: textValue}); + const 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', { + const textarea = createAndMountElement('textarea', { defaultValue: textValue, }); ReactInputSelection.setSelection(textarea, {start: 1, end: 10}); @@ -190,15 +190,15 @@ describe('ReactInputSelection', () => { describe('getSelectionInformation/restoreSelection', () => { it('gets and restores selection for inputs that get remounted', () => { // Mock window getSelection if needed - var originalGetSelection = window.getSelection; + const originalGetSelection = window.getSelection; window.getSelection = window.getSelection || makeGetSelection(window); - var input = document.createElement('input'); + const input = document.createElement('input'); input.value = textValue; document.body.appendChild(input); input.focus(); input.selectionStart = 1; input.selectionEnd = 10; - var selectionInfo = ReactInputSelection.getSelectionInformation(); + const selectionInfo = ReactInputSelection.getSelectionInformation(); expect(selectionInfo.activeElement).toBe(input); expect(selectionInfo.elementSelections[0].element).toBe(input); expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ @@ -222,24 +222,24 @@ describe('ReactInputSelection', () => { }); it('gets and restores selection for inputs in an iframe that get remounted', () => { - var iframe = document.createElement('iframe'); + const iframe = document.createElement('iframe'); document.body.appendChild(iframe); - var iframeDoc = iframe.contentDocument; - var iframeWin = iframeDoc.defaultView; + const iframeDoc = iframe.contentDocument; + const iframeWin = iframeDoc.defaultView; // Mock window and iframe getSelection if needed - var originalGetSelection = window.getSelection; - var originalIframeGetSelection = iframeWin.getSelection; + const originalGetSelection = window.getSelection; + const originalIframeGetSelection = iframeWin.getSelection; window.getSelection = window.getSelection || makeGetSelection(window); iframeWin.getSelection = iframeWin.getSelection || makeGetSelection(iframeWin); - var input = document.createElement('input'); + const input = document.createElement('input'); input.value = textValue; iframeDoc.body.appendChild(input); input.focus(); input.selectionStart = 1; input.selectionEnd = 10; - var selectionInfo = ReactInputSelection.getSelectionInformation(); + const selectionInfo = ReactInputSelection.getSelectionInformation(); expect(selectionInfo.activeElement === input).toBe(true); expect(selectionInfo.elementSelections[0].selectionRange).toEqual({ start: 1, diff --git a/packages/react-dom/src/client/ReactDOMSelection.js b/packages/react-dom/src/client/ReactDOMSelection.js index 0257da4770405..767894a43697e 100644 --- a/packages/react-dom/src/client/ReactDOMSelection.js +++ b/packages/react-dom/src/client/ReactDOMSelection.js @@ -154,7 +154,7 @@ export function getModernOffsetsFromPoints( * @param {object} offsets */ export function setOffsets(node, offsets) { - var doc = node.ownerDocument || document; + const doc = node.ownerDocument || document; if (!doc.defaultView.getSelection) { return; diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index 8a9198bff195c..3f24870e471af 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -107,7 +107,7 @@ 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 = getEventTargetDocument(nativeEventTarget); + const doc = getEventTargetDocument(nativeEventTarget); if ( mouseDown ||