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.
package/.prettierrc.json CHANGED
@@ -1 +1,3 @@
1
- {}
1
+ {
2
+ "semi": false
3
+ }
package/eslint.config.js CHANGED
@@ -1,9 +1,18 @@
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
- );
1
+ module.exports = {
2
+ root: true,
3
+ env: { browser: true, es2020: true },
4
+ extends: [
5
+ "eslint:recommended",
6
+ "plugin:@typescript-eslint/recommended",
7
+ "plugin:react-hooks/recommended",
8
+ ],
9
+ ignorePatterns: ["dist", ".eslintrc.cjs"],
10
+ parser: "@typescript-eslint/parser",
11
+ plugins: ["react-refresh"],
12
+ rules: {
13
+ "react-refresh/only-export-components": [
14
+ "warn",
15
+ { allowConstantExport: true },
16
+ ],
17
+ },
18
+ }
package/index.html CHANGED
@@ -7,7 +7,7 @@
7
7
  <title>gesturx</title>
8
8
  </head>
9
9
  <body>
10
- <div id="app"></div>
11
- <script type="module" src="/src/main.ts"></script>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
12
  </body>
13
13
  </html>
package/package.json CHANGED
@@ -1,24 +1,37 @@
1
1
  {
2
2
  "name": "gesturx",
3
3
  "private": false,
4
- "version": "0.0.0",
4
+ "version": "1.0.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
8
8
  "build": "tsc && vite build",
9
+ "build:lib": "tsc && cross-env LIB_MODE=true vite build",
9
10
  "preview": "vite preview",
10
- "lint": "prettier --write . && eslint --fix .",
11
+ "lint": "prettier --write . && eslint --fix . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
11
12
  "test": "vitest",
12
13
  "coverage": "vitest run --coverage"
13
14
  },
14
15
  "devDependencies": {
16
+ "@chakra-ui/icons": "^2.1.1",
17
+ "@chakra-ui/react": "^2.8.2",
18
+ "@emotion/react": "^11.11.4",
19
+ "@emotion/styled": "^11.11.5",
15
20
  "@eslint/js": "^9.0.0",
16
21
  "@types/node": "^20.12.7",
22
+ "@types/react": "^18.3.2",
23
+ "@types/react-dom": "^18.3.0",
24
+ "@vitejs/plugin-react": "^4.3.0",
17
25
  "@vitest/coverage-v8": "^1.5.0",
26
+ "cross-env": "^7.0.3",
18
27
  "eslint": "^8.57.0",
28
+ "framer-motion": "^11.2.6",
19
29
  "happy-dom": "^14.7.1",
20
30
  "prettier": "3.2.5",
31
+ "react": "^18.3.1",
32
+ "react-dom": "^18.3.1",
21
33
  "rollup-plugin-node-externals": "^7.1.1",
34
+ "rxjs": "^7.8.1",
22
35
  "typescript": "^5.4.5",
23
36
  "typescript-eslint": "^7.7.0",
24
37
  "vite": "^5.2.0",
@@ -26,7 +39,7 @@
26
39
  "vite-plugin-eslint": "^1.8.1",
27
40
  "vitest": "^1.5.0"
28
41
  },
29
- "dependencies": {
42
+ "peerDependencies": {
30
43
  "rxjs": "^7.8.1"
31
44
  }
32
45
  }
@@ -0,0 +1,4 @@
1
+ export { PanRecognizer } from "./pan/PanRecognizer"
2
+ export { SwipeRecognizer } from "./swipe/SwipeRecognizer"
3
+ export { TapRecognizer } from "./tap/TapRecognizer"
4
+ export { createManager } from "./manager"
@@ -1,45 +1,46 @@
1
- import { map, merge } from "rxjs";
2
- import { createTapRecognizer } from "./tapRecognizer";
3
- import { getCenterFromEvent } from "./utils";
1
+ import { merge, share } from "rxjs"
2
+ import { TapRecognizer } from "./tap/TapRecognizer"
3
+ import { PanRecognizer } from "./pan/PanRecognizer"
4
+ import { SwipeRecognizer } from "./swipe/SwipeRecognizer"
5
+ import { RotateRecognizer } from "./rotate/RotateRecognizer"
6
+ import { PinchRecognizer } from "./pinch/PinchRecognizer"
7
+
8
+ export type Manager = ReturnType<typeof createManager>
4
9
 
