1 |
|
2 | import * as React from "react";
|
3 | import {
|
4 | AttachTouch,
|
5 | SwipeDirections,
|
6 | DOWN,
|
7 | SwipeEventData,
|
8 | HandledEvents,
|
9 | LEFT,
|
10 | RIGHT,
|
11 | Setter,
|
12 | SwipeableCallbacks,
|
13 | SwipeableHandlers,
|
14 | SwipeableProps,
|
15 | SwipeablePropsWithDefaultOptions,
|
16 | SwipeableState,
|
17 | SwipeCallback,
|
18 | TapCallback,
|
19 | UP,
|
20 | Vector2,
|
21 | } from "./types";
|
22 |
|
23 | export {
|
24 | LEFT,
|
25 | RIGHT,
|
26 | UP,
|
27 | DOWN,
|
28 | SwipeDirections,
|
29 | SwipeEventData,
|
30 | SwipeCallback,
|
31 | TapCallback,
|
32 | SwipeableHandlers,
|
33 | SwipeableProps,
|
34 | Vector2,
|
35 | };
|
36 |
|
37 | const defaultProps = {
|
38 | delta: 10,
|
39 | preventDefaultTouchmoveEvent: false,
|
40 | rotationAngle: 0,
|
41 | trackMouse: false,
|
42 | trackTouch: true,
|
43 | };
|
44 | const initialState: SwipeableState = {
|
45 | first: true,
|
46 | initial: [0, 0],
|
47 | start: 0,
|
48 | swiping: false,
|
49 | xy: [0, 0],
|
50 | };
|
51 | const mouseMove = "mousemove";
|
52 | const mouseUp = "mouseup";
|
53 | const touchEnd = "touchend";
|
54 | const touchMove = "touchmove";
|
55 | const touchStart = "touchstart";
|
56 |
|
57 | function getDirection(
|
58 | absX: number,
|
59 | absY: number,
|
60 | deltaX: number,
|
61 | deltaY: number
|
62 | ): SwipeDirections {
|
63 | if (absX > absY) {
|
64 | if (deltaX > 0) {
|
65 | return RIGHT;
|
66 | }
|
67 | return LEFT;
|
68 | } else if (deltaY > 0) {
|
69 | return DOWN;
|
70 | }
|
71 | return UP;
|
72 | }
|
73 |
|
74 | function rotateXYByAngle(pos: Vector2, angle: number): Vector2 {
|
75 | if (angle === 0) return pos;
|
76 | const angleInRadians = (Math.PI / 180) * angle;
|
77 | const x =
|
78 | pos[0] * Math.cos(angleInRadians) + pos[1] * Math.sin(angleInRadians);
|
79 | const y =
|
80 | pos[1] * Math.cos(angleInRadians) - pos[0] * Math.sin(angleInRadians);
|
81 | return [x, y];
|
82 | }
|
83 |
|
84 | function getHandlers(
|
85 | set: Setter,
|
86 | handlerProps: { trackMouse: boolean | undefined }
|
87 | ): [
|
88 | {
|
89 | ref: (element: HTMLElement | null) => void;
|
90 | onMouseDown?: (event: React.MouseEvent) => void;
|
91 | },
|
92 | AttachTouch
|
93 | ] {
|
94 | const onStart = (event: HandledEvents) => {
|
95 |
|
96 | if (event && "touches" in event && event.touches.length > 1) return;
|
97 |
|
98 | set((state, props) => {
|
99 |
|
100 | if (props.trackMouse) {
|
101 | document.addEventListener(mouseMove, onMove);
|
102 | document.addEventListener(mouseUp, onUp);
|
103 | }
|
104 | const { clientX, clientY } =
|
105 | "touches" in event ? event.touches[0] : event;
|
106 | const xy = rotateXYByAngle([clientX, clientY], props.rotationAngle);
|
107 | return {
|
108 | ...state,
|
109 | ...initialState,
|
110 | initial: [...xy],
|
111 | xy,
|
112 | start: event.timeStamp || 0,
|
113 | };
|
114 | });
|
115 | };
|
116 |
|
117 | const onMove = (event: HandledEvents) => {
|
118 | set((state, props) => {
|
119 |
|
120 |
|
121 | if ("touches" in event && event.touches.length > 1) {
|
122 | return state;
|
123 | }
|
124 | const { clientX, clientY } =
|
125 | "touches" in event ? event.touches[0] : event;
|
126 | const [x, y] = rotateXYByAngle([clientX, clientY], props.rotationAngle);
|
127 | const deltaX = x - state.xy[0];
|
128 | const deltaY = y - state.xy[1];
|
129 | const absX = Math.abs(deltaX);
|
130 | const absY = Math.abs(deltaY);
|
131 | const time = (event.timeStamp || 0) - state.start;
|
132 | const velocity = Math.sqrt(absX * absX + absY * absY) / (time || 1);
|
133 | const vxvy: Vector2 = [deltaX / (time || 1), deltaY / (time || 1)];
|
134 |
|
135 | const dir = getDirection(absX, absY, deltaX, deltaY);
|
136 |
|
137 |
|
138 | const delta =
|
139 | typeof props.delta === "number"
|
140 | ? props.delta
|
141 | : props.delta[dir.toLowerCase() as Lowercase<SwipeDirections>] ||
|
142 | defaultProps.delta;
|
143 | if (absX < delta && absY < delta && !state.swiping) return state;
|
144 |
|
145 | const eventData = {
|
146 | absX,
|
147 | absY,
|
148 | deltaX,
|
149 | deltaY,
|
150 | dir,
|
151 | event,
|
152 | first: state.first,
|
153 | initial: state.initial,
|
154 | velocity,
|
155 | vxvy,
|
156 | };
|
157 |
|
158 |
|
159 | eventData.first && props.onSwipeStart && props.onSwipeStart(eventData);
|
160 |
|
161 |
|
162 | props.onSwiping && props.onSwiping(eventData);
|
163 |
|
164 |
|
165 |
|
166 | let cancelablePageSwipe = false;
|
167 | if (props.onSwiping || props.onSwiped || `onSwiped${dir}` in props) {
|
168 | cancelablePageSwipe = true;
|
169 | }
|
170 |
|
171 | if (
|
172 | cancelablePageSwipe &&
|
173 | props.preventDefaultTouchmoveEvent &&
|
174 | props.trackTouch &&
|
175 | event.cancelable
|
176 | )
|
177 | event.preventDefault();
|
178 |
|
179 | return {
|
180 | ...state,
|
181 |
|
182 | first: false,
|
183 | eventData,
|
184 | swiping: true,
|
185 | };
|
186 | });
|
187 | };
|
188 |
|
189 | const onEnd = (event: HandledEvents) => {
|
190 | set((state, props) => {
|
191 | let eventData: SwipeEventData | undefined;
|
192 | if (state.swiping && state.eventData) {
|
193 | eventData = { ...state.eventData, event };
|
194 | props.onSwiped && props.onSwiped(eventData);
|
195 |
|
196 | const onSwipedDir =
|
197 | props[`onSwiped${eventData.dir}` as keyof SwipeableCallbacks];
|
198 | onSwipedDir && onSwipedDir(eventData);
|
199 | } else {
|
200 | props.onTap && props.onTap({ event });
|
201 | }
|
202 | return { ...state, ...initialState, eventData };
|
203 | });
|
204 | };
|
205 |
|
206 | const cleanUpMouse = () => {
|
207 |
|
208 | document.removeEventListener(mouseMove, onMove);
|
209 | document.removeEventListener(mouseUp, onUp);
|
210 | };
|
211 |
|
212 | const onUp = (e: HandledEvents) => {
|
213 | cleanUpMouse();
|
214 | onEnd(e);
|
215 | };
|
216 |
|
217 | |
218 |
|
219 |
|
220 |
|
221 |
|
222 |
|
223 |
|
224 |
|
225 | const attachTouch: AttachTouch = (el, passive) => {
|
226 | let cleanup = () => {};
|
227 | if (el && el.addEventListener) {
|
228 |
|
229 | const tls: [
|
230 | typeof touchStart | typeof touchMove | typeof touchEnd,
|
231 | (e: HandledEvents) => void
|
232 | ][] = [
|
233 | [touchStart, onStart],
|
234 | [touchMove, onMove],
|
235 | [touchEnd, onEnd],
|
236 | ];
|
237 | tls.forEach(([e, h]) => el.addEventListener(e, h, { passive }));
|
238 |
|
239 | cleanup = () => tls.forEach(([e, h]) => el.removeEventListener(e, h));
|
240 | }
|
241 | return cleanup;
|
242 | };
|
243 |
|
244 | const onRef = (el: HTMLElement | null) => {
|
245 |
|
246 |
|
247 | if (el === null) return;
|
248 | set((state, props) => {
|
249 |
|
250 | if (state.el === el) return state;
|
251 |
|
252 | const addState: { cleanUpTouch?: () => void } = {};
|
253 |
|
254 | if (state.el && state.el !== el && state.cleanUpTouch) {
|
255 | state.cleanUpTouch();
|
256 | addState.cleanUpTouch = undefined;
|
257 | }
|
258 |
|
259 | if (props.trackTouch && el) {
|
260 | addState.cleanUpTouch = attachTouch(
|
261 | el,
|
262 | !props.preventDefaultTouchmoveEvent
|
263 | );
|
264 | }
|
265 |
|
266 |
|
267 | return { ...state, el, ...addState };
|
268 | });
|
269 | };
|
270 |
|
271 |
|
272 | const output: { ref: typeof onRef; onMouseDown?: typeof onStart } = {
|
273 | ref: onRef,
|
274 | };
|
275 |
|
276 |
|
277 | if (handlerProps.trackMouse) {
|
278 | output.onMouseDown = onStart;
|
279 | }
|
280 |
|
281 | return [output, attachTouch];
|
282 | }
|
283 |
|
284 | function updateTransientState(
|
285 | state: SwipeableState,
|
286 | props: SwipeableProps,
|
287 | attachTouch: AttachTouch
|
288 | ) {
|
289 | const addState: { cleanUpTouch?(): void } = {};
|
290 |
|
291 | if (!props.trackTouch && state.cleanUpTouch) {
|
292 | state.cleanUpTouch();
|
293 | addState.cleanUpTouch = undefined;
|
294 | } else if (props.trackTouch && !state.cleanUpTouch) {
|
295 |
|
296 | if (state.el) {
|
297 | addState.cleanUpTouch = attachTouch(
|
298 | state.el,
|
299 | !props.preventDefaultTouchmoveEvent
|
300 | );
|
301 | }
|
302 | }
|
303 | return { ...state, ...addState };
|
304 | }
|
305 |
|
306 | export function useSwipeable(options: SwipeableProps): SwipeableHandlers {
|
307 | const { trackMouse } = options;
|
308 | const transientState = React.useRef({ ...initialState });
|
309 | const transientProps = React.useRef<SwipeablePropsWithDefaultOptions>({
|
310 | ...defaultProps,
|
311 | });
|
312 | transientProps.current = { ...defaultProps, ...options };
|
313 |
|
314 | const [handlers, attachTouch] = React.useMemo(
|
315 | () =>
|
316 | getHandlers(
|
317 | (stateSetter) =>
|
318 | (transientState.current = stateSetter(
|
319 | transientState.current,
|
320 | transientProps.current
|
321 | )),
|
322 | { trackMouse }
|
323 | ),
|
324 | [trackMouse]
|
325 | );
|
326 |
|
327 | transientState.current = updateTransientState(
|
328 | transientState.current,
|
329 | transientProps.current,
|
330 | attachTouch
|
331 | );
|
332 |
|
333 | return handlers;
|
334 | }
|