| 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 | |
| 8 | import getActiveElement from './getActiveElement'; |
| 9 | |
| 10 | import {getOffsets, setOffsets} from './ReactDOMSelection'; |
| 11 | import {ELEMENT_NODE, TEXT_NODE} from './HTMLNodeType'; |
| 12 | |
| 13 | function isTextNode(node) { |
| 14 | return node && node.nodeType === TEXT_NODE; |
| 15 | } |
| 16 | |
| 17 | function containsNode(outerNode, innerNode) { |
| 18 | if (!outerNode || !innerNode) { |
| 19 | return false; |
| 20 | } else if (outerNode === innerNode) { |
| 21 | return true; |
| 22 | } else if (isTextNode(outerNode)) { |
| 23 | return false; |
| 24 | } else if (isTextNode(innerNode)) { |
| 25 | return containsNode(outerNode, innerNode.parentNode); |
| 26 | } else if ('contains' in outerNode) { |
| 27 | return outerNode.contains(innerNode); |
| 28 | } else if (outerNode.compareDocumentPosition) { |
| 29 | return !!(outerNode.compareDocumentPosition(innerNode) & 16); |
| 30 | } else { |
| 31 | return false; |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | function isInDocument(node) { |
| 36 | return ( |
| 37 | node && |
| 38 | node.ownerDocument && |
| 39 | containsNode(node.ownerDocument.documentElement, node) |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | function isSameOriginFrame(iframe) { |
| 44 | try { |
| 45 | // Accessing the contentDocument of a HTMLIframeElement can cause the browser |
| 46 | // to throw, e.g. if it has a cross-origin src attribute. |
| 47 | // Safari will show an error in the console when the access results in "Blocked a frame with origin". e.g: |
| 48 | // iframe.contentDocument.defaultView; |
| 49 | // A safety way is to access one of the cross origin properties: Window or Location |
| 50 | // Which might result in "SecurityError" DOM Exception and it is compatible to Safari. |
| 51 | // https://html.spec.whatwg.org/multipage/browsers.html#integration-with-idl |
| 52 | |
| 53 | return typeof iframe.contentWindow.location.href === 'string'; |
| 54 | } catch (err) { |
| 55 | return false; |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | function getActiveElementDeep(containerInfo) { |
| 60 | let win = |
| 61 | containerInfo != null && |
| 62 | containerInfo.ownerDocument != null && |
| 63 | containerInfo.ownerDocument.defaultView != null |
| 64 | ? containerInfo.ownerDocument.defaultView |
| 65 | : window; |
| 66 | let element = getActiveElement(win.document); |
| 67 | while (element instanceof win.HTMLIFrameElement) { |
| 68 | if (isSameOriginFrame(element)) { |
| 69 | win = element.contentWindow; |
| 70 | } else { |
| 71 | return element; |
| 72 | } |
| 73 | element = getActiveElement(win.document); |
| 74 | } |
| 75 | return element; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * @ReactInputSelection: React input selection module. Based on Selection.js, |
| 80 | * but modified to be suitable for react and has a couple of bug fixes (doesn't |
| 81 | * assume buttons have range selections allowed). |
| 82 | * Input selection module for React. |
| 83 | */ |
| 84 | |
| 85 | /** |
| 86 | * @hasSelectionCapabilities: we get the element types that support selection |
| 87 | * from https://html.spec.whatwg.org/#do-not-apply, looking at `selectionStart` |
| 88 | * and `selectionEnd` rows. |
| 89 | */ |
| 90 | export function hasSelectionCapabilities(elem) { |
| 91 | const nodeName = elem && elem.nodeName && elem.nodeName.toLowerCase(); |
| 92 | return ( |
| 93 | nodeName && |
| 94 | ((nodeName === 'input' && |
| 95 | (elem.type === 'text' || |
| 96 | elem.type === 'search' || |
| 97 | elem.type === 'tel' || |
| 98 | elem.type === 'url' || |
| 99 | elem.type === 'password')) || |
| 100 | nodeName === 'textarea' || |
| 101 | elem.contentEditable === 'true') |
| 102 | ); |
| 103 | } |
| 104 | |
| 105 | export function getSelectionInformation(containerInfo) { |
| 106 | const focusedElem = getActiveElementDeep(containerInfo); |
| 107 | return { |
| 108 | focusedElem: focusedElem, |
| 109 | selectionRange: hasSelectionCapabilities(focusedElem) |
| 110 | ? getSelection(focusedElem) |
| 111 | : null, |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | /** |
| 116 | * @restoreSelection: If any selection information was potentially lost, |
| 117 | * restore it. This is useful when performing operations that could remove dom |
| 118 | * nodes and place them back in, resulting in focus being lost. |
| 119 | */ |
| 120 | export function restoreSelection(priorSelectionInformation, containerInfo) { |
| 121 | const curFocusedElem = getActiveElementDeep(containerInfo); |
| 122 | const priorFocusedElem = priorSelectionInformation.focusedElem; |
| 123 | const priorSelectionRange = priorSelectionInformation.selectionRange; |
| 124 | if (curFocusedElem !== priorFocusedElem && isInDocument(priorFocusedElem)) { |
| 125 | if ( |
| 126 | priorSelectionRange !== null && |
| 127 | hasSelectionCapabilities(priorFocusedElem) |
| 128 | ) { |
| 129 | setSelection(priorFocusedElem, priorSelectionRange); |
| 130 | } |
| 131 | |
| 132 | // Focusing a node can change the scroll position, which is undesirable |
| 133 | const ancestors = []; |
| 134 | let ancestor = priorFocusedElem; |
| 135 | while ((ancestor = ancestor.parentNode)) { |
| 136 | if (ancestor.nodeType === ELEMENT_NODE) { |
| 137 | ancestors.push({ |
| 138 | element: ancestor, |
| 139 | left: ancestor.scrollLeft, |
| 140 | top: ancestor.scrollTop, |
| 141 | }); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | if (typeof priorFocusedElem.focus === 'function') { |
| 146 | priorFocusedElem.focus(); |
| 147 | } |
| 148 | |
| 149 | for (let i = 0; i < ancestors.length; i++) { |
| 150 | const info = ancestors[i]; |
| 151 | info.element.scrollLeft = info.left; |
| 152 | info.element.scrollTop = info.top; |
| 153 | } |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * @getSelection: Gets the selection bounds of a focused textarea, input or |
| 159 | * contentEditable node. |
| 160 | * -@input: Look up selection bounds of this input |
| 161 | * -@return {start: selectionStart, end: selectionEnd} |
| 162 | */ |
| 163 | export function getSelection(input) { |
| 164 | let selection; |
| 165 | |
| 166 | if ('selectionStart' in input) { |
| 167 | // Modern browser with input or textarea. |
| 168 | selection = { |
| 169 | start: input.selectionStart, |
| 170 | end: input.selectionEnd, |
| 171 | }; |
| 172 | } else { |
| 173 | // Content editable or old IE textarea. |
| 174 | selection = getOffsets(input); |
| 175 | } |
| 176 | |
| 177 | return selection || {start: 0, end: 0}; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * @setSelection: Sets the selection bounds of a textarea or input and focuses |
| 182 | * the input. |
| 183 | * -@input Set selection bounds of this input or textarea |
| 184 | * -@offsets Object of same form that is returned from get* |
| 185 | */ |
| 186 | export function setSelection(input, offsets) { |
| 187 | const start = offsets.start; |
| 188 | let end = offsets.end; |
| 189 | if (end === undefined) { |
| 190 | end = start; |
| 191 | } |
| 192 | |
| 193 | if ('selectionStart' in input) { |
| 194 | input.selectionStart = start; |
| 195 | input.selectionEnd = Math.min(end, input.value.length); |
| 196 | } else { |
| 197 | setOffsets(input, offsets); |
| 198 | } |
| 199 | } |