gesturx 0.0.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ import { Observable, map, scan } from "rxjs"
2
+ import { RecognizerEvent } from "./RecognizerEvent"
3
+ import { calculateVelocity } from "../utils/utils"
4
+ import {
5
+ calculateAngleDelta,
6
+ calculateAverageDistance,
7
+ calculateCentroid,
8
+ } from "../utils/geometry"
9
+
10
+ export interface RecognizerEventInput {
11
+ event: PointerEvent
12
+ latestActivePointers: PointerEvent[]
13
+ }
14
+
15
+ interface RecognizerEventState extends RecognizerEventInput {
16
+ startTime: number
17
+ center: { x: number; y: number }
18
+ deltaX: number
19
+ deltaY: number
20
+ deltaPointersAngle: number
21
+ }
22
+
23
+ const calculateNewDelta = (
24
+ newCenter: { x: number; y: number },
25
+ prevCenter: { x: number; y: number },
26
+ prevDeltaX: number,
27
+ prevDeltaY: number,
28
+ ) => {
29
+ const deltaX = newCenter.x - prevCenter.x + prevDeltaX
30
+ const deltaY = newCenter.y - prevCenter.y + prevDeltaY
31
+
32
+ return {
33
+ deltaX,
34
+ deltaY,
35
+ }
36
+ }
37
+
38
+ export const mapToRecognizerEvent = <T extends RecognizerEventInput>(
39
+ stream: Observable<T>,
40
+ ): Observable<RecognizerEvent> => {
41
+ const getCenterFromData = (data: T) =>
42
+ calculateCentroid(
43
+ !data.latestActivePointers.length
44
+ ? [data.event]
45
+ : data.latestActivePointers,
46
+ )
47
+
48
+ return stream.pipe(
49
+ scan<
50
+ T,
51
+ RecognizerEventState,
52
+ Pick<
53
+ RecognizerEventState,
54
+ "deltaX" | "center" | "deltaY" | "latestActivePointers"
55
+ >
56
+ >(
57
+ (acc, curr) => {
58
+ const newCenter = getCenterFromData(curr)
59
+ const prevCenter = acc.center
60
+ const prevDeltaX = acc.deltaX
61
+ const prevDeltaY = acc.deltaY
62
+ const prevActivePointersNumber = acc.latestActivePointers?.length ?? 0
63
+ const currActivePointersNumber = curr.latestActivePointers?.length ?? 0
64
+
65
+ const hasAddedOrRemovedAFinger =
66
+ [
67
+ "pointerdown",
68
+ "pointercancel",
69
+ "pointerleave",
70
+ "pointerup",
71
+ ].includes(curr.event.type) &&
72
+ prevActivePointersNumber !== currActivePointersNumber
73
+ const hasAtLeastOneFinger = currActivePointersNumber > 0
74
+
75
+ const { deltaX, deltaY } =
76
+ hasAddedOrRemovedAFinger && hasAtLeastOneFinger
77
+ ? calculateNewDelta(newCenter, newCenter, prevDeltaX, prevDeltaY)
78
+ : calculateNewDelta(newCenter, prevCenter, prevDeltaX, prevDeltaY)
79
+
80
+ const { degreesDelta: deltaPointersAngle } =
81
+ prevActivePointersNumber === currActivePointersNumber &&
82
+ prevActivePointersNumber >= 2 &&
83
+ currActivePointersNumber >= 2
84
+ ? calculateAngleDelta(
85
+ acc.latestActivePointers ?? [],
86
+ curr.latestActivePointers ?? [],
87
+ )
88
+ : { degreesDelta: 0 }
89
+
90
+ return {
91
+ startTime: new Date().getTime(),
92
+ ...acc,
93
+ ...curr,
94
+ center: newCenter,
95
+ deltaX,
96
+ deltaY,
97
+ deltaPointersAngle,
98
+ }
99
+ },
100
+ {
101
+ deltaX: 0,
102
+ deltaY: 0,
103
+ center: { x: 0, y: 0 },
104
+ latestActivePointers: [],
105
+ },
106
+ ),
107
+ map((data) => {
108
+ const {
109
+ deltaX,
110
+ deltaY,
111
+ latestActivePointers,
112
+ startTime,
113
+ center,
114
+ event,
115
+ deltaPointersAngle,
116
+ } = data
117
+ const events = latestActivePointers ?? []
118
+
119
+ // Calculate the change in time
120
+ const now = Date.now()
121
+ const delay = now - startTime
122
+
123
+ const { velocityX, velocityY } = calculateVelocity(delay, deltaX, deltaY)
124
+ const pointersAverageDistance =
125
+ calculateAverageDistance(latestActivePointers)
126
+
127
+ return {
128
+ deltaX,
129
+ deltaY,
130
+ velocityX,
131
+ velocityY,
132
+ delay,
133
+ center,
134
+ pointers: events,
135
+ startTime,
136
+ event,
137
+ deltaPointersAngle,
138
+ pointersAverageDistance,
139
+ } satisfies RecognizerEvent
140
+ }),
141
+ )
142
+ }
@@ -0,0 +1,115 @@
1
+ import {
2
+ Observable,
3
+ combineLatest,
4
+ distinctUntilChanged,
5
+ filter,
6
+ first,
7
+ map,
8
+ merge,
9
+ mergeMap,
10
+ scan,
11
+ switchMap,
12
+ takeUntil,
13
+ } from "rxjs"
14
+ import { Recognizer } from "../recognizer/Recognizer"
15
+ import { PanEvent, PanRecognizer } from "../pan/PanRecognizer"
16
+ import { RecognizerEvent } from "../recognizer/RecognizerEvent"
17
+
18
+ export interface RotateEvent extends RecognizerEvent {
19
+ type: "rotate" | "rotateStart" | "rotateEnd"
20
+ /**
21
+ * Current rotation angle
22
+ */
23
+ angle: number
24
+ /**
25
+ * Delta angle between events
26
+ */
27
+ deltaAngle: number
28
+ }
29
+
30
+ type Options = {
31
+ posThreshold?: number
32
+ }
33
+
34
+ export class RotateRecognizer extends Recognizer<Options> {
35
+ public events$: Observable<RotateEvent>
36
+
37
+ constructor(protected options: Options = {}) {
38
+ super(options)
39
+
40
+ this.events$ = this.config$.pipe(
41
+ switchMap((initializedWith) => {
42
+ const { posThreshold } = initializedWith.options ?? {}
43
+
44
+ const panRecognizer = new PanRecognizer({ posThreshold })
45
+
46
+ panRecognizer.initialize(initializedWith)
47
+
48
+ const hasLessThanTwoFinger$ = panRecognizer.events$.pipe(
49
+ filter(({ pointers }) => pointers.length < 2),
50
+ distinctUntilChanged(),
51
+ )
52
+
53
+ const hasMoreThanOneFinger$ = panRecognizer.events$.pipe(
54
+ map((event) => [event, event.pointers.length > 1] as const),
55
+ distinctUntilChanged(
56
+ (
57
+ [_, previousHasMoreThanOneFinger],
58
+ [__, currentHasMoreThanOneFinger],
59
+ ) => previousHasMoreThanOneFinger === currentHasMoreThanOneFinger,
60
+ ),
61
+ filter(([_, hasMoreThanOneFinger]) => hasMoreThanOneFinger),
62
+ )
63
+
64
+ const start$ = hasMoreThanOneFinger$.pipe(
65
+ map(([event]) => ({
66
+ ...event,
67
+ type: "rotateStart" as const,
68
+ angle: 0,
69
+ deltaAngle: 0,
70
+ })),
71
+ )
72
+
73
+ const rotate$ = start$.pipe(
74
+ mergeMap(() =>
75
+ panRecognizer.events$.pipe(
76
+ scan<
77
+ PanEvent,
78
+ RotateEvent,
79
+ Pick<RotateEvent, "angle" | "deltaAngle">
80
+ >(
81
+ (acc, current) => {
82
+ const angle = acc.angle + current.deltaPointersAngle
83
+
84
+ return {
85
+ ...acc,
86
+ ...current,
87
+ type: "rotate" as const,
88
+ angle,
89
+ deltaAngle: current.deltaPointersAngle,
90
+ }
91
+ },
92
+ { angle: 0, deltaAngle: 0 },
93
+ ),
94
+ takeUntil(hasLessThanTwoFinger$),
95
+ ),
96
+ ),
97
+ )
98
+
99
+ const end$ = start$.pipe(
100
+ mergeMap(() =>
101
+ combineLatest([rotate$, hasLessThanTwoFinger$] as const).pipe(
102
+ first(),
103
+ map(([event]) => ({
104
+ ...event,
105
+ type: "rotateEnd" as const,
106
+ })),
107
+ ),
108
+ ),
109
+ )
110
+
111
+ return merge(start$, rotate$, end$)
112
+ }),
113
+ )
114
+ }
115
+ }
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect, beforeEach } from "vitest"
2
+ import { buffer, first, lastValueFrom, tap, timer } from "rxjs"
3
+ import { SwipeRecognizer } from "./SwipeRecognizer"
4
+
5
+ const waitFor = (time: number) =>
6
+ new Promise((resolve) => setTimeout(resolve, time))
7
+
8
+ describe("SwipeRecognizer", () => {
9
+ let container = document.createElement("div")
10
+
11
+ beforeEach(() => {
12
+ container = document.createElement("div")
13
+ document.body.appendChild(container)
14
+ })
15
+
16
+ function createPointerEvent({
17
+ type,
18
+ x,
19
+ y,
20
+ identifier,
21
+ }: {
22
+ type: string
23
+ x: number
24
+ y: number
25
+ identifier: number
26
+ }) {
27
+ const event = new PointerEvent(type, {
28
+ clientX: x,
29
+ clientY: y,
30
+ pointerId: identifier,
31
+ pointerType: "touch",
32
+ bubbles: true,
33
+ })
34
+
35
+ // @ts-ignore
36
+ event.x = x
37
+ // @ts-ignore
38
+ event.y = y
39
+
40
+ return event
41
+ }
42
+
43
+ function sendPointerEvent({
44
+ type,
45
+ x,
46
+ y,
47
+ container,
48
+ identifier,
49
+ }: {
50
+ container: HTMLElement
51
+ type: string
52
+ x: number
53
+ y: number
54
+ identifier: number
55
+ }) {
56
+ const event = createPointerEvent({
57
+ type,
58
+ x,
59
+ y,
60
+ identifier,
61
+ })
62
+
63
+ sendPointerEventFromEvent({ event, container })
64
+ }
65
+
66
+ function sendPointerEventFromEvent({
67
+ event,
68
+ container,
69
+ }: {
70
+ event: PointerEvent
71
+ container: HTMLElement
72
+ }) {
73
+ container.dispatchEvent(event)
74
+ }
75
+
76
+ // ~0.8 velocityX
77
+ const createVelocityOfOnePanMoving = async () => {
78
+ await waitFor(1)
79
+
80
+ sendPointerEvent({
81
+ container,
82
+ identifier: 1,
83
+ type: "pointerdown",
84
+ x: 0,
85
+ y: 0,
86
+ })
87
+
88
+ await waitFor(10)
89
+
90
+ sendPointerEvent({
91
+ container,
92
+ identifier: 1,
93
+ type: "pointermove",
94
+ x: 10,
95
+ y: 0,
96
+ })
97
+
98
+ await waitFor(10)
99
+
100
+ sendPointerEvent({
101
+ container,
102
+ identifier: 1,
103
+ type: "pointerup",
104
+ x: 20,
105
+ y: 0,
106
+ })
107
+ }
108
+
109
+ it("should detect a swipe if above threshold", async () => {
110
+ const waitLongEnough$ = timer(100)
111
+ const recognizer = new SwipeRecognizer({
112
+ escapeVelocity: 0.4,
113
+ posThreshold: 5
114
+ })
115
+
116
+ recognizer.initialize({
117
+ container,
118
+ })
119
+
120
+ const values = await lastValueFrom(
121
+ recognizer.events$.pipe(
122
+ tap({
123
+ subscribe: async () => {
124
+ await createVelocityOfOnePanMoving()
125
+ },
126
+ }),
127
+ buffer(waitLongEnough$),
128
+ first(),
129
+ ),
130
+ )
131
+
132
+ expect(values).toMatchObject([{ type: "swipe" }])
133
+ })
134
+
135
+ it("should not detect a swipe if below threshold", async () => {
136
+ const waitLongEnough$ = timer(100)
137
+ const recognizer = new SwipeRecognizer({
138
+ escapeVelocity: 10,
139
+ posThreshold: 5
140
+ })
141
+
142
+ recognizer.initialize({
143
+ container,
144
+ })
145
+
146
+ const values = await lastValueFrom(
147
+ recognizer.events$.pipe(
148
+ tap({
149
+ subscribe: async () => {
150
+ await createVelocityOfOnePanMoving()
151
+ },
152
+ }),
153
+ buffer(waitLongEnough$),
154
+ first(),
155
+ ),
156
+ )
157
+
158
+ expect(values).toMatchObject([])
159
+ })
160
+ })
@@ -0,0 +1,82 @@
1
+ import {
2
+ Observable,
3
+ exhaustMap,
4
+ filter,
5
+ first,
6
+ map,
7
+ share,
8
+ switchMap,
9
+ takeUntil,
10
+ } from "rxjs"
11
+ import { Recognizer } from "../recognizer/Recognizer"
12
+ import { PanRecognizer } from "../pan/PanRecognizer"
13
+ import { RecognizerEvent } from "../recognizer/RecognizerEvent"
14
+ import { calculateDegreeAngleBetweenPoints } from "../utils/geometry"
15
+ import { isRecognizedAsSwipe } from "./isRecognizedAsSwipe"
16
+
17
+ export interface SwipeEvent extends RecognizerEvent {
18
+ type: "swipe"
19
+ /**
20
+ * Angle between first pointer and last one in degree
21
+ *
22
+ * (-90)
23
+ * (+/-180) <- | -> (+/-0)
24
+ * (+90)
25
+ */
26
+ angle: number
27
+ }
28
+
29
+ type Options = {
30
+ /**
31
+ * The minimum velocity (px/ms) that the gesture has to obtain by the end event.
32
+ */
33
+ escapeVelocity?: number
34
+ // @todo
35
+ numInputs?: number
36
+ // @todo
37
+ maxRestTime?: number
38
+ posThreshold?: number
39
+ }
40
+
41
+ export class SwipeRecognizer extends Recognizer<Options> {
42
+ public events$: Observable<SwipeEvent>
43
+
44
+ constructor(protected options: Options = {}) {
45
+ super(options)
46
+
47
+ this.events$ = this.validConfig$.pipe(
48
+ switchMap((initializedWith) => {
49
+ const { escapeVelocity = 0.9, posThreshold } =
50
+ initializedWith.options ?? {}
51
+ const panRecognizer = new PanRecognizer({ posThreshold })
52
+
53
+ panRecognizer.initialize(initializedWith)
54
+
55
+ const hasMoreThanOneFinger$ = panRecognizer.events$.pipe(
56
+ filter(({ pointers }) => pointers.length > 1),
57
+ )
58
+
59
+ return panRecognizer.start$.pipe(
60
+ exhaustMap((startEvent) => {
61
+ return panRecognizer.end$.pipe(
62
+ first(),
63
+ isRecognizedAsSwipe(escapeVelocity),
64
+ map(({ type, ...rest }) => {
65
+ return {
66
+ type: "swipe" as const,
67
+ angle: calculateDegreeAngleBetweenPoints(
68
+ startEvent.event,
69
+ rest.event,
70
+ ),
71
+ ...rest,
72
+ }
73
+ }),
74
+ takeUntil(hasMoreThanOneFinger$),
75
+ )
76
+ }),
77
+ share(),
78
+ )
79
+ }),
80
+ )
81
+ }
82
+ }
@@ -0,0 +1,13 @@
1
+ import { Observable, filter } from "rxjs"
2
+ import { PanEvent } from "../pan/PanRecognizer"
3
+
4
+ export const isRecognizedAsSwipe =
5
+ (escapeVelocity: number) => (stream: Observable<PanEvent>) =>
6
+ stream.pipe(
7
+ filter((event) => {
8
+ return (
9
+ Math.abs(event.velocityX) >= escapeVelocity ||
10
+ Math.abs(event.velocityY) >= escapeVelocity
11
+ )
12
+ }),
13
+ )