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 {useEffectEvent, 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 move = useEffectEvent((originalEvent: EventBase, pointerType: PointerType, deltaX: number, deltaY: number) => {
47 if (deltaX === 0 && deltaY === 0) {
48 return;
49 }
50
51 if (!state.current.didMove) {
52 state.current.didMove = true;
53 onMoveStart?.({
54 type: 'movestart',
55 pointerType,
56 shiftKey: originalEvent.shiftKey,
57 metaKey: originalEvent.metaKey,
58 ctrlKey: originalEvent.ctrlKey,
59 altKey: originalEvent.altKey
60 });
61 }
62 onMove?.({
63 type: 'move',
64 pointerType,
65 deltaX: deltaX,
66 deltaY: deltaY,
67 shiftKey: originalEvent.shiftKey,
68 metaKey: originalEvent.metaKey,
69 ctrlKey: originalEvent.ctrlKey,
70 altKey: originalEvent.altKey
71 });
72 });
73
74 let end = useEffectEvent((originalEvent: EventBase, pointerType: PointerType) => {
75 restoreTextSelection();
76 if (state.current.didMove) {
77 onMoveEnd?.({
78 type: 'moveend',
79 pointerType,
80 shiftKey: originalEvent.shiftKey,
81 metaKey: originalEvent.metaKey,
82 ctrlKey: originalEvent.ctrlKey,
83 altKey: originalEvent.altKey
84 });
85 }
86 });
87
88 let moveProps = useMemo(() => {
89 let moveProps: DOMAttributes = {};
90
91 let start = () => {
92 disableTextSelection();
93 state.current.didMove = false;
94 };
95
96 if (typeof PointerEvent === 'undefined') {
97 let onMouseMove = (e: MouseEvent) => {
98 if (e.button === 0) {
99 move(e, 'mouse', e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0));
100 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
101 }
102 };
103 let onMouseUp = (e: MouseEvent) => {
104 if (e.button === 0) {
105 end(e, 'mouse');
106 removeGlobalListener(window, 'mousemove', onMouseMove, false);
107 removeGlobalListener(window, 'mouseup', onMouseUp, false);
108 }
109 };
110 moveProps.onMouseDown = (e: React.MouseEvent) => {
111 if (e.button === 0) {
112 start();
113 e.stopPropagation();
114 e.preventDefault();
115 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
116 addGlobalListener(window, 'mousemove', onMouseMove, false);
117 addGlobalListener(window, 'mouseup', onMouseUp, false);
118 }
119 };
120
121 let onTouchMove = (e: TouchEvent) => {
122 let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
123 if (touch >= 0) {
124 let {pageX, pageY} = e.changedTouches[touch];
125 move(e, 'touch', pageX - (state.current.lastPosition?.pageX ?? 0), pageY - (state.current.lastPosition?.pageY ?? 0));
126 state.current.lastPosition = {pageX, pageY};
127 }
128 };
129 let onTouchEnd = (e: TouchEvent) => {
130 let touch = [...e.changedTouches].findIndex(({identifier}) => identifier === state.current.id);
131 if (touch >= 0) {
132 end(e, 'touch');
133 state.current.id = null;
134 removeGlobalListener(window, 'touchmove', onTouchMove);
135 removeGlobalListener(window, 'touchend', onTouchEnd);
136 removeGlobalListener(window, 'touchcancel', onTouchEnd);
137 }
138 };
139 moveProps.onTouchStart = (e: React.TouchEvent) => {
140 if (e.changedTouches.length === 0 || state.current.id != null) {
141 return;
142 }
143
144 let {pageX, pageY, identifier} = e.changedTouches[0];
145 start();
146 e.stopPropagation();
147 e.preventDefault();
148 state.current.lastPosition = {pageX, pageY};
149 state.current.id = identifier;
150 addGlobalListener(window, 'touchmove', onTouchMove, false);
151 addGlobalListener(window, 'touchend', onTouchEnd, false);
152 addGlobalListener(window, 'touchcancel', onTouchEnd, false);
153 };
154 } else {
155 let onPointerMove = (e: PointerEvent) => {
156 if (e.pointerId === state.current.id) {
157 let pointerType = (e.pointerType || 'mouse') as PointerType;
158
159 // Problems with PointerEvent#movementX/movementY:
160 // 1. it is always 0 on macOS Safari.
161 // 2. On Chrome Android, it's scaled by devicePixelRatio, but not on Chrome macOS
162 move(e, pointerType, e.pageX - (state.current.lastPosition?.pageX ?? 0), e.pageY - (state.current.lastPosition?.pageY ?? 0));
163 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
164 }
165 };
166
167 let onPointerUp = (e: PointerEvent) => {
168 if (e.pointerId === state.current.id) {
169 let pointerType = (e.pointerType || 'mouse') as PointerType;
170 end(e, pointerType);
171 state.current.id = null;
172 removeGlobalListener(window, 'pointermove', onPointerMove, false);
173 removeGlobalListener(window, 'pointerup', onPointerUp, false);
174 removeGlobalListener(window, 'pointercancel', onPointerUp, false);
175 }
176 };
177
178 moveProps.onPointerDown = (e: React.PointerEvent) => {
179 if (e.button === 0 && state.current.id == null) {
180 start();
181 e.stopPropagation();
182 e.preventDefault();
183 state.current.lastPosition = {pageX: e.pageX, pageY: e.pageY};
184 state.current.id = e.pointerId;
185 addGlobalListener(window, 'pointermove', onPointerMove, false);
186 addGlobalListener(window, 'pointerup', onPointerUp, false);
187 addGlobalListener(window, 'pointercancel', onPointerUp, false);
188 }
189 };
190 }
191
192 let triggerKeyboardMove = (e: EventBase, deltaX: number, deltaY: number) => {
193 start();
194 move(e, 'keyboard', deltaX, deltaY);
195 end(e, 'keyboard');
196 };
197
198 moveProps.onKeyDown = (e) => {
199 switch (e.key) {
200 case 'Left':
201 case 'ArrowLeft':
202 e.preventDefault();
203 e.stopPropagation();
204 triggerKeyboardMove(e, -1, 0);
205 break;
206 case 'Right':
207 case 'ArrowRight':
208 e.preventDefault();
209 e.stopPropagation();
210 triggerKeyboardMove(e, 1, 0);
211 break;
212 case 'Up':
213 case 'ArrowUp':
214 e.preventDefault();
215 e.stopPropagation();
216 triggerKeyboardMove(e, 0, -1);
217 break;
218 case 'Down':
219 case 'ArrowDown':
220 e.preventDefault();
221 e.stopPropagation();
222 triggerKeyboardMove(e, 0, 1);
223 break;
224 }
225 };
226
227 return moveProps;
228 }, [state, addGlobalListener, removeGlobalListener, move, end]);
229
230 return {moveProps};
231}