5
10
  export const createManager = ({
6
- container,
7
11
  afterEventReceived = (event) => event,
8
12
  recognizers,
9
13
  }: {
10
- container: HTMLElement;
11
- recognizers: ReturnType<typeof createTapRecognizer>[];
12
- afterEventReceived?: (event: PointerEvent) => PointerEvent;
14
+ recognizers: (
15
+ | TapRecognizer
16
+ | PanRecognizer
17
+ | SwipeRecognizer
18
+ | RotateRecognizer
19
+ | PinchRecognizer
20
+ )[]
21
+ afterEventReceived?: (event: PointerEvent) => PointerEvent
13
22
  }) => {
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 });
23
+ const events$ = merge(
24
+ ...recognizers.map((recognizer) => recognizer.events$),
25
+ ).pipe(share())
24
26
 
25
- // const recognizers = [
26
- // singleTapRecognizer({ container, afterEventReceived }),
27
- // // pinchRecognizer({ container, afterEventReceived }),
28
- // // dragRecognizer,
29
- // ]
27
+ const initialize = (container: HTMLElement) => {
28
+ /**
29
+ * We have to disable touch-action otherwise every events will be followed
30
+ * by a cancel event since the browser will try to handle touch.
31
+ * @see https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
32
+ */
33
+ container.style.touchAction = `none`
30
34
 
31
- const recognizerInstances = recognizers.map((recognizer) =>
32
- recognizer({ container, afterEventReceived }),
33
- );
35
+ /**
36
+ * Disables text selection to improve the dragging gesture. Mainly for desktop browsers.
37
+ */
38
+ container.style.userSelect = `none`
34
39
 
35
- const events$ = merge(
36
- ...recognizerInstances.map((recognizer) => recognizer.events$),
37
- ).pipe(
38
- map((event) => ({
39
- ...event,
40
- center: getCenterFromEvent(event.srcEvent),
41
- })),
42
- );
40
+ recognizers.forEach((recognizer) => {
41
+ recognizer.initialize({ container, afterEventReceived })
42
+ })
43
+ }
43
44
 
