UNPKG

8.2 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 {disableTextSelection, restoreTextSelection} from './textSelection';
14import {DOMAttributes, MoveEvents, PointerType} from '@react-types/shared';
15import React, {useMemo, useRef} from 'react';
16import {useGlobalListeners} from '@react-aria/utils';
17
18export interface MoveResult {
19 /** Props to spread on the target element. */
20 moveProps: DOMAttributes
21}
22
23interface EventBase {
24 shiftKey: boolean,
25 ctrlKey: boolean,
26 metaKey: boolean,
27 altKey: boolean
28}
29
30/**
31 * Handles move interactions across mouse, touch, and keyboard, including dragging with
32 * the mouse or touch, and using the arrow keys. Normalizes behavior across browsers and
33 * platforms, and ignores emulated mouse events on touch devices.
34 */
35export function useMove(props: MoveEvents): MoveResult {
36 let {onMoveStart, onMove, onMoveEnd} = props;
37
38 let state = useRef<{
39 didMove: boolean,
40 lastPosition: {pageX: number, pageY: number} | null,
41 id: number | null
42 }>({didMove: false, lastPosition: null, id: null});
43
44 let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
45
46 let moveProps = useMemo(() => {
47 let moveProps: DOMAttributes = {};
48
49 let start = () => {
50 disableTextSelection();
51 state.current.didMove = false;
52 };
53 let move = (originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => {
54 if (deltaX === 0 && deltaY === 0) {
55 return;
56 }
57
58 if (!state.current.didMove) {
59 state.current.didMove = true;
60 onMoveStart?.({
61 type: 'movestart',
62 pointerType,
63 shiftKey: originalEvent.shiftKey,
64 metaKey: originalEvent.metaKey,
65 ctrlKey: originalEvent.ctrlKey,
66 altKey: originalEvent.altKey
67 });
68 }
69 onMove({
70 type: 'move',
71 pointerType,
72 deltaX: deltaX,
73 deltaY: deltaY,
74 shiftKey: originalEvent.shiftKey,
75 metaKey: originalEvent.metaKey,
76 ctrlKey: originalEvent.ctrlKey,
77 altKey: originalEvent.altKey
78 });
79 };
80 let end = (originalEvent: EventBase, pointerType: PointerType) => {
81 restoreTextSelection();
82 if (state.current.didMove) {
83 onMoveEnd?.({
84 type: 'moveend',
85 pointerType,
86 shiftKey: originalEvent.shiftKey,
87 metaKey: originalEvent.metaKey,
88 ctrlKey: originalEvent.ctrlKey,
89 altKey: originalEvent.altKey
90 });
91 }
92 };
93
94 if (typeof PointerEvent === 'undefined') {
95 let onMouseMove = (e: MouseEvent) => {
96 if (e.button === 0) {
97 move(e, 'mouse', e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY);
98 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
99 }
100 };
101 let onMouseUp = (e: MouseEvent) => {
102 if (e.button === 0) {
103 end(e, 'mouse');
104 removeGlobalListener(window, 'mousemove', onMouseMove, false);
105 removeGlobalListener(window, 'mouseup', onMouseUp, false);
106 }
107 };
108 moveProps.onMouseDown = (e: React.MouseEvent) => {
109 if (e.button === 0) {
110 start();
111 e.stopPropagation();
112 e.preventDefault();
113 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
114 addGlobalListener(window, 'mousemove', onMouseMove, false);
115 addGlobalListener(window, 'mouseup', onMouseUp, false);
116 }
117 };
118
119 let onTouchMove = (e: TouchEvent) => {
120 let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
121 if (touch >= 0) {
122 let {pageX, pageY} = e.changedTouches[touch];
123 move(e, 'touch', pageX - state.current.lastPosition.pageX, pageY - state.current.lastPosition.pageY);
124 state.current.lastPosition = {pageX, pageY};
125 }
126 };
127 let onTouchEnd = (e: TouchEvent) => {
128 let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
129 if (touch >= 0) {
130 end(e, 'touch');
131 state.current.id = null;
132 removeGlobalListener(window, 'touchmove', onTouchMove);
133 removeGlobalListener(window, 'touchend', onTouchEnd);
134 removeGlobalListener(window, 'touchcancel', onTouchEnd);
135 }
136 };
137 moveProps.onTouchStart = (e: React.TouchEvent) => {
138 if (e.changedTouches.length === 0 || state.current.id != null) {
139 return;
140 }
141
142 let {pageX, pageY, identifier} = e.changedTouches[0];
143 start();
144 e.stopPropagation();
145 e.preventDefault();
146 state.current.lastPosition = {pageX, pageY};
147 state.current.id = identifier;
148 addGlobalListener(window, 'touchmove', onTouchMove, false);
149 addGlobalListener(window, 'touchend', onTouchEnd, false);
150 addGlobalListener(window, 'touchcancel', onTouchEnd, false);
151 };
152 } else {
153 let onPointerMove = (e: PointerEvent) => {
154 if (e.pointerId === state.current.id) {
155 let pointerType = (e.pointerType || 'mouse') as PointerType;
156
157 // Problems with PointerEvent#movementX/movementY:
158 // 1. it is always 0 on macOS Safari.
159 // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS
160 move(e, pointerType, e.pageX - state.current.lastPosition.pageX, e.pageY - state.current.lastPosition.pageY);
161 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
162 }
163 };
164
165 let onPointerUp = (e: PointerEvent) => {
166 if (e.pointerId === state.current.id) {
167 let pointerType = (e.pointerType || 'mouse') as PointerType;
168 end(e, pointerType);
169 state.current.id = null;
170 removeGlobalListener(window, 'pointermove', onPointerMove, false);
171 removeGlobalListener(window, 'pointerup', onPointerUp, false);
172 removeGlobalListener(window, 'pointercancel', onPointerUp, false);
173 }
174 };
175
176 moveProps.onPointerDown = (e: React.PointerEvent) => {
177 if (e.button === 0 && state.current.id == null) {
178 start();
179 e.stopPropagation();
180 e.preventDefault();
181 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
182 state.current.id = e.pointerId;
183 addGlobalListener(window, 'pointermove', onPointerMove, false);
184 addGlobalListener(window, 'pointerup', onPointerUp, false);
185 addGlobalListener(window, 'pointercancel', onPointerUp, false);
186 }
187 };
188 }
189
190 let triggerKeyboardMove = (e: EventBase, deltaX: number, deltaY: number) => {
191 start();
192 move(e, 'keyboard', deltaX, deltaY);
193 end(e, 'keyboard');
194 };
195
196 moveProps.onKeyDown = (e) => {
197 switch (e.key) {
198 case 'Left':
199 case 'ArrowLeft':
200 e.preventDefault();
201 e.stopPropagation();
202 triggerKeyboardMove(e, -1, 0);
203 break;
204 case 'Right':
205 case 'ArrowRight':
206 e.preventDefault();
207 e.stopPropagation();
208 triggerKeyboardMove(e, 1, 0);
209 break;
210 case 'Up':
211 case 'ArrowUp':
212 e.preventDefault();
213 e.stopPropagation();
214 triggerKeyboardMove(e, 0, -1);
215 break;
216 case 'Down':
217 case 'ArrowDown':
218 e.preventDefault();
219 e.stopPropagation();
220 triggerKeyboardMove(e, 0, 1);
221 break;
222 }
223 };
224
225 return moveProps;
226 }, [state, onMoveStart, onMove, onMoveEnd, addGlobalListener, removeGlobalListener]);
227
228 return {moveProps};
229}