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 |
|
13 | import {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).
|
28 | type 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
|
32 | let state: State = 'default';
|
33 | let savedUserSelect = '';
|
34 | let modifiedElementMap = new WeakMap<Element, string>();
|
35 |
|
36 | export 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 |
|
54 | export 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 | }
|