gesturx 0.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,17 @@
1
+ on:
2
+ push:
3
+ branches: main
4
+
5
+ jobs:
6
+ publish:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v3
11
+ with:
12
+ node-version: "20"
13
+ - run: npm ci
14
+ - run: npm test
15
+ - uses: JS-DevTools/npm-publish@v3
16
+ with:
17
+ token: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1 @@
1
+ {}
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Maxime
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # gesturx
2
+
3
+ Gesture recognizer using rxjs
@@ -0,0 +1,9 @@
1
+ // @ts-check
2
+
3
+ import eslint from "@eslint/js";
4
+ import tseslint from "typescript-eslint";
5
+
6
+ export default tseslint.config(
7
+ eslint.configs.recommended,
8
+ ...tseslint.configs.recommended,
9
+ );
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>gesturx</title>
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "gesturx",
3
+ "private": false,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview",
10
+ "lint": "prettier --write . && eslint --fix .",
11
+ "test": "vitest",
12
+ "coverage": "vitest run --coverage"
13
+ },
14
+ "devDependencies": {
15
+ "@eslint/js": "^9.0.0",
16
+ "@types/node": "^20.12.7",
17
+ "@vitest/coverage-v8": "^1.5.0",
18
+ "eslint": "^8.57.0",
19
+ "happy-dom": "^14.7.1",
20
+ "prettier": "3.2.5",
21
+ "rollup-plugin-node-externals": "^7.1.1",
22
+ "typescript": "^5.4.5",
23
+ "typescript-eslint": "^7.7.0",
24
+ "vite": "^5.2.0",
25
+ "vite-plugin-dts": "^3.8.3",
26
+ "vite-plugin-eslint": "^1.8.1",
27
+ "vitest": "^1.5.0"
28
+ },
29
+ "dependencies": {
30
+ "rxjs": "^7.8.1"
31
+ }
32
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,45 @@
1
+ import { map, merge } from "rxjs";
2
+ import { createTapRecognizer } from "./tapRecognizer";
3
+ import { getCenterFromEvent } from "./utils";
4
+
5
+ export const createManager = ({
6
+ container,
7
+ afterEventReceived = (event) => event,
8
+ recognizers,
9
+ }: {
10
+ container: HTMLElement;
11
+ recognizers: ReturnType<typeof createTapRecognizer>[];
12
+ afterEventReceived?: (event: PointerEvent) => PointerEvent;
13
+ }) => {
14
+ // user api
15
+ // const dragRecognizer = createPanRecognizer();
16
+ // const singleTapRecognizer = createTapRecognizer({
17
+ // maxTaps: 2,
18
+ // failWith: [dragRecognizer],
19
+ // });
20
+ // const pinchRecognizer = createPinchRecognizer();
21
+
22
+ // internal api
23
+ // dragRecognizer.initialize({ container, afterEventReceived });
24
+
25
+ // const recognizers = [
26
+ // singleTapRecognizer({ container, afterEventReceived }),
27
+ // // pinchRecognizer({ container, afterEventReceived }),
28
+ // // dragRecognizer,
29
+ // ]
30
+
31
+ const recognizerInstances = recognizers.map((recognizer) =>
32
+ recognizer({ container, afterEventReceived }),
33
+ );
34
+
35
+ const events$ = merge(
36
+ ...recognizerInstances.map((recognizer) => recognizer.events$),
37
+ ).pipe(
38
+ map((event) => ({
39
+ ...event,
40
+ center: getCenterFromEvent(event.srcEvent),
41
+ })),
42
+ );
43
+
44
+ return { events$ };
45
+ };
@@ -0,0 +1,542 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { createTapRecognizer } from "./tapRecognizer";
3
+ import { buffer, first, lastValueFrom, tap, timer } from "rxjs";
4
+
5
+ const waitFor = (time: number) =>
6
+ new Promise((resolve) => setTimeout(resolve, time));
7
+
8
+ describe("TapGestureRecognizer", () => {
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
+ return event;
36
+ }
37
+
38
+ function sendPointerEvent({
39
+ type,
40
+ x,
41
+ y,
42
+ container,
43
+ identifier,
44
+ }: {
45
+ container: HTMLElement;
46
+ type: string;
47
+ x: number;
48
+ y: number;
49
+ identifier: number;
50
+ }) {
51
+ const event = createPointerEvent({
52
+ type,
53
+ x,
54
+ y,
55
+ identifier,
56
+ });
57
+
58
+ sendPointerEventFromEvent({ event, container });
59
+ }
60
+
61
+ function sendPointerEventFromEvent({
62
+ event,
63
+ container,
64
+ }: {
65
+ event: PointerEvent;
66
+ container: HTMLElement;
67
+ }) {
68
+ container.dispatchEvent(event);
69
+ }
70
+
71
+ it("should detect a single tap", async () => {
72
+ const multiTapThreshold = 5;
73
+ const waitLongEnough$ = timer(multiTapThreshold * 2);
74
+ const recognizer = createTapRecognizer({ multiTapThreshold })({
75
+ container,
76
+ });
77
+
78
+ const values = await lastValueFrom(
79
+ recognizer.events$.pipe(
80
+ tap({
81
+ subscribe: async () => {
82
+ await waitFor(1);
83
+
84
+ sendPointerEvent({
85
+ container,
86
+ identifier: 1,
87
+ type: "pointerdown",
88
+ x: 0,
89
+ y: 0,
90
+ });
91
+ sendPointerEvent({
92
+ container,
93
+ identifier: 1,
94
+ type: "pointerup",
95
+ x: 0,
96
+ y: 0,
97
+ });
98
+ },
99
+ }),
100
+ buffer(waitLongEnough$),
101
+ first(),
102
+ ),
103
+ );
104
+
105
+ expect(values).toMatchObject([{ type: "tap", taps: 1 }]);
106
+ });
107
+
108
+ it("should detect a double tap", async () => {
109
+ const multiTapThreshold = 5;
110
+ const waitLongEnough$ = timer(multiTapThreshold * 2);
111
+ const recognizer = createTapRecognizer({ multiTapThreshold, maxTaps: 2 })({
112
+ container,
113
+ });
114
+
115
+ const values = await lastValueFrom(
116
+ recognizer.events$.pipe(
117
+ tap({
118
+ subscribe: async () => {
119
+ await waitFor(1);
120
+
121
+ sendPointerEvent({
122
+ container,
123
+ identifier: 1,
124
+ type: "pointerdown",
125
+ x: 0,
126
+ y: 0,
127
+ });
128
+ sendPointerEvent({
129
+ container,
130
+ identifier: 1,
131
+ type: "pointerup",
132
+ x: 0,
133
+ y: 0,
134
+ });
135
+ sendPointerEvent({
136
+ container,
137
+ identifier: 2,
138
+ type: "pointerdown",
139
+ x: 0,
140
+ y: 0,
141
+ });
142
+ sendPointerEvent({
143
+ container,
144
+ identifier: 2,
145
+ type: "pointerup",
146
+ x: 0,
147
+ y: 0,
148
+ });
149
+ },
150
+ }),
151
+ buffer(waitLongEnough$),
152
+ first(),
153
+ ),
154
+ );
155
+
156
+ expect(values).toMatchObject([{ type: "tap", taps: 2 }]);
157
+ });
158
+
159
+ it("should detect a triple tap", async () => {
160
+ const multiTapThreshold = 5;
161
+ const recognizer = createTapRecognizer({ multiTapThreshold, maxTaps: 3 })({
162
+ container,
163
+ });
164
+ const waitLongEnough$ = timer(100);
165
+
166
+ const values = await lastValueFrom(
167
+ recognizer.events$.pipe(
168
+ tap({
169
+ subscribe: () => {
170
+ setTimeout(() => {
171
+ sendPointerEvent({
172
+ container,
173
+ identifier: 1,
174
+ type: "pointerdown",
175
+ x: 0,
176
+ y: 0,
177
+ });
178
+ sendPointerEvent({
179
+ container,
180
+ identifier: 1,
181
+ type: "pointerup",
182
+ x: 0,
183
+ y: 0,
184
+ });
185
+ sendPointerEvent({
186
+ container,
187
+ identifier: 1,
188
+ type: "pointerdown",
189
+ x: 0,
190
+ y: 0,
191
+ });
192
+ sendPointerEvent({
193
+ container,
194
+ identifier: 1,
195
+ type: "pointerup",
196
+ x: 0,
197
+ y: 0,
198
+ });
199
+ sendPointerEvent({
200
+ container,
201
+ identifier: 1,
202
+ type: "pointerdown",
203
+ x: 0,
204
+ y: 0,
205
+ });
206
+ sendPointerEvent({
207
+ container,
208
+ identifier: 1,
209
+ type: "pointerup",
210
+ x: 0,
211
+ y: 0,
212
+ });
213
+ });
214
+ },
215
+ }),
216
+ buffer(waitLongEnough$),
217
+ first(),
218
+ ),
219
+ );
220
+
221
+ expect(values).toMatchObject([{ type: "tap", taps: 3 }]);
222
+ });
223
+
224
+ it("should not detect a double tap if the second tap is after threshold", async () => {
225
+ const multiTapThreshold = 5;
226
+ const waitLongEnough$ = timer(100);
227
+ const recognizer = createTapRecognizer({ multiTapThreshold, maxTaps: 2 })({
228
+ container,
229
+ });
230
+
231
+ const values = await lastValueFrom(
232
+ recognizer.events$.pipe(
233
+ tap({
234
+ subscribe: async () => {
235
+ await waitFor(1);
236
+
237
+ sendPointerEvent({
238
+ container,
239
+ identifier: 1,
240
+ type: "pointerdown",
241
+ x: 0,
242
+ y: 0,
243
+ });
244
+ sendPointerEvent({
245
+ container,
246
+ identifier: 1,
247
+ type: "pointerup",
248
+ x: 0,
249
+ y: 0,
250
+ });
251
+
252
+ await waitFor(multiTapThreshold);
253
+
254
+ sendPointerEvent({
255
+ container,
256
+ identifier: 2,
257
+ type: "pointerdown",
258
+ x: 0,
259
+ y: 0,
260
+ });
261
+ sendPointerEvent({
262
+ container,
263
+ identifier: 2,
264
+ type: "pointerup",
265
+ x: 0,
266
+ y: 0,
267
+ });
268
+ },
269
+ }),
270
+ buffer(waitLongEnough$),
271
+ first(),
272
+ ),
273
+ );
274
+
275
+ expect(values.length).toBe(2);
276
+ expect(values).toMatchObject([
277
+ { type: "tap", taps: 1, srcEvent: { pointerId: 1 } },
278
+ { type: "tap", taps: 1, srcEvent: { pointerId: 2 } },
279
+ ]);
280
+ });
281
+
282
+ it("should not trigger tap if moved too much between down and up", async () => {
283
+ const multiTapThreshold = 5;
284
+ const waitLongEnough$ = timer(100);
285
+ const recognizer = createTapRecognizer({
286
+ multiTapThreshold,
287
+ posThreshold: 5,
288
+ })({ container });
289
+
290
+ const values = await lastValueFrom(
291
+ recognizer.events$.pipe(
292
+ tap({
293
+ subscribe: async () => {
294
+ await waitFor(1);
295
+
296
+ sendPointerEvent({
297
+ container,
298
+ identifier: 1,
299
+ type: "pointerdown",
300
+ x: 0,
301
+ y: 0,
302
+ });
303
+ sendPointerEvent({
304
+ container,
305
+ identifier: 1,
306
+ type: "pointerup",
307
+ x: 50,
308
+ y: 50,
309
+ });
310
+ },
311
+ }),
312
+ buffer(waitLongEnough$),
313
+ first(),
314
+ ),
315
+ );
316
+
317
+ expect(values.length).toBe(0);
318
+ });
319
+
320
+ it("should not detect a tap if the pointer is held down too long", async () => {
321
+ const multiTapThreshold = 5;
322
+ const maximumPressTime = 10;
323
+ const waitLongEnough$ = timer(100);
324
+ const recognizer = createTapRecognizer({
325
+ multiTapThreshold,
326
+ posThreshold: 5,
327
+ maximumPressTime,
328
+ })({ container });
329
+
330
+ const values = await lastValueFrom(
331
+ recognizer.events$.pipe(
332
+ tap({
333
+ subscribe: async () => {
334
+ await waitFor(1);
335
+
336
+ sendPointerEvent({
337
+ container,
338
+ identifier: 1,
339
+ type: "pointerdown",
340
+ x: 0,
341
+ y: 0,
342
+ });
343
+
344
+ await waitFor(maximumPressTime - 5);
345
+
346
+ sendPointerEvent({
347
+ container,
348
+ identifier: 1,
349
+ type: "pointerup",
350
+ x: 0,
351
+ y: 0,
352
+ });
353
+
354
+ await waitFor(multiTapThreshold * 2);
355
+
356
+ sendPointerEvent({
357
+ container,
358
+ identifier: 2,
359
+ type: "pointerdown",
360
+ x: 0,
361
+ y: 0,
362
+ });
363
+
364
+ await waitFor(maximumPressTime);
365
+
366
+ sendPointerEvent({
367
+ container,
368
+ identifier: 2,
369
+ type: "pointerup",
370
+ x: 0,
371
+ y: 0,
372
+ });
373
+ },
374
+ }),
375
+ buffer(waitLongEnough$),
376
+ first(),
377
+ ),
378
+ );
379
+
380
+ expect(values.length).toBe(1);
381
+ expect(values).toMatchObject([
382
+ { type: "tap", taps: 1, srcEvent: { pointerId: 1 } },
383
+ ]);
384
+ });
385
+
386
+ it("should ignore taps when there are simultaneous pointers active", async () => {
387
+ const recognizer = createTapRecognizer({})({ container });
388
+ const waitLongEnough$ = timer(100);
389
+
390
+ const values = await lastValueFrom(
391
+ recognizer.events$.pipe(
392
+ tap({
393
+ subscribe: () => {
394
+ setTimeout(() => {
395
+ sendPointerEvent({
396
+ container,
397
+ identifier: 1,
398
+ type: "pointerdown",
399
+ x: 0,
400
+ y: 0,
401
+ });
402
+ sendPointerEvent({
403
+ container,
404
+ identifier: 2,
405
+ type: "pointerdown",
406
+ x: 10,
407
+ y: 10,
408
+ });
409
+ sendPointerEvent({
410
+ container,
411
+ identifier: 1,
412
+ type: "pointerup",
413
+ x: 0,
414
+ y: 0,
415
+ });
416
+ sendPointerEvent({
417
+ container,
418
+ identifier: 2,
419
+ type: "pointerup",
420
+ x: 10,
421
+ y: 10,
422
+ });
423
+ });
424
+ },
425
+ }),
426
+ buffer(waitLongEnough$),
427
+ first(),
428
+ ),
429
+ );
430
+
431
+ expect(values.length).toBe(0); // Assuming the recognizer should ignore simultaneous taps
432
+ });
433
+
434
+ it("should ignore one tap if there is more than one tap", async () => {
435
+ const recognizer = createTapRecognizer({
436
+ multiTapThreshold: 10,
437
+ maxTaps: 1,
438
+ })({ container });
439
+ const waitLongEnough$ = timer(100);
440
+
441
+ const values = await lastValueFrom(
442
+ recognizer.events$.pipe(
443
+ tap({
444
+ subscribe: async () => {
445
+ await waitFor(1);
446
+
447
+ sendPointerEvent({
448
+ container,
449
+ identifier: 1,
450
+ type: "pointerdown",
451
+ x: 0,
452
+ y: 0,
453
+ });
454
+ sendPointerEvent({
455
+ container,
456
+ identifier: 1,
457
+ type: "pointerup",
458
+ x: 0,
459
+ y: 0,
460
+ });
461
+
462
+ await waitFor(1);
463
+
464
+ sendPointerEvent({
465
+ container,
466
+ identifier: 2,
467
+ type: "pointerdown",
468
+ x: 0,
469
+ y: 0,
470
+ });
471
+ sendPointerEvent({
472
+ container,
473
+ identifier: 2,
474
+ type: "pointerup",
475
+ x: 0,
476
+ y: 0,
477
+ });
478
+ },
479
+ }),
480
+ buffer(waitLongEnough$),
481
+ first(),
482
+ ),
483
+ );
484
+
485
+ expect(values.length).toBe(0);
486
+ });
487
+
488
+ it("should ignore taps when a second tap is outside of position threshold", async () => {
489
+ const recognizer = createTapRecognizer({
490
+ posThreshold: 5,
491
+ multiTapThreshold: 10,
492
+ maxTaps: Infinity,
493
+ })({ container });
494
+ const waitLongEnough$ = timer(100);
495
+
496
+ const values = await lastValueFrom(
497
+ recognizer.events$.pipe(
498
+ tap({
499
+ subscribe: async () => {
500
+ // await waitFor(1)
501
+
502
+ sendPointerEvent({
503
+ container,
504
+ identifier: 1,
505
+ type: "pointerdown",
506
+ x: 0,
507
+ y: 0,
508
+ });
509
+ sendPointerEvent({
510
+ container,
511
+ identifier: 1,
512
+ type: "pointerup",
513
+ x: 0,
514
+ y: 0,
515
+ });
516
+
517
+ // await waitFor(1)
518
+
519
+ sendPointerEvent({
520
+ container,
521
+ identifier: 2,
522
+ type: "pointerdown",
523
+ x: 10,
524
+ y: 10,
525
+ });
526
+ sendPointerEvent({
527
+ container,
528
+ identifier: 2,
529
+ type: "pointerup",
530
+ x: 10,
531
+ y: 10,
532
+ });
533
+ },
534
+ }),
535
+ buffer(waitLongEnough$),
536
+ first(),
537
+ ),
538
+ );
539
+
540
+ expect(values.length).toBe(0);
541
+ });
542
+ });
@@ -0,0 +1,143 @@
1
+ import {
2
+ Observable,
3
+ buffer,
4
+ debounceTime,
5
+ exhaustMap,
6
+ filter,
7
+ first,
8
+ ignoreElements,
9
+ map,
10
+ merge,
11
+ of,
12
+ share,
13
+ shareReplay,
14
+ switchMap,
15
+ takeUntil,
16
+ timer,
17
+ withLatestFrom,
18
+ } from "rxjs";
19
+ import {
20
+ fromFailWith,
21
+ fromPointerDown,
22
+ getPointerEvents,
23
+ hasAtLeastOneItem,
24
+ isOUtsidePosThreshold,
25
+ trackActivePointers,
26
+ } from "./utils";
27
+ import { GestureEvent } from "./types";
28
+
29
+ export type TapEvent = GestureEvent & { type: "tap"; taps: number };
30
+
31
+ type Options = {
32
+ // Maximum time in ms between multiple taps.
33
+ multiTapThreshold?: number;
34
+ // Maximum press time in ms.
35
+ maximumPressTime?: number;
36
+ maxTaps?: number;
37
+ posThreshold?: number;
38
+ failWith?: { start$: Observable<unknown> }[];
39
+ };
40
+
41
+ const mapToTapEvent = (
42
+ stream: Observable<[PointerEvent, ...PointerEvent[]]>,
43
+ ): Observable<TapEvent> =>
44
+ stream.pipe(
45
+ map((events) => ({
46
+ type: "tap",
47
+ srcEvent: events[0],
48
+ taps: events.length,
49
+ })),
50
+ );
51
+
52
+ export const createTapRecognizer = ({
53
+ multiTapThreshold = 200,
54
+ maximumPressTime = 250,
55
+ maxTaps = 1,
56
+ posThreshold = 5,
57
+ failWith,
58
+ }: Options = {}) => {
59
+ return ({
60
+ container,
61
+ afterEventReceived = (event) => event,
62
+ }: {
63
+ container: HTMLElement;
64
+ afterEventReceived?: (event: PointerEvent) => PointerEvent;
65
+ }) => {
66
+ const { pointerUp$, pointerLeave$, pointerCancel$, pointerMove$ } =
67
+ getPointerEvents({
68
+ container,
69
+ afterEventReceived,
70
+ });
71
+
72
+ const pointerDown$ = fromPointerDown({ container, afterEventReceived });
73
+ const activePointers$ = trackActivePointers({ container }).pipe(
74
+ shareReplay(1),
75
+ );
76
+ const hasMoreThanOneActivePointer$ = activePointers$.pipe(
77
+ filter((pointers) => pointers.length > 1),
78
+ );
79
+
80
+ const onlyOnePointer = <T>(stream: Observable<T>) =>
81
+ stream.pipe(
82
+ withLatestFrom(activePointers$),
83
+ filter(([, pointers]) => pointers.length === 1),
84
+ map(([pointer]) => pointer),
85
+ );
86
+
87
+ const bufferPointerDowns = (stream: Observable<PointerEvent>) =>
88
+ stream.pipe(
89
+ buffer(pointerUp$.pipe(debounceTime(multiTapThreshold))),
90
+ first(),
91
+ );
92
+
93
+ const failWith$ = fromFailWith(failWith);
94
+
95
+ const tap$ = merge(
96
+ pointerDown$,
97
+ activePointers$.pipe(ignoreElements()),
98
+ ).pipe(
99
+ onlyOnePointer,
100
+ exhaustMap((initialPointerEvent) => {
101
+ const pointerDowns$ = merge(of(initialPointerEvent), pointerDown$);
102
+ const subsequentPointerEvents$ = merge(
103
+ pointerUp$,
104
+ pointerLeave$,
105
+ pointerCancel$,
106
+ pointerMove$,
107
+ );
108
+ const pointerDownsBuffered$ = pointerDowns$.pipe(bufferPointerDowns);
109
+ const subsequentPointersOutOfPositionThreshold$ =
110
+ subsequentPointerEvents$.pipe(
111
+ filter((event) =>
112
+ isOUtsidePosThreshold(initialPointerEvent, event, posThreshold),
113
+ ),
114
+ first(),
115
+ );
116
+ const waitedTooLong$ = activePointers$.pipe(
117
+ switchMap(() => timer(maximumPressTime)),
118
+ );
119
+
120
+ const takeUntil$ = merge(
121
+ failWith$,
122
+ pointerCancel$,
123
+ pointerLeave$,
124
+ hasMoreThanOneActivePointer$,
125
+ subsequentPointersOutOfPositionThreshold$,
126
+ waitedTooLong$,
127
+ );
128
+
129
+ const tap$ = pointerDownsBuffered$.pipe(
130
+ filter(hasAtLeastOneItem),
131
+ filter((events) => events.length <= maxTaps),
132
+ mapToTapEvent,
133
+ takeUntil(takeUntil$),
134
+ );
135
+
136
+ return tap$;
137
+ }),
138
+ share(),
139
+ );
140
+
141
+ return { events$: tap$ };
142
+ };
143
+ };
@@ -0,0 +1,3 @@
1
+ export type GestureEvent = {
2
+ srcEvent: PointerEvent;
3
+ };
@@ -0,0 +1,211 @@
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
+
16
+ export function isDefined<T>(
17
+ arg: T | null | undefined,
18
+ ): arg is T extends null | undefined ? never : T {
19
+ return arg !== null && arg !== undefined;
20
+ }
21
+
22
+ export const hasAtLeastOneItem = <T>(events: T[]): events is [T, ...T[]] =>
23
+ events.length > 0;
24
+
25
+ export const getCenterFromEvent = (
26
+ event: MouseEvent | TouchEvent | PointerEvent,
27
+ ) => ({
28
+ x:
29
+ "changedTouches" in event
30
+ ? event.changedTouches[0]?.pageX ?? 0
31
+ : event.clientX,
32
+ y:
33
+ "changedTouches" in event
34
+ ? event.changedTouches[0]?.pageY ?? 0
35
+ : event.clientY,
36
+ });
37
+
38
+ export function isOUtsidePosThreshold(
39
+ startEvent: PointerEvent,
40
+ endEvent: PointerEvent,
41
+ posThreshold: number,
42
+ ) {
43
+ const start = getCenterFromEvent(startEvent);
44
+ const end = getCenterFromEvent(endEvent);
45
+
46
+ // Determines if the movement qualifies as a drag
47
+ return (
48
+ Math.abs(end.x - start.x) > posThreshold ||
49
+ Math.abs(end.y - start.y) > posThreshold
50
+ );
51
+ }
52
+
53
+ export function calculateDistance(pointA: PointerEvent, pointB: PointerEvent) {
54
+ const dx = pointA.clientX - pointB.clientX;
55
+ const dy = pointA.clientY - pointB.clientY;
56
+
57
+ return Math.sqrt(dx * dx + dy * dy);
58
+ }
59
+
60
+ export const fromFailWith = (failWith?: { start$: Observable<unknown> }[]) =>
61
+ !failWith?.length
62
+ ? NEVER
63
+ : merge(...(failWith?.map(({ start$ }) => start$) ?? []));
64
+
65
+ export function matchPointer(pointer: PointerEvent) {
66
+ return (stream: Observable<PointerEvent>) =>
67
+ stream.pipe(
68
+ filter((newPointer) => newPointer.pointerId === pointer.pointerId),
69
+ );
70
+ }
71
+
72
+ export const getPointerEvents = ({
73
+ container,
74
+ afterEventReceived = (event) => event,
75
+ }: {
76
+ container: HTMLElement;
77
+ afterEventReceived?: (event: PointerEvent) => PointerEvent;
78
+ }) => {
79
+ const pointerDown$ = fromPointerDown({ afterEventReceived, container });
80
+ const pointerUp$ = fromEvent<PointerEvent>(container, "pointerup").pipe(
81
+ map(afterEventReceived),
82
+ );
83
+ const pointerMove$ = fromEvent<PointerEvent>(container, "pointermove").pipe(
84
+ map(afterEventReceived),
85
+ );
86
+ /**
87
+ * Mostly used to handle cursor leaving window on mouse pointers.
88
+ */
89
+ const pointerLeave$ = fromEvent<PointerEvent>(container, "pointerleave").pipe(
90
+ map(afterEventReceived),
91
+ );
92
+ /**
93
+ * Handle things such as contextual menu popup or
94
+ * native swipe navigation conflict.
95
+ */
96
+ const pointerCancel$ = fromEvent<PointerEvent>(
97
+ container,
98
+ "pointercancel",
99
+ ).pipe(map(afterEventReceived));
100
+
101
+ return {
102
+ pointerDown$,
103
+ pointerCancel$,
104
+ pointerLeave$,
105
+ pointerUp$,
106
+ pointerMove$,
107
+ };
108
+ };
109
+
110
+ export const fromPointerDown = ({
111
+ container,
112
+ afterEventReceived = (event) => event,
113
+ }: {
114
+ container: HTMLElement;
115
+ afterEventReceived?: (event: PointerEvent) => PointerEvent;
116
+ }) =>
117
+ fromEvent<PointerEvent>(container, "pointerdown").pipe(
118
+ map(afterEventReceived),
119
+ );
120
+
121
+ export const enrichEvent = ({
122
+ event,
123
+ startTime,
124
+ startEvent,
125
+ }: {
126
+ event: PointerEvent;
127
+ startTime: number;
128
+ startEvent: PointerEvent;
129
+ }) => {
130
+ const startX = startEvent.clientX;
131
+ const startY = startEvent.clientY;
132
+ const deltaX = event.clientX - startX;
133
+ const deltaY = event.clientY - startY;
134
+
135
+ // Calculate the change in time
136
+ const dt = Date.now() - startTime;
137
+
138
+ // Avoid division by zero
139
+ // Calculate velocity in pixels per second
140
+ const velocityX = dt > 0 ? deltaX / dt : 0;
141
+ const velocityY = dt > 0 ? deltaY / dt : 0;
142
+
143
+ return {
144
+ srcEvent: event,
145
+ deltaX,
146
+ deltaY,
147
+ velocityX,
148
+ velocityY,
149
+ delay: new Date().getTime() - startTime,
150
+ center: {
151
+ x: event.x,
152
+ y: event.y,
153
+ },
154
+ };
155
+ };
156
+
157
+ /**
158
+ * @todo test the strict stability
159
+ * @important
160
+ * Stable function, will not emit if the pointers don't change
161
+ */
162
+ export const trackActivePointers = ({
163
+ container,
164
+ }: {
165
+ container: HTMLElement;
166
+ }) => {
167
+ const pointerUp$ = fromEvent<PointerEvent>(container, "pointerup");
168
+ // const pointerMove$ = fromEvent<PointerEvent>(container, "pointermove")
169
+ const pointerLeave$ = fromEvent<PointerEvent>(container, "pointerleave");
170
+ const pointerCancel$ = fromEvent<PointerEvent>(container, "pointercancel");
171
+
172
+ return fromEvent<PointerEvent>(container, "pointerdown").pipe(
173
+ mergeMap((pointerDown) => {
174
+ const pointerDownRelease$ = merge(
175
+ pointerUp$,
176
+ pointerLeave$,
177
+ pointerCancel$,
178
+ ).pipe(matchPointer(pointerDown));
179
+ const pointerDown$ = defer(() => of(pointerDown));
180
+ // const currentPointerMove$ = pointerMove$.pipe(
181
+ // filter((pointerMoveEvent) => matchPointer(pointerMoveEvent, pointerDown)),
182
+ // throttleTime(100, undefined, { trailing: true }),
183
+ // )
184
+
185
+ return pointerDown$.pipe(
186
+ // mergeMap(() => merge(pointerDown$, currentPointerMove$)),
187
+ mergeMap(() => merge(pointerDown$, NEVER)),
188
+ map((event) => ({ id: event.pointerId, event })),
189
+ takeUntil(pointerDownRelease$),
190
+ endWith({ id: pointerDown.pointerId, event: undefined }),
191
+ );
192
+ }),
193
+ scan(
194
+ (acc, { event, id }) => {
195
+ if (!event) {
196
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
197
+ const { [id]: _deleted, ...rest } = acc;
198
+
199
+ return rest;
200
+ }
201
+
202
+ return {
203
+ ...acc,
204
+ [id]: event,
205
+ };
206
+ },
207
+ {} as Record<number, PointerEvent | undefined>,
208
+ ),
209
+ map((events) => Object.values(events).filter(isDefined)),
210
+ );
211
+ };
package/src/main.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { createManager } from "./core/manager";
2
+ import { createTapRecognizer } from "./core/tapRecognizer";
3
+ import "./style.css";
4
+
5
+ const container = document.querySelector<HTMLDivElement>("#app")!;
6
+
7
+ const tapRecognizer = createTapRecognizer({ maxTaps: 3 });
8
+
9
+ const manager = createManager({
10
+ container,
11
+ recognizers: [tapRecognizer],
12
+ });
13
+
14
+ manager.events$.subscribe(console.warn);
15
+
16
+ container.innerHTML = `
17
+ <div class="container">
18
+ </div>
19
+ `;
package/src/style.css ADDED
@@ -0,0 +1,29 @@
1
+ :root {
2
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.5;
4
+ font-weight: 400;
5
+
6
+ color-scheme: light dark;
7
+ color: rgba(255, 255, 255, 0.87);
8
+ background-color: #242424;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ body {
17
+ margin: 0;
18
+ display: flex;
19
+ min-width: 100vw;
20
+ min-height: 100vh;
21
+ }
22
+
23
+ #app {
24
+ display: flex;
25
+ padding: 2rem;
26
+ justify-content: center;
27
+ align-items: center;
28
+ flex: 1;
29
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
package/tsconfig.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true
21
+ },
22
+ "include": ["src"]
23
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { defineConfig } from "vite";
2
+ import dts from "vite-plugin-dts";
3
+ import { resolve } from "path";
4
+ import { name } from "../prose-reader/packages/enhancer-gesture/package.json";
5
+ import externals from "rollup-plugin-node-externals";
6
+
7
+ const libName = name.replace(`@`, ``).replace(`/`, `-`);
8
+
9
+ export default defineConfig(({ mode }) => ({
10
+ build: {
11
+ minify: false,
12
+ lib: {
13
+ entry: resolve(__dirname, `src/index.ts`),
14
+ name: libName,
15
+ fileName: "index",
16
+ },
17
+ emptyOutDir: mode !== `development`,
18
+ sourcemap: true,
19
+ },
20
+ plugins: [
21
+ {
22
+ enforce: `pre`,
23
+ ...externals({
24
+ peerDeps: true,
25
+ deps: true,
26
+ devDeps: true,
27
+ }),
28
+ },
29
+ dts({
30
+ entryRoot: "src",
31
+ }),
32
+ ],
33
+ }));
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig(() => ({
4
+ test: {
5
+ environment: "happy-dom",
6
+ coverage: {},
7
+ },
8
+ }));