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,172 @@
1
+ import {
2
+ Observable,
3
+ buffer,
4
+ combineLatest,
5
+ debounceTime,
6
+ exhaustMap,
7
+ filter,
8
+ first,
9
+ ignoreElements,
10
+ map,
11
+ merge,
12
+ of,
13
+ share,
14
+ shareReplay,
15
+ switchMap,
16
+ takeUntil,
17
+ timer,
18
+ withLatestFrom,
19
+ } from "rxjs"
20
+ import {
21
+ fromFailWith,
22
+ filterNotEmpty,
23
+ isOutsidePosThreshold,
24
+ } from "../utils/utils"
25
+ import { Recognizer, RecognizerOptions } from "../recognizer/Recognizer"
26
+ import { RecognizerEvent } from "../recognizer/RecognizerEvent"
27
+ import { mapToRecognizerEvent } from "../recognizer/mapToRecognizerEvent"
28
+ import {
29
+ fromPointerDown,
30
+ getPointerEvents,
31
+ trackFingers,
32
+ } from "../utils/events"
33
+
34
+ export interface TapEvent extends RecognizerEvent {
35
+ type: "tap"
36
+ taps: number
37
+ }
38
+
39
+ interface Options extends RecognizerOptions {
40
+ // Maximum time in ms between multiple taps.
41
+ multiTapThreshold?: number
42
+ // Maximum press time in ms.
43
+ maximumPressTime?: number
44
+ maxTaps?: number
45
+ posThreshold?: number
46
+ }
47
+
48
+ export class TapRecognizer extends Recognizer<Options> {
49
+ public events$: Observable<TapEvent>
50
+
51
+ constructor(protected options: Options) {
52
+ super(options)
53
+
54
+ this.events$ = this.validConfig$.pipe(
55
+ switchMap((config) => {
56
+ const { container, afterEventReceived } = config
57
+ const {
58
+ multiTapThreshold = 200,
59
+ maximumPressTime = 250,
60
+ maxTaps = 2,
61
+ // threshold should be high because of fingers size
62
+ // and potential margin due to it. clicks are nearly perfect
63
+ // not fingers.
64
+ posThreshold = 10,
65
+ failWith,
66
+ } = config.options ?? {}
67
+
68
+ const { pointerUp$, pointerLeave$, pointerCancel$, pointerMove$ } =
69
+ getPointerEvents({
70
+ container,
71
+ afterEventReceived,
72
+ })
73
+
74
+ const pointerDown$ = fromPointerDown({ container, afterEventReceived })
75
+ const activePointers$ = pointerDown$.pipe(
76
+ trackFingers({
77
+ pointerCancel$,
78
+ pointerLeave$,
79
+ pointerMove$,
80
+ pointerUp$,
81
+ trackMove: false,
82
+ }),
83
+ shareReplay(1),
84
+ )
85
+
86
+ const hasMoreThanOneActivePointer$ = activePointers$.pipe(
87
+ filter((pointers) => pointers.length > 1),
88
+ )
89
+
90
+ const onlyOnePointer = <T>(stream: Observable<T>) =>
91
+ stream.pipe(
92
+ withLatestFrom(activePointers$),
93
+ filter(([, pointers]) => pointers.length === 1),
94
+ map(([pointer]) => pointer),
95
+ )
96
+
97
+ const bufferPointerDowns = (stream: Observable<PointerEvent>) =>
98
+ stream.pipe(
99
+ buffer(pointerUp$.pipe(debounceTime(multiTapThreshold))),
100
+ first(),
101
+ )
102
+
103
+ const failWith$ = fromFailWith(failWith)
104
+
105
+ const tap$ = merge(
106
+ pointerDown$,
107
+ activePointers$.pipe(ignoreElements()),
108
+ ).pipe(
109
+ onlyOnePointer,
110
+ exhaustMap((initialPointerEvent) => {
111
+ const pointerDowns$ = merge(of(initialPointerEvent), pointerDown$)
112
+ const subsequentPointerEvents$ = merge(
113
+ pointerUp$,
114
+ pointerLeave$,
115
+ pointerCancel$,
116
+ pointerMove$,
117
+ )
118
+ const pointerDownsBuffered$ = pointerDowns$.pipe(bufferPointerDowns)
119
+ const subsequentPointersOutOfPositionThreshold$ =
120
+ subsequentPointerEvents$.pipe(
121
+ filter((event) =>
122
+ isOutsidePosThreshold(
123
+ initialPointerEvent,
124
+ event,
125
+ posThreshold,
126
+ ),
127
+ ),
128
+ first(),
129
+ )
130
+ const waitedTooLong$ = activePointers$.pipe(
131
+ switchMap(() => timer(maximumPressTime)),
132
+ )
133
+
134
+ const takeUntil$ = merge(
135
+ failWith$,
136
+ pointerCancel$,
137
+ hasMoreThanOneActivePointer$,
138
+ subsequentPointersOutOfPositionThreshold$,
139
+ waitedTooLong$,
140
+ )
141
+
142
+ const rawEvent$ = pointerDownsBuffered$.pipe(
143
+ filterNotEmpty,
144
+ filter((events) => events.length <= maxTaps),
145
+ map((events) => ({
146
+ type: "tap" as const,
147
+ taps: events.length,
148
+ latestActivePointers: events,
149
+ event: events[0],
150
+ })),
151
+ )
152
+
153
+ return combineLatest([
154
+ rawEvent$.pipe(mapToRecognizerEvent),
155
+ rawEvent$,
156
+ ]).pipe(
157
+ map(([recognizerEvent, { type, taps }]) => ({
158
+ ...recognizerEvent,
159
+ type,
160
+ taps,
161
+ })),
162
+ takeUntil(takeUntil$),
163
+ )
164
+ }),
165
+ )
166
+
167
+ return tap$
168
+ }),
169
+ share(),
170
+ )
171
+ }
172
+ }
@@ -0,0 +1,186 @@
1
+ import {
2
+ NEVER,
3
+ Observable,
4
+ defer,
5
+ endWith,
6
+ filter,
7
+ fromEvent,
8
+ map,
9
+ merge,
10
+ mergeMap,
11
+ of,
12
+ scan,
13
+ takeUntil,
14
+ } from "rxjs"
15
+ import { isDefined } from "./utils"
16
+
17
+ export function matchPointer(pointer: PointerEvent) {
18
+ return (stream: Observable<PointerEvent>) =>
19
+ stream.pipe(
20
+ filter((newPointer) => newPointer.pointerId === pointer.pointerId),
21
+ )
22
+ }
23
+
24
+ export const getPointerEvents = ({
25
+ container,
26
+ afterEventReceived = (event) => event,
27
+ }: {
28
+ container: HTMLElement
29
+ afterEventReceived?: (event: PointerEvent) => PointerEvent
30
+ }) => {
31
+ const pointerDown$ = fromPointerDown({ afterEventReceived, container })
32
+ const pointerUp$ = fromEvent<PointerEvent>(container, "pointerup").pipe(
33
+ map(afterEventReceived),
34
+ )
35
+ const pointerMove$ = fromEvent<PointerEvent>(container, "pointermove").pipe(
36
+ map(afterEventReceived),
37
+ )
38
+ /**
39
+ * Mostly used to handle cursor leaving window on mouse pointers.
40
+ */
41
+ const pointerLeave$ = fromEvent<PointerEvent>(container, "pointerleave").pipe(
42
+ map(afterEventReceived),
43
+ )
44
+ /**
45
+ * Handle things such as contextual menu popup or
46
+ * native swipe navigation conflict.
47
+ */
48
+ const pointerCancel$ = fromEvent<PointerEvent>(
49
+ container,
50
+ "pointercancel",
51
+ ).pipe(map(afterEventReceived))
52
+
53
+ return {
54
+ pointerDown$,
55
+ pointerCancel$,
56
+ pointerLeave$,
57
+ pointerUp$,
58
+ pointerMove$,
59
+ }
60
+ }
61
+
62
+ export const fromPointerDown = ({
63
+ container,
64
+ afterEventReceived = (event) => event,
65
+ }: {
66
+ container: HTMLElement
67
+ afterEventReceived?: (event: PointerEvent) => PointerEvent
68
+ }) =>
69
+ fromEvent<PointerEvent>(container, "pointerdown").pipe(
70
+ map(afterEventReceived),
71
+ )
72
+
73
+ /**
74
+ * @todo test the strict stability
75
+ * @important
76
+ * Stable function, will not emit if the pointers don't change
77
+ */
78
+ export const trackActivePointers = ({
79
+ container,
80
+ }: {
81
+ container: HTMLElement
82
+ }) => {
83
+ const pointerUp$ = fromEvent<PointerEvent>(container, "pointerup")
84
+ const pointerLeave$ = fromEvent<PointerEvent>(container, "pointerleave")
85
+ const pointerCancel$ = fromEvent<PointerEvent>(container, "pointercancel")
86
+
87
+ return fromEvent<PointerEvent>(container, "pointerdown").pipe(
88
+ mergeMap((pointerDown) => {
89
+ const pointerDownRelease$ = merge(
90
+ pointerUp$,
91
+ pointerLeave$,
92
+ pointerCancel$,
93
+ ).pipe(matchPointer(pointerDown))
94
+ const pointerDown$ = defer(() => of(pointerDown))
95
+
96
+ return pointerDown$.pipe(
97
+ mergeMap(() => merge(pointerDown$, NEVER)),
98
+ map((event) => ({ id: event.pointerId, event })),
99
+ takeUntil(pointerDownRelease$),
100
+ endWith({ id: pointerDown.pointerId, event: undefined }),
101
+ )
102
+ }),
103
+ scan(
104
+ (acc, { event, id }) => {
105
+ if (!event) {
106
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
107
+ const { [id]: _deleted, ...rest } = acc
108
+
109
+ return rest
110
+ }
111
+
112
+ return {
113
+ ...acc,
114
+ [id]: event,
115
+ }
116
+ },
117
+ {} as Record<number, PointerEvent | undefined>,
118
+ ),
119
+ map((events) => Object.values(events).filter(isDefined)),
120
+ )
121
+ }
122
+
123
+ /**
124
+ * Track all pointerEvent for active fingers
125
+ */
126
+ export const trackFingers =
127
+ ({
128
+ pointerCancel$,
129
+ pointerLeave$,
130
+ pointerUp$,
131
+ pointerMove$,
132
+ trackMove,
133
+ }: {
134
+ pointerUp$: Observable<PointerEvent>
135
+ pointerLeave$: Observable<PointerEvent>
136
+ pointerCancel$: Observable<PointerEvent>
137
+ pointerMove$: Observable<PointerEvent>
138
+ /**
139
+ * Setting this value to false will not update the list
140
+ * on pointer move. Use it if you only need to track number of active
141
+ * fingers.
142
+ */
143
+ trackMove: boolean
144
+ }) =>
145
+ (stream: Observable<PointerEvent>) => {
146
+ return stream.pipe(
147
+ mergeMap((pointerDown) => {
148
+ const pointerDownRelease$ = merge(
149
+ pointerUp$,
150
+ pointerLeave$,
151
+ pointerCancel$,
152
+ ).pipe(matchPointer(pointerDown))
153
+
154
+ const pointerDown$ = defer(() => of(pointerDown))
155
+
156
+ const tracking$ = merge(
157
+ pointerDown$,
158
+ trackMove ? pointerMove$ : NEVER,
159
+ ).pipe(
160
+ matchPointer(pointerDown),
161
+ map((event) => ({ id: event.pointerId, event })),
162
+ takeUntil(pointerDownRelease$),
163
+ endWith({ id: pointerDown.pointerId, event: undefined }),
164
+ )
165
+
166
+ return tracking$
167
+ }),
168
+ scan(
169
+ (acc, { event, id }) => {
170
+ if (!event) {
171
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
172
+ const { [id]: _deleted, ...rest } = acc
173
+
174
+ return rest
175
+ }
176
+
177
+ return {
178
+ ...acc,
179
+ [id]: event,
180
+ }
181
+ },
182
+ {} as Record<number, PointerEvent | undefined>,
183
+ ),
184
+ map((events) => Object.values(events).filter(isDefined)),
185
+ )
186
+ }
@@ -0,0 +1,115 @@
1
+ type Point = { x: number; y: number }
2
+
3
+ export function calculateDistance(pointA: Point, pointB: Point): number {
4
+ return Math.sqrt(
5
+ Math.pow(pointB.x - pointA.x, 2) + Math.pow(pointB.y - pointA.y, 2),
6
+ )
7
+ }
8
+
9
+ export function calculateAverageDistance(pointerEvents: Point[]): number {
10
+ const n = pointerEvents.length
11
+ if (n < 2) return 0 // No distance if fewer than 2 pointers
12
+
13
+ const totalDistance = pointerEvents.reduce((sum, event1, i) => {
14
+ return (
15
+ sum +
16
+ pointerEvents.slice(i + 1).reduce((innerSum, event2) => {
17
+ return innerSum + calculateDistance(event1, event2)
18
+ }, 0)
19
+ )
20
+ }, 0)
21
+
22
+ // Number of pairs is n choose 2: n * (n - 1) / 2
23
+ const numberOfPairs = (n * (n - 1)) / 2
24
+ return totalDistance / numberOfPairs
25
+ }
26
+
27
+ export function calculateRadianAngleBetweenPoints(
28
+ pointerA: Point,
29
+ pointerB: Point,
30
+ ): number {
31
+ // Calculate the differences in coordinates
32
+ const deltaX = pointerB.x - pointerA.x
33
+ const deltaY = pointerB.y - pointerA.y
34
+
35
+ // Calculate the angle in radians
36
+ const angleInRadians = Math.atan2(deltaY, deltaX)
37
+
38
+ // Return the angle in degrees
39
+ return angleInRadians
40
+ }
41
+
42
+ export function calculateDegreeAngleBetweenPoints(
43
+ pointerA: Point,
44
+ pointerB: Point,
45
+ ): number {
46
+ // Calculate the angle in radians
47
+ const angleInRadians = calculateRadianAngleBetweenPoints(pointerA, pointerB)
48
+
49
+ // Convert the angle to degrees (optional)
50
+ const angleInDegrees = angleInRadians * (180 / Math.PI)
51
+
52
+ // Return the angle in degrees
53
+ return angleInDegrees
54
+ }
55
+
56
+ export function calculateCentroid(points: Point[]) {
57
+ const sum = points.reduce(
58
+ (acc, point) => {
59
+ acc.x += point.x
60
+ acc.y += point.y
61
+
62
+ return acc
63
+ },
64
+ { x: 0, y: 0 },
65
+ )
66
+
67
+ const numPoints = points.length || 1
68
+
69
+ return {
70
+ x: sum.x / numPoints,
71
+ y: sum.y / numPoints,
72
+ }
73
+ }
74
+
75
+ export function calculateAngleDelta(
76
+ initialPoints: Point[],
77
+ newPoints: Point[],
78
+ ) {
79
+ if (initialPoints.length !== newPoints.length) {
80
+ throw new Error("Initial and new points arrays must have the same length.")
81
+ }
82
+
83
+ const initialCentroid = calculateCentroid(initialPoints)
84
+ const newCentroid = calculateCentroid(newPoints)
85
+
86
+ let totalAngleDelta = 0
87
+
88
+ for (let i = 0; i < initialPoints.length; i++) {
89
+ const initialAngle = calculateRadianAngleBetweenPoints(
90
+ initialCentroid,
91
+ initialPoints[i]!,
92
+ )
93
+ const newAngle = calculateRadianAngleBetweenPoints(
94
+ newCentroid,
95
+ newPoints[i]!,
96
+ )
97
+
98
+ let angleDelta = newAngle - initialAngle
99
+
100
+ // Normalize the angle delta to be within [-PI, PI]
101
+ if (angleDelta > Math.PI) {
102
+ angleDelta -= 2 * Math.PI
103
+ } else if (angleDelta < -Math.PI) {
104
+ angleDelta += 2 * Math.PI
105
+ }
106
+
107
+ totalAngleDelta += angleDelta
108
+ }
109
+
110
+ // Return the average angle delta
111
+ const radianDelta = totalAngleDelta / initialPoints.length
112
+ const degreesDelta = radianDelta * (180 / Math.PI)
113
+
114
+ return { radianDelta, degreesDelta }
115
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { calculateCentroid } from "./geometry"
3
+
4
+ describe("calculateCentroid", () => {
5
+ it("calculates the correct center for a list of pointer events", () => {
6
+ const events = [
7
+ { x: 0, y: 0 },
8
+ { x: 2, y: 2 },
9
+ { x: 4, y: 6 },
10
+ ]
11
+ const center = calculateCentroid(events as any)
12
+ expect(center).toEqual({ x: 2, y: 2.6666666666666665 })
13
+ })
14
+
15
+ it("returns origin when no events are present", () => {
16
+ const events: any[] = []
17
+ const center = calculateCentroid(events)
18
+ expect(center).toEqual({ x: 0, y: 0 })
19
+ })
20
+
21
+ it("handles single point correctly", () => {
22
+ const events = [{ x: 5, y: 5 }]
23
+ const center = calculateCentroid(events as any)
24
+ expect(center).toEqual({ x: 5, y: 5 })
25
+ })
26
+ })
@@ -0,0 +1,58 @@
1
+ import {
2
+ NEVER,
3
+ Observable,
4
+ filter,
5
+ merge,
6
+ } from "rxjs"
7
+ import { calculateCentroid } from "./geometry"
8
+
9
+ export function isDefined<T>(
10
+ arg: T | null | undefined,
11
+ ): arg is T extends null | undefined ? never : T {
12
+ return arg !== null && arg !== undefined
13
+ }
14
+
15
+ export const hasAtLeastOneItem = <T>(events: T[]): events is [T, ...T[]] =>
16
+ events.length > 0
17
+
18
+ export const filterNotEmpty = <T>(
19
+ stream: Observable<T[]>,
20
+ ): Observable<[T, ...T[]]> => stream.pipe(filter(hasAtLeastOneItem))
21
+
22
+ export function isOutsidePosThreshold(
23
+ startEvent: PointerEvent,
24
+ endEvent: PointerEvent,
25
+ posThreshold: number,
26
+ ) {
27
+ const start = calculateCentroid([startEvent])
28
+ const end = calculateCentroid([endEvent])
29
+
30
+ // Determines if the movement qualifies as a drag
31
+ return (
32
+ Math.abs(end.x - start.x) > posThreshold ||
33
+ Math.abs(end.y - start.y) > posThreshold
34
+ )
35
+ }
36
+
37
+ export const fromFailWith = (failWith?: { start$: Observable<unknown> }[]) =>
38
+ !failWith?.length
39
+ ? NEVER
40
+ : merge(...(failWith?.map(({ start$ }) => start$) ?? []))
41
+
42
+ /**
43
+ * Avoid division by zero
44
+ * Calculate velocity in pixels per second
45
+ */
46
+ export const calculateVelocity = (
47
+ delay: number,
48
+ deltaX: number,
49
+ deltaY: number,
50
+ ) => {
51
+ const velocityX = delay > 0 ? deltaX / delay : 0
52
+ const velocityY = delay > 0 ? deltaY / delay : 0
53
+
54
+ return {
55
+ velocityX,
56
+ velocityY,
57
+ }
58
+ }