| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import type {AnyNativeEvent} from '../PluginModuleType'; |
| 11 | import type {DOMEventName} from '../DOMEventNames'; |
| 12 | import type {DispatchQueue} from '../DOMPluginEventSystem'; |
| 13 | import type {EventSystemFlags} from '../EventSystemFlags'; |
| 14 | import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; |
| 15 | import type {ReactSyntheticEvent} from '../ReactSyntheticEventType'; |
| 16 | |
| 17 | import {canUseDOM} from 'shared/ExecutionEnvironment'; |
| 18 | import {SyntheticEvent} from '../../events/SyntheticEvent'; |
| 19 | import isTextInputElement from '../isTextInputElement'; |
| 20 | import shallowEqual from 'shared/shallowEqual'; |
| 21 | |
| 22 | import {registerTwoPhaseEvent} from '../EventRegistry'; |
| 23 | import getActiveElement from '../../client/getActiveElement'; |
| 24 | import {getNodeFromInstance} from '../../client/ReactDOMComponentTree'; |
| 25 | import {hasSelectionCapabilities} from '../../client/ReactInputSelection'; |
| 26 | import {DOCUMENT_NODE} from '../../client/HTMLNodeType'; |
| 27 | import {accumulateTwoPhaseListeners} from '../DOMPluginEventSystem'; |
| 28 | |
| 29 | const skipSelectionChangeEvent = |
| 30 | canUseDOM && 'documentMode' in document && document.documentMode <= 11; |
| 31 | |
| 32 | function registerEvents() { |
| 33 | registerTwoPhaseEvent('onSelect', [ |
| 34 | 'focusout', |
| 35 | 'contextmenu', |
| 36 | 'dragend', |
| 37 | 'focusin', |
| 38 | 'keydown', |
| 39 | 'keyup', |
| 40 | 'mousedown', |
| 41 | 'mouseup', |
| 42 | 'selectionchange', |
| 43 | ]); |
| 44 | } |
| 45 | |
| 46 | let activeElement = null; |
| 47 | let activeElementInst = null; |
| 48 | let lastSelection = null; |
| 49 | let mouseDown = false; |
| 50 | |
| 51 | /** |
| 52 | * Get an object which is a unique representation of the current selection. |
| 53 | * |
| 54 | * The return value will not be consistent across nodes or browsers, but |
| 55 | * two identical selections on the same node will return identical objects. |
| 56 | */ |
| 57 | function getSelection(node: any) { |
| 58 | if ('selectionStart' in node && hasSelectionCapabilities(node)) { |
| 59 | return { |
| 60 | start: node.selectionStart, |
| 61 | end: node.selectionEnd, |
| 62 | }; |
| 63 | } else { |
| 64 | const win = |
| 65 | (node.ownerDocument && node.ownerDocument.defaultView) || window; |
| 66 | const selection = win.getSelection(); |
| 67 | return { |
| 68 | anchorNode: selection.anchorNode, |
| 69 | anchorOffset: selection.anchorOffset, |
| 70 | focusNode: selection.focusNode, |
| 71 | focusOffset: selection.focusOffset, |
| 72 | }; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * Get document associated with the event target. |
| 78 | */ |
| 79 | function getEventTargetDocument(eventTarget: any) { |
| 80 | return eventTarget.window === eventTarget |
| 81 | ? eventTarget.document |
| 82 | : eventTarget.nodeType === DOCUMENT_NODE |
| 83 | ? eventTarget |
| 84 | : eventTarget.ownerDocument; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Poll selection to see whether it's changed. |
| 89 | * |
| 90 | * @param {object} nativeEvent |
| 91 | * @param {object} nativeEventTarget |
| 92 | * @return {?SyntheticEvent} |
| 93 | */ |
| 94 | function constructSelectEvent( |
| 95 | dispatchQueue: DispatchQueue, |
| 96 | nativeEvent: AnyNativeEvent, |
| 97 | nativeEventTarget: null | EventTarget, |
| 98 | ) { |
| 99 | // Ensure we have the right element, and that the user is not dragging a |
| 100 | // selection (this matches native `select` event behavior). In HTML5, select |
| 101 | // fires only on input and textarea thus if there's no focused element we |
| 102 | // won't dispatch. |
| 103 | const doc = getEventTargetDocument(nativeEventTarget); |
| 104 | |
| 105 | if ( |
| 106 | mouseDown || |
| 107 | activeElement == null || |
| 108 | activeElement !== getActiveElement(doc) |
| 109 | ) { |
| 110 | return; |
| 111 | } |
| 112 | |
| 113 | // Only fire when selection has actually changed. |
| 114 | const currentSelection = getSelection(activeElement); |
| 115 | if (!lastSelection || !shallowEqual(lastSelection, currentSelection)) { |
| 116 | lastSelection = currentSelection; |
| 117 | |
| 118 | const listeners = accumulateTwoPhaseListeners( |
| 119 | activeElementInst, |
| 120 | 'onSelect', |
| 121 | ); |
| 122 | if (listeners.length > 0) { |
| 123 | const event: ReactSyntheticEvent = new SyntheticEvent( |
| 124 | 'onSelect', |
| 125 | 'select', |
| 126 | null, |
| 127 | nativeEvent, |
| 128 | nativeEventTarget, |
| 129 | ); |
| 130 | dispatchQueue.push({event, listeners}); |
| 131 | event.target = activeElement; |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * This plugin creates an `onSelect` event that normalizes select events |
| 138 | * across form elements. |
| 139 | * |
| 140 | * Supported elements are: |
| 141 | * - input (see `isTextInputElement`) |
| 142 | * - textarea |
| 143 | * - contentEditable |
| 144 | * |
| 145 | * This differs from native browser implementations in the following ways: |
| 146 | * - Fires on contentEditable fields as well as inputs. |
| 147 | * - Fires for collapsed selection. |
| 148 | * - Fires after user input. |
| 149 | */ |
| 150 | function extractEvents( |
| 151 | dispatchQueue: DispatchQueue, |
| 152 | domEventName: DOMEventName, |
| 153 | targetInst: null | Fiber, |
| 154 | nativeEvent: AnyNativeEvent, |
| 155 | nativeEventTarget: null | EventTarget, |
| 156 | eventSystemFlags: EventSystemFlags, |
| 157 | targetContainer: EventTarget, |
| 158 | ) { |
| 159 | const targetNode = targetInst ? getNodeFromInstance(targetInst) : window; |
| 160 | |
| 161 | switch (domEventName) { |
| 162 | // Track the input node that has focus. |
| 163 | case 'focusin': |
| 164 | if ( |
| 165 | isTextInputElement(targetNode as any) || |
| 166 | targetNode.contentEditable === 'true' |
| 167 | ) { |
| 168 | activeElement = targetNode; |
| 169 | activeElementInst = targetInst; |
| 170 | lastSelection = null; |
| 171 | } |
| 172 | break; |
| 173 | case 'focusout': |
| 174 | activeElement = null; |
| 175 | activeElementInst = null; |
| 176 | lastSelection = null; |
| 177 | break; |
| 178 | // Don't fire the event while the user is dragging. This matches the |
| 179 | // semantics of the native select event. |
| 180 | case 'mousedown': |
| 181 | mouseDown = true; |
| 182 | break; |
| 183 | case 'contextmenu': |
| 184 | case 'mouseup': |
| 185 | case 'dragend': |
| 186 | mouseDown = false; |
| 187 | constructSelectEvent(dispatchQueue, nativeEvent, nativeEventTarget); |
| 188 | break; |
| 189 | // Chrome and IE fire non-standard event when selection is changed (and |
| 190 | // sometimes when it hasn't). IE's event fires out of order with respect |
| 191 | // to key and input events on deletion, so we discard it. |
| 192 | // |
| 193 | // Firefox doesn't support selectionchange, so check selection status |
| 194 | // after each key entry. The selection changes after keydown and before |
| 195 | // keyup, but we check on keydown as well in the case of holding down a |
| 196 | // key, when multiple keydown events are fired but only one keyup is. |
| 197 | // This is also our approach for IE handling, for the reason above. |
| 198 | case 'selectionchange': |
| 199 | if (skipSelectionChangeEvent) { |
| 200 | break; |
| 201 | } |
| 202 | // falls through |
| 203 | case 'keydown': |
| 204 | case 'keyup': |
| 205 | constructSelectEvent(dispatchQueue, nativeEvent, nativeEventTarget); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | export {registerEvents, extractEvents}; |