UNPKG

4.54 kBPlain TextView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import {getOwnerDocument, isIOS, runAfterTransition} from '@react-aria/utils';
14
15// Safari on iOS starts selecting text on long press. The only way to avoid this, it seems,
16// is to add user-select: none to the entire page. Adding it to the pressable element prevents
17// that element from being selected, but nearby elements may still receive selection. We add
18// user-select: none on touch start, and remove it again on touch end to prevent this.
19// This must be implemented using global state to avoid race conditions between multiple elements.
20
21// There are three possible states due to the delay before removing user-select: none after
22// pointer up. The 'default' state always transitions to the 'disabled' state, which transitions
23// to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'.
24
25// For non-iOS devices, we apply user-select: none to the pressed element instead to avoid possible
26// performance issues that arise from applying and removing user-select: none to the entire page
27// (see https://github.com/adobe/react-spectrum/issues/1609).
28type State = 'default' | 'disabled' | 'restoring';
29
30// Note that state only matters here for iOS. Non-iOS gets user-select: none applied to the target element
31// rather than at the document level so we just need to apply/remove user-select: none for each pressed element individually
32let state: State = 'default';
33let savedUserSelect = '';
34let modifiedElementMap = new WeakMap<Element, string>();
35
36export function disableTextSelection(target?: Element) {
37 if (isIOS()) {
38 if (state === 'default') {
39 // eslint-disable-next-line no-restricted-globals
40 const documentObject = getOwnerDocument(target);
41 savedUserSelect = documentObject.documentElement.style.webkitUserSelect;
42 documentObject.documentElement.style.webkitUserSelect = 'none';
43 }
44
45 state = 'disabled';
46 } else if (target instanceof HTMLElement || target instanceof SVGElement) {
47 // If not iOS, store the target's original user-select and change to user-select: none
48 // Ignore state since it doesn't apply for non iOS
49 modifiedElementMap.set(target, target.style.userSelect);
50 target.style.userSelect = 'none';
51 }
52}
53
54export function restoreTextSelection(target?: Element) {
55 if (isIOS()) {
56 // If the state is already default, there's nothing to do.
57 // If it is restoring, then there's no need to queue a second restore.
58 if (state !== 'disabled') {
59 return;
60 }
61
62 state = 'restoring';
63
64 // There appears to be a delay on iOS where selection still might occur
65 // after pointer up, so wait a bit before removing user-select.
66 setTimeout(() => {
67 // Wait for any CSS transitions to complete so we don't recompute style
68 // for the whole page in the middle of the animation and cause jank.
69 runAfterTransition(() => {
70 // Avoid race conditions
71 if (state === 'restoring') {
72 // eslint-disable-next-line no-restricted-globals
73 const documentObject = getOwnerDocument(target);
74 if (documentObject.documentElement.style.webkitUserSelect === 'none') {
75 documentObject.documentElement.style.webkitUserSelect = savedUserSelect || '';
76 }
77
78 savedUserSelect = '';
79 state = 'default';
80 }
81 });
82 }, 300);
83 } else if (target instanceof HTMLElement || target instanceof SVGElement) {
84 // If not iOS, restore the target's original user-select if any
85 // Ignore state since it doesn't apply for non iOS
86 if (target && modifiedElementMap.has(target)) {
87 let targetOldUserSelect = modifiedElementMap.get(target) as string;
88
89 if (target.style.userSelect === 'none') {
90 target.style.userSelect = targetOldUserSelect;
91 }
92
93 if (target.getAttribute('style') === '') {
94 target.removeAttribute('style');
95 }
96 modifiedElementMap.delete(target);
97 }
98 }
99}