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..f49d4ba729587 --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactInputSelection-test.js @@ -0,0 +1,267 @@ +/** + * Copyright (c) 2013-present, Facebook, Inc. + * + * 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 + */ + +'use strict'; + +const React = require('react'); +const ReactDOM = require('react-dom'); +const ReactTestUtils = require('react-dom/test-utils'); +const ReactInputSelection = require('../client/ReactInputSelection'); + +describe('ReactInputSelection', () => { + 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); + }; + const 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', () => { + const textarea = document.createElement('textarea'); + expect(ReactInputSelection.hasSelectionCapabilities(textarea)).toBe(true); + }); + + 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); + }); + + const inputReadOnly = document.createElement('input'); + inputReadOnly.readOnly = 'true'; + expect(ReactInputSelection.hasSelectionCapabilities(inputReadOnly)).toBe( + true, + ); + }); + + 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', () => { + const div = document.createElement('div'); + div.contentEditable = 'true'; + const body = document.createElement('body'); + body.contentEditable = 'true'; + const input = document.createElement('input'); + input.contentEditable = 'true'; + const 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', () => { + const select = document.createElement('select'); + const 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', () => { + const input = createAndMountElement('input', {defaultValue: textValue}); + input.setSelectionRange(6, 11); + expect(ReactInputSelection.getSelection(input)).toEqual({ + start: 6, + end: 11, + }); + + const 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', () => { + const 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', () => { + const 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', () => { + const input = createAndMountElement('input', {defaultValue: textValue}); + ReactInputSelection.setSelection(input, {start: 1, end: 10}); + expect(input.selectionStart).toEqual(1); + expect(input.selectionEnd).toEqual(10); + + const 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 + const originalGetSelection = window.getSelection; + window.getSelection = window.getSelection || makeGetSelection(window); + const input = document.createElement('input'); + input.value = textValue; + document.body.appendChild(input); + input.focus(); + input.selectionStart = 1; + input.selectionEnd = 10; + const selectionInfo = ReactInputSelection.getSelectionInformation(); + expect(selectionInfo.activeElement).toBe(input); + expect(selectionInfo.elementSelections[0].element).toBe(input); + expect(selectionInfo.elementSelections[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', () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const iframeDoc = iframe.contentDocument; + const iframeWin = iframeDoc.defaultView; + // Mock window and iframe getSelection if needed + const originalGetSelection = window.getSelection; + const originalIframeGetSelection = iframeWin.getSelection; + window.getSelection = window.getSelection || makeGetSelection(window); + iframeWin.getSelection = + iframeWin.getSelection || makeGetSelection(iframeWin); + + const input = document.createElement('input'); + input.value = textValue; + iframeDoc.body.appendChild(input); + input.focus(); + input.selectionStart = 1; + input.selectionEnd = 10; + const selectionInfo = ReactInputSelection.getSelectionInformation(); + expect(selectionInfo.activeElement === input).toBe(true); + expect(selectionInfo.elementSelections[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/packages/react-dom/src/client/ReactDOMSelection.js b/packages/react-dom/src/client/ReactDOMSelection.js index 2fe966df8216a..767894a43697e 100644 --- a/packages/react-dom/src/client/ReactDOMSelection.js +++ b/packages/react-dom/src/client/ReactDOMSelection.js @@ -14,7 +14,11 @@ import {TEXT_NODE} from '../shared/HTMLNodeType'; * @return {?object} */ export function getOffsets(outerNode) { - const selection = window.getSelection && window.getSelection(); + let win = window; + if (outerNode.ownerDocument && outerNode.ownerDocument.defaultView) { + win = outerNode.ownerDocument.defaultView; + } + const selection = win.getSelection && win.getSelection(); if (!selection || selection.rangeCount === 0) { return null; @@ -150,11 +154,13 @@ export function getModernOffsetsFromPoints( * @param {object} offsets */ export function setOffsets(node, offsets) { - if (!window.getSelection) { + const doc = node.ownerDocument || document; + + if (!doc.defaultView.getSelection) { return; } - const selection = window.getSelection(); + const selection = doc.defaultView.getSelection(); const length = node[getTextContentAccessor()].length; let start = Math.min(offsets.start, length); let end = offsets.end === undefined ? start : Math.min(offsets.end, length); @@ -180,7 +186,7 @@ export function setOffsets(node, offsets) { ) { return; } - const range = document.createRange(); + const range = doc.createRange(); range.setStart(startMarker.node, startMarker.offset); selection.removeAllRanges(); diff --git a/packages/react-dom/src/client/ReactInputSelection.js b/packages/react-dom/src/client/ReactInputSelection.js index 506153a118053..a991540527bda 100644 --- a/packages/react-dom/src/client/ReactInputSelection.js +++ b/packages/react-dom/src/client/ReactInputSelection.js @@ -12,7 +12,100 @@ import * as ReactDOMSelection from './ReactDOMSelection'; import {ELEMENT_NODE} from '../shared/HTMLNodeType'; function isInDocument(node) { - return containsNode(document.documentElement, node); + return ( + node && + node.ownerDocument && + containsNode(node.ownerDocument.documentElement, node) + ); +} + +function getActiveElementDeep() { + let win = window; + let element = getActiveElement(); + while (element instanceof win.HTMLIFrameElement) { + try { + win = element.contentDocument.defaultView; + } catch (e) { + return element; + } + element = getActiveElement(win.document); + } + return element; +} + +function getElementsWithSelections(acc, win) { + acc = acc || []; + win = win || window; + let doc; + try { + doc = win.document; + if (!doc) { + return acc; + } + } catch (e) { + return acc; + } + let element = getActiveElement(doc); + // Use getSelection if activeElement is the document body + if (element === doc.body) { + if (win.getSelection) { + const selection = win.getSelection(); + if (selection) { + const startNode = selection.anchorNode; + const endNode = selection.focusNode; + const startOffset = selection.anchorOffset; + const 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) { + const range = doc.selection.createRange(); + element = range.parentElement(); + } + } + + if (hasSelectionCapabilities(element)) { + acc = acc.concat({ + element: element, + selectionRange: getSelection(element), + }); + } + + for (let i = 0; i < win.frames.length; i++) { + acc = getElementsWithSelections(acc, win.frames[i]); + } + + return 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, + }); + } + } + + element.focus(); + + for (let i = 0; i < ancestors.length; i++) { + const info = ancestors[i]; + info.element.scrollLeft = info.left; + info.element.scrollTop = info.top; + } } /** @@ -21,24 +114,34 @@ function isInDocument(node) { * 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') ); } export function getSelectionInformation() { - const focusedElem = getActiveElement(); return { - focusedElem: focusedElem, - selectionRange: hasSelectionCapabilities(focusedElem) - ? getSelection(focusedElem) - : null, + activeElement: getActiveElementDeep(), + elementSelections: getElementsWithSelections(), }; } @@ -48,34 +151,35 @@ export function getSelectionInformation() { * nodes and place them back in, resulting in focus being lost. */ export function restoreSelection(priorSelectionInformation) { - const curFocusedElem = getActiveElement(); - const priorFocusedElem = priorSelectionInformation.focusedElem; - const priorSelectionRange = priorSelectionInformation.selectionRange; - if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) { - if (hasSelectionCapabilities(priorFocusedElem)) { - 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, - }); + const priorActiveElement = priorSelectionInformation.activeElement; + const elementSelections = priorSelectionInformation.elementSelections; + let curActiveElement = getActiveElementDeep(); + const isActiveElementOnlySelection = + elementSelections.length === 1 && + elementSelections[0] === priorActiveElement; + if ( + !isInDocument(priorActiveElement) || + priorActiveElement === priorActiveElement.ownerDocument.body || + (isActiveElementOnlySelection && curActiveElement === priorActiveElement) + ) { + return; + } + elementSelections.forEach(function(selection) { + const element = selection.element; + if ( + isInDocument(element) && + getActiveElement(element.ownerDocument) !== element + ) { + setSelection(element, selection.selectionRange); + if (element !== priorActiveElement) { + focusNodePreservingScroll(element); + curActiveElement = element; } } + }); - priorFocusedElem.focus(); - - for (let i = 0; i < ancestors.length; i++) { - const info = ancestors[i]; - info.element.scrollLeft = info.left; - info.element.scrollTop = info.top; - } + if (curActiveElement !== priorActiveElement) { + focusNodePreservingScroll(priorActiveElement); } } diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index e6193c7973a8c..3f24870e471af 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -64,21 +64,42 @@ function getSelection(node) { start: node.selectionStart, end: node.selectionEnd, }; - } else if (window.getSelection) { - const selection = window.getSelection(); - return { - anchorNode: selection.anchorNode, - anchorOffset: selection.anchorOffset, - focusNode: selection.focusNode, - focusOffset: selection.focusOffset, - }; + } else { + let win = window; + if (node.ownerDocument && node.ownerDocument.defaultView) { + win = node.ownerDocument.defaultView; + } + if (win.getSelection) { + const selection = win.getSelection(); + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + }; + } } } +/** + * Get document associated with the event target. + * + * @param {object} nativeEventTarget + * @return {Document} + */ +function getEventTargetDocument(eventTarget) { + return eventTarget.window === eventTarget + ? eventTarget.document + : eventTarget.nodeType === DOCUMENT_NODE + ? eventTarget + : eventTarget.ownerDocument; +} + /** * Poll selection to see whether it's changed. * * @param {object} nativeEvent + * @param {object} nativeEventTarget * @return {?SyntheticEvent} */ function constructSelectEvent(nativeEvent, nativeEventTarget) { @@ -86,10 +107,12 @@ function constructSelectEvent(nativeEvent, nativeEventTarget) { // selection (this matches native `select` event behavior). In HTML5, select // fires only on input and textarea thus if there's no focused element we // won't dispatch. + const doc = getEventTargetDocument(nativeEventTarget); + if ( mouseDown || activeElement == null || - activeElement !== getActiveElement() + activeElement !== getActiveElement(doc) ) { return null; } @@ -140,12 +163,7 @@ const SelectEventPlugin = { nativeEvent, nativeEventTarget, ) { - const doc = - nativeEventTarget.window === nativeEventTarget - ? nativeEventTarget.document - : nativeEventTarget.nodeType === DOCUMENT_NODE - ? nativeEventTarget - : nativeEventTarget.ownerDocument; + const doc = getEventTargetDocument(nativeEventTarget); // Track whether all listeners exists for this plugin. If none exist, we do // not extract events. See #3639. if (!doc || !isListeningToAllDependencies('onSelect', doc)) {