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