44
- return { events$ };
45
- };
45
+ return { events$, initialize, recognizers }
46
+ }
@@ -0,0 +1,192 @@
1
+ import {
2
+ Observable,
3
+ combineLatest,
4
+ exhaustMap,
5
+ filter,
6
+ first,
7
+ map,
8
+ merge,
9
+ mergeMap,
10
+ of,
11
+ share,
12
+ shareReplay,
13
+ skipUntil,
14
+ switchMap,
15
+ takeUntil,
16
+ withLatestFrom,
17
+ } from "rxjs"
18
+ import { isOutsidePosThreshold } from "../utils/utils"
19
+ import { Recognizer } from "../recognizer/Recognizer"
20
+ import { RecognizerEvent } from "../recognizer/RecognizerEvent"
21
+ import { mapToRecognizerEvent } from "../recognizer/mapToRecognizerEvent"
22
+ import { getPointerEvents, matchPointer, trackFingers } from "../utils/events"
23
+
24
+ export interface PanEvent extends RecognizerEvent {
25
+ type: "panStart" | "panMove" | "panEnd"
26
+ }
27
+
28
+ type Options = {
29
+ posThreshold?: number
30
+ }
31
+
32
+ export class PanRecognizer extends Recognizer<Options> {
33
+ public events$: Observable<PanEvent>
34
+ public start$: Observable<PanEvent>
35
+ public end$: Observable<PanEvent>
36
+ public fingers$: Observable<number>
37
+
38
+ constructor(protected options: Options = {}) {
39
+ super(options)
40
+
41
+ this.events$ = this.validConfig$.pipe(
42
+ switchMap((config) => {
43
+ const { container, afterEventReceived } = config
44
+ const { posThreshold = 15 } = config.options ?? {}
45
+ const {
46
+ pointerCancel$,
47
+ pointerDown$,
48
+ pointerLeave$,
49
+ pointerMove$,
50
+ pointerUp$,
51
+ } = getPointerEvents({
52
+ container,
53
+ afterEventReceived,
54
+ })
55
+
56
+ return pointerDown$.pipe(
57
+ exhaustMap((initialPointerDownEvent) => {
58
+ const pointerDowns$ = merge(
59
+ of(initialPointerDownEvent),
60
+ pointerDown$,
61
+ )
62
+
63
+ const pointerUpdate$ = merge(
64
+ pointerCancel$,
65
+ pointerDown$,
66
+ pointerLeave$,
67
+ pointerMove$,
68
+ pointerUp$,
69
+ )
70
+
71
+ const trackFingers$ = pointerDowns$.pipe(
72
+ trackFingers({
73
+ pointerCancel$,
74
+ pointerLeave$,
75
+ pointerUp$,
76
+ pointerMove$,
77
+ trackMove: true,
78
+ }),
79
+ shareReplay(),
80
+ )
81
+
82
+ /**
83
+ * We release when we detect a pointer leave and there are no more
84
+ * active fingers.
85
+ *
86
+ * @todo move to utils
87
+ */
88
+ const panReleased$ = merge(
89
+ pointerUp$,
90
+ pointerLeave$,
91
+ pointerCancel$,
92
+ ).pipe(
93
+ withLatestFrom(trackFingers$),
94
+ filter(([_, pointers]) => !pointers.length),
95
+ map(([event]) => event),
96
+ first(),
97
+ share(),
98
+ )
99
+
100
+ const firstPointerDownMovingOutOfThreshold$ = pointerDowns$.pipe(
101
+ mergeMap((pointerDown) =>
102
+ pointerMove$.pipe(
103
+ matchPointer(pointerDown),
104
+ // we start pan only if the user started moving a certain distance
105
+ filter((pointerMoveEvent) =>
106
+ isOutsidePosThreshold(
107
+ pointerDown,
108
+ pointerMoveEvent,
109
+ posThreshold,
110
+ ),
111
+ ),
112
+ ),
113
+ ),
114
+ first(),
115
+ )
116
+
117
+ const panStart$ = firstPointerDownMovingOutOfThreshold$.pipe(
118
+ map(() => ({
119
+ type: "panStart" as const,
120
+ event: initialPointerDownEvent,
121
+ })),
122
+ share(),
123
+ takeUntil(panReleased$),
124
+ )
125
+
126
+ const panEnd$ = panStart$.pipe(
127
+ mergeMap(() =>
128
+ panReleased$.pipe(
129
+ map((endEvent) => ({
130
+ type: "panEnd" as const,
131
+ event: endEvent,
132
+ })),
133
+ ),
134
+ ),
135
+ )
136
+
137
+ const panUpdate$ = pointerUpdate$.pipe(
138
+ skipUntil(panStart$),
139
+ map((event) => ({
140
+ type: "panMove" as const,
141
+ event,
142
+ })),
143
+ takeUntil(panReleased$),
144
+ )
145
+
146
+ const rawEvent$ = merge(panStart$, panUpdate$, panEnd$).pipe(
147
+ withLatestFrom(trackFingers$),
148
+ map(([event, pointers]) => ({
149
+ ...event,
150
+ latestActivePointers: pointers,
151
+ })),
152
+ share(),
153
+ )
154
+
155
+ return combineLatest([
156
+ rawEvent$.pipe(mapToRecognizerEvent),
157
+ rawEvent$,
158
+ ]).pipe(
159
+ map(([recognizerEvent, { type }]) => ({
160
+ ...recognizerEvent,
161
+ type,
162
+ })),
163
+ )
164
+ }),
165
+ )
166
+ }),
167
+ share(),
168
+ )
169
+
170
+ this.start$ = this.events$.pipe(
171
+ filter((event) => event.type === "panStart"),
172
+ )
173
+ this.end$ = this.events$.pipe(filter((event) => event.type === "panEnd"))
174
+
175
+ this.fingers$ = this.validConfig$.pipe(
176
+ switchMap(({ container, afterEventReceived }) => {
177
+ const pointerEvents = getPointerEvents({
178
+ container,
179
+ afterEventReceived,
180
+ })
181
+
182
+ return pointerEvents.pointerDown$.pipe(
183
+ trackFingers({
184
+ ...pointerEvents,
185
+ trackMove: false,
186
+ }),
187
+ )
188
+ }),
189
+ map((events) => events.length),
190
+ )
191
+ }
192
+ }
@@ -0,0 +1,131 @@
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 { RecognizerEvent } from "../recognizer/RecognizerEvent"
15
+ import { Recognizer } from "../recognizer/Recognizer"
16
+ import { PanEvent, PanRecognizer } from "../pan/PanRecognizer"
17
+
18
+ export interface PinchEvent extends RecognizerEvent {
19
+ type: "pinchStart" | "pinchMove" | "pinchEnd"
20
+ scale: number
21
+ /**
22
+ * Distance between start and current
23
+ */
24
+ distance: number
25
+ /**
26
+ * Delta distance between events
27
+ */
28
+ deltaDistance: number
29
+ /**
30
+ * Delta scale between events
31
+ */
32
+ deltaDistanceScale: number
33
+ }
34
+
35
+ type Options = {
36
+ posThreshold?: number
37
+ }
38
+
39
+ export class PinchRecognizer extends Recognizer<Options> {
40
+ public events$: Observable<PinchEvent>
41
+
42
+ constructor(protected options: Options = {}) {
43
+ super(options)
44
+
45
+ this.events$ = this.config$.pipe(
46
+ switchMap((initializedWith) => {
47
+ const { posThreshold } = initializedWith.options ?? {}
48
+ const panRecognizer = new PanRecognizer({ posThreshold })
49
+
50
+ panRecognizer.initialize(initializedWith)
51
+
52
+ const hasLessThanTwoFinger$ = panRecognizer.events$.pipe(
53
+ filter(({ pointers }) => pointers.length < 2),
54
+ distinctUntilChanged(),
55
+ )
56
+
57
+ const hasMoreThanOneFinger$ = panRecognizer.events$.pipe(
58
+ map((event) => [event, event.pointers.length > 1] as const),
59
+ distinctUntilChanged(
60
+ (
61
+ [_, previousHasMoreThanOneFinger],
62
+ [__, currentHasMoreThanOneFinger],
63
+ ) => previousHasMoreThanOneFinger === currentHasMoreThanOneFinger,
64
+ ),
65
+ filter(([_, hasMoreThanOneFinger]) => hasMoreThanOneFinger),
66
+ )
67
+
68
+ const start$ = hasMoreThanOneFinger$.pipe(
69
+ map(([event]) => ({
70
+ ...event,
71
+ type: "pinchStart" as const,
72
+ scale: 1,
73
+ distance: 0,
74
+ deltaDistance: 0,
75
+ deltaDistanceScale: 1,
76
+ })),
77
+ )
78
+
79
+ const rotate$ = start$.pipe(
80
+ mergeMap((startEvent) => {
81
+ return panRecognizer.events$.pipe(
82
+ scan<PanEvent, PinchEvent, Partial<PinchEvent>>(
83
+ (acc, current) => {
84
+ const previousPointersAverageDistance =
85
+ acc.pointersAverageDistance ??
86
+ current.pointersAverageDistance
87
+
88
+ const scale =
89
+ current.pointersAverageDistance /
90
+ startEvent.pointersAverageDistance
91
+
92
+ return {
93
+ ...acc,
94
+ ...current,
95
+ scale,
96
+ distance:
97
+ current.pointersAverageDistance -
98
+ startEvent.pointersAverageDistance,
99
+ deltaDistance:
100
+ current.pointersAverageDistance -
101
+ previousPointersAverageDistance,
102
+ deltaDistanceScale:
103
+ current.pointersAverageDistance /
104
+ previousPointersAverageDistance,
105
+ type: "pinchMove" as const,
106
+ }
107
+ },
108
+ { scale: 0 },
109
+ ),
110
+ takeUntil(hasLessThanTwoFinger$),
111
+ )
112
+ }),
113
+ )
114
+
115
+ const end$ = start$.pipe(
116
+ mergeMap(() =>
117
+ combineLatest([rotate$, hasLessThanTwoFinger$] as const).pipe(
118
+ first(),
119
+ map(([event]) => ({
120
+ ...event,
121
+ type: "pinchEnd" as const,
122
+ })),
123
+ ),
124
+ ),
125
+ )
126
+
127
+ return merge(start$, rotate$, end$)
128
+ }),
129
+ )
130
+ }
131
+ }
@@ -0,0 +1,54 @@
1
+ import { BehaviorSubject, Observable, filter } from "rxjs"
2
+
3
+ type RecognizerConfig<Options> = {
4
+ container?: HTMLElement
5
+ afterEventReceived?: (event: PointerEvent) => PointerEvent
6
+ options?: Options
7
+ }
8
+
9
+ type ValidRecognizerConfig<Options> = Required<
10
+ Pick<RecognizerConfig<Options>, "container">
11
+ > &
12
+ RecognizerConfig<Options>
13
+
14
+ export interface RecognizerOptions {
15
+ failWith?: { start$: Observable<unknown> }[]
16
+ }
17
+
18
+ export class Recognizer<Options> {
19
+ protected config$ = new BehaviorSubject<RecognizerConfig<Options>>({})
20
+ protected validConfig$ = this.config$.pipe(
21
+ filter(
22
+ (config): config is ValidRecognizerConfig<Options> => !!config.container,
23
+ ),
24
+ )
25
+
26
+ constructor(options: Options) {
27
+ this.update(options)
28
+ }
29
+
30
+ public initialize(config: RecognizerConfig<Options>) {
31
+ const prevConfig = this.config$.getValue()
32
+
33
+ this.config$.next({
34
+ ...prevConfig,
35
+ ...config,
36
+ options: {
37
+ ...prevConfig.options,
38
+ ...config.options,
39
+ } as Options,
40
+ })
41
+ }
42
+
43
+ public update(options: Options) {
44
+ const config = this.config$.getValue()
45
+
46
+ this.config$.next({
47
+ ...config,
48
+ options: {
49
+ ...config.options,
50
+ ...options,
51
+ },
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,39 @@
1
+ export interface RecognizerEvent {
2
+ center: { x: number; y: number }
3
+ event: PointerEvent
4
+ /**
5
+ * Contain the last known pointer event for each active fingers.
6
+ * It should contain a unique list, with n being the number of active fingers.
7
+ */
8
+ pointers: PointerEvent[]
9
+ /**
10
+ * Delay between the user action and the event recognition
11
+ */
12
+ delay: number
13
+ deltaX: number
14
+ deltaY: number
15
+ /**
16
+ * @important
17
+ * Can be negative, 0, positive
18
+ */
19
+ velocityX: number
20
+ /**
21
+ * @important
22
+ * Can be negative, 0, positive
23
+ */
24
+ velocityY: number
25
+ startTime: number
26
+ /**
27
+ * Angle delta (in degrees) between pointers between this event
28
+ * and the previous one.
29
+ *
30
+ * @important
31
+ * - Will only register a delta if the previous event and current event
32
+ * have the same amount of fingers
33
+ */
34
+ deltaPointersAngle: number
35
+ /**
36
+ * Average distance between all pointers
37
+ */
38
+ pointersAverageDistance: number
39
+ }