@sigx/lynx-gestures 0.1.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/LICENSE +21 -0
- package/README.md +358 -0
- package/dist/components/Draggable.d.ts +66 -0
- package/dist/components/Draggable.d.ts.map +1 -0
- package/dist/components/Pressable.d.ts +40 -0
- package/dist/components/Pressable.d.ts.map +1 -0
- package/dist/components/ScrollView.d.ts +46 -0
- package/dist/components/ScrollView.d.ts.map +1 -0
- package/dist/components/Swipeable.d.ts +40 -0
- package/dist/components/Swipeable.d.ts.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +401 -0
- package/dist/index.js.map +1 -0
- package/dist/scroll-context.d.ts +65 -0
- package/dist/scroll-context.d.ts.map +1 -0
- package/dist/types.d.ts +53 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/use-pinch.d.ts +14 -0
- package/dist/use-pinch.d.ts.map +1 -0
- package/dist/use-rotation.d.ts +13 -0
- package/dist/use-rotation.d.ts.map +1 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +54 -0
- package/src/components/Draggable.tsx +450 -0
- package/src/components/Pressable.tsx +175 -0
- package/src/components/ScrollView.tsx +123 -0
- package/src/components/Swipeable.tsx +220 -0
- package/src/index.ts +61 -0
- package/src/scroll-context.ts +72 -0
- package/src/types.ts +87 -0
- package/src/use-pinch.ts +106 -0
- package/src/use-rotation.ts +129 -0
- package/src/utils.ts +26 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Signal } from '@sigx/lynx';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Touch event types (platform-agnostic, matches Lynx shape)
|
|
5
|
+
//
|
|
6
|
+
// Used by `usePinch` / `useRotation` — multi-touch JS-only fallbacks. The
|
|
7
|
+
// rest of the gesture surface (Tap, LongPress, Pan, Fling, Swipe) is
|
|
8
|
+
// arena-driven via `Gesture.*` + `useGestureDetector`; the legacy hooks
|
|
9
|
+
// were deleted in Phase 2.12.4.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export interface TouchPoint {
|
|
13
|
+
identifier: number;
|
|
14
|
+
x: number;
|
|
15
|
+
y: number;
|
|
16
|
+
pageX: number;
|
|
17
|
+
pageY: number;
|
|
18
|
+
clientX: number;
|
|
19
|
+
clientY: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TouchEvent {
|
|
23
|
+
touches: TouchPoint[];
|
|
24
|
+
changedTouches: TouchPoint[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Gesture phase
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export type GesturePhase = 'idle' | 'began' | 'active' | 'ended' | 'cancelled';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Bind-prop handler bag (spread onto an element's main-thread-bindtouch* attrs)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
export interface GestureHandlers {
|
|
38
|
+
bindtouchstart?: (e: TouchEvent) => void;
|
|
39
|
+
bindtouchmove?: (e: TouchEvent) => void;
|
|
40
|
+
bindtouchend?: (e: TouchEvent) => void;
|
|
41
|
+
bindtouchcancel?: (e: TouchEvent) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Pinch
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export interface PinchState {
|
|
49
|
+
phase: GesturePhase;
|
|
50
|
+
scale: number;
|
|
51
|
+
focalX: number;
|
|
52
|
+
focalY: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UsePinchOptions {
|
|
56
|
+
onPinch?: (state: PinchState) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UsePinchReturn {
|
|
60
|
+
state: Signal<PinchState>;
|
|
61
|
+
handlers: GestureHandlers;
|
|
62
|
+
reset: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Rotation
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export interface RotationState {
|
|
70
|
+
phase: GesturePhase;
|
|
71
|
+
/** Cumulative rotation in radians since gesture start (signed). */
|
|
72
|
+
rotation: number;
|
|
73
|
+
/** Angular velocity in radians/ms. */
|
|
74
|
+
velocity: number;
|
|
75
|
+
focalX: number;
|
|
76
|
+
focalY: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface UseRotationOptions {
|
|
80
|
+
onRotation?: (state: RotationState) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface UseRotationReturn {
|
|
84
|
+
state: Signal<RotationState>;
|
|
85
|
+
handlers: GestureHandlers;
|
|
86
|
+
reset: () => void;
|
|
87
|
+
}
|
package/src/use-pinch.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { signal } from '@sigx/lynx';
|
|
2
|
+
import type { UsePinchOptions, UsePinchReturn, TouchEvent, PinchState, TouchPoint } from './types.js';
|
|
3
|
+
import { distance, midpoint } from './utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Two-finger pinch/zoom gesture.
|
|
7
|
+
*
|
|
8
|
+
* Tracks fingers manually since Lynx fires separate touchstart events per
|
|
9
|
+
* finger (each with touches.length=1). Uses proximity-based matching on
|
|
10
|
+
* touchmove since Lynx identifiers are unreliable across events.
|
|
11
|
+
*
|
|
12
|
+
* NOTE: Requires a device/environment that delivers multi-touch events to
|
|
13
|
+
* the same element. Some Lynx hosts (e.g. Lynx Explorer on emulator) may
|
|
14
|
+
* not support this — test on a physical device.
|
|
15
|
+
*/
|
|
16
|
+
export function usePinch(options: UsePinchOptions = {}): UsePinchReturn {
|
|
17
|
+
const { onPinch } = options;
|
|
18
|
+
|
|
19
|
+
const state = signal<PinchState>({
|
|
20
|
+
phase: 'idle',
|
|
21
|
+
scale: 1,
|
|
22
|
+
focalX: 0,
|
|
23
|
+
focalY: 0,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
let baseDistance = 0;
|
|
27
|
+
let active = false;
|
|
28
|
+
let finger1: TouchPoint | null = null;
|
|
29
|
+
let finger2: TouchPoint | null = null;
|
|
30
|
+
|
|
31
|
+
function onTouchStart(e: TouchEvent): void {
|
|
32
|
+
const t = e.touches[0];
|
|
33
|
+
if (!t) return;
|
|
34
|
+
|
|
35
|
+
if (!finger1) {
|
|
36
|
+
finger1 = { ...t };
|
|
37
|
+
} else if (!finger2) {
|
|
38
|
+
finger2 = { ...t };
|
|
39
|
+
active = true;
|
|
40
|
+
baseDistance = distance(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
41
|
+
const [fx, fy] = midpoint(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
42
|
+
state.phase = 'began';
|
|
43
|
+
state.scale = 1;
|
|
44
|
+
state.focalX = fx;
|
|
45
|
+
state.focalY = fy;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function onTouchMove(e: TouchEvent): void {
|
|
50
|
+
if (!active || !finger1 || !finger2) return;
|
|
51
|
+
|
|
52
|
+
const t = e.changedTouches[0];
|
|
53
|
+
if (!t) return;
|
|
54
|
+
|
|
55
|
+
// Determine which finger moved by proximity
|
|
56
|
+
const dist1 = distance(t.pageX, t.pageY, finger1.pageX, finger1.pageY);
|
|
57
|
+
const dist2 = distance(t.pageX, t.pageY, finger2.pageX, finger2.pageY);
|
|
58
|
+
|
|
59
|
+
if (dist1 < dist2) {
|
|
60
|
+
finger1 = { ...t };
|
|
61
|
+
} else {
|
|
62
|
+
finger2 = { ...t };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const currentDist = distance(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
66
|
+
const scale = baseDistance > 0 ? currentDist / baseDistance : 1;
|
|
67
|
+
const [fx, fy] = midpoint(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
68
|
+
|
|
69
|
+
state.phase = 'active';
|
|
70
|
+
state.scale = scale;
|
|
71
|
+
state.focalX = fx;
|
|
72
|
+
state.focalY = fy;
|
|
73
|
+
onPinch?.(state as PinchState);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function onTouchEnd(): void {
|
|
77
|
+
if (active) {
|
|
78
|
+
state.phase = 'ended';
|
|
79
|
+
onPinch?.(state as PinchState);
|
|
80
|
+
}
|
|
81
|
+
active = false;
|
|
82
|
+
finger1 = null;
|
|
83
|
+
finger2 = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function reset(): void {
|
|
87
|
+
active = false;
|
|
88
|
+
finger1 = null;
|
|
89
|
+
finger2 = null;
|
|
90
|
+
state.phase = 'idle';
|
|
91
|
+
state.scale = 1;
|
|
92
|
+
state.focalX = 0;
|
|
93
|
+
state.focalY = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
state,
|
|
98
|
+
handlers: {
|
|
99
|
+
bindtouchstart: onTouchStart,
|
|
100
|
+
bindtouchmove: onTouchMove,
|
|
101
|
+
bindtouchend: onTouchEnd,
|
|
102
|
+
bindtouchcancel: onTouchEnd,
|
|
103
|
+
},
|
|
104
|
+
reset,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { signal } from '@sigx/lynx';
|
|
2
|
+
import type {
|
|
3
|
+
UseRotationOptions,
|
|
4
|
+
UseRotationReturn,
|
|
5
|
+
TouchEvent,
|
|
6
|
+
TouchPoint,
|
|
7
|
+
RotationState,
|
|
8
|
+
} from './types.js';
|
|
9
|
+
import { angle, angleDelta, distance, midpoint } from './utils.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two-finger rotation gesture.
|
|
13
|
+
*
|
|
14
|
+
* Tracks the angle of the line between two fingers; reports cumulative rotation
|
|
15
|
+
* in radians from gesture start. Like usePinch, uses proximity-based finger
|
|
16
|
+
* matching on touchmove (Lynx touch identifiers are not stable across events).
|
|
17
|
+
*
|
|
18
|
+
* NOTE: requires multi-touch delivery to the same element. Some Lynx hosts
|
|
19
|
+
* (Lynx Explorer on emulator) may not support this — test on a physical device.
|
|
20
|
+
*/
|
|
21
|
+
export function useRotation(options: UseRotationOptions = {}): UseRotationReturn {
|
|
22
|
+
const { onRotation } = options;
|
|
23
|
+
|
|
24
|
+
const state = signal<RotationState>({
|
|
25
|
+
phase: 'idle',
|
|
26
|
+
rotation: 0,
|
|
27
|
+
velocity: 0,
|
|
28
|
+
focalX: 0,
|
|
29
|
+
focalY: 0,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
let baseAngle = 0;
|
|
33
|
+
let prevAngle = 0;
|
|
34
|
+
let prevTime = 0;
|
|
35
|
+
let active = false;
|
|
36
|
+
let finger1: TouchPoint | null = null;
|
|
37
|
+
let finger2: TouchPoint | null = null;
|
|
38
|
+
|
|
39
|
+
function onTouchStart(e: TouchEvent): void {
|
|
40
|
+
const t = e.touches[0];
|
|
41
|
+
if (!t) return;
|
|
42
|
+
|
|
43
|
+
if (!finger1) {
|
|
44
|
+
finger1 = { ...t };
|
|
45
|
+
} else if (!finger2) {
|
|
46
|
+
finger2 = { ...t };
|
|
47
|
+
active = true;
|
|
48
|
+
baseAngle = angle(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
49
|
+
prevAngle = baseAngle;
|
|
50
|
+
prevTime = Date.now();
|
|
51
|
+
const [fx, fy] = midpoint(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
52
|
+
state.phase = 'began';
|
|
53
|
+
state.rotation = 0;
|
|
54
|
+
state.velocity = 0;
|
|
55
|
+
state.focalX = fx;
|
|
56
|
+
state.focalY = fy;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function onTouchMove(e: TouchEvent): void {
|
|
61
|
+
if (!active || !finger1 || !finger2) return;
|
|
62
|
+
const t = e.changedTouches[0];
|
|
63
|
+
if (!t) return;
|
|
64
|
+
|
|
65
|
+
const dist1 = distance(t.pageX, t.pageY, finger1.pageX, finger1.pageY);
|
|
66
|
+
const dist2 = distance(t.pageX, t.pageY, finger2.pageX, finger2.pageY);
|
|
67
|
+
if (dist1 < dist2) {
|
|
68
|
+
finger1 = { ...t };
|
|
69
|
+
} else {
|
|
70
|
+
finger2 = { ...t };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const dt = Math.max(now - prevTime, 1);
|
|
75
|
+
const currentAngle = angle(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
76
|
+
const rotation = angleDelta(baseAngle, currentAngle);
|
|
77
|
+
const velocity = angleDelta(prevAngle, currentAngle) / dt;
|
|
78
|
+
const [fx, fy] = midpoint(finger1.pageX, finger1.pageY, finger2.pageX, finger2.pageY);
|
|
79
|
+
|
|
80
|
+
prevAngle = currentAngle;
|
|
81
|
+
prevTime = now;
|
|
82
|
+
|
|
83
|
+
state.phase = 'active';
|
|
84
|
+
state.rotation = rotation;
|
|
85
|
+
state.velocity = velocity;
|
|
86
|
+
state.focalX = fx;
|
|
87
|
+
state.focalY = fy;
|
|
88
|
+
onRotation?.(state as RotationState);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function onTouchEnd(): void {
|
|
92
|
+
if (active) {
|
|
93
|
+
state.phase = 'ended';
|
|
94
|
+
onRotation?.(state as RotationState);
|
|
95
|
+
}
|
|
96
|
+
active = false;
|
|
97
|
+
finger1 = null;
|
|
98
|
+
finger2 = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function onTouchCancel(): void {
|
|
102
|
+
if (active) state.phase = 'cancelled';
|
|
103
|
+
active = false;
|
|
104
|
+
finger1 = null;
|
|
105
|
+
finger2 = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reset(): void {
|
|
109
|
+
active = false;
|
|
110
|
+
finger1 = null;
|
|
111
|
+
finger2 = null;
|
|
112
|
+
state.phase = 'idle';
|
|
113
|
+
state.rotation = 0;
|
|
114
|
+
state.velocity = 0;
|
|
115
|
+
state.focalX = 0;
|
|
116
|
+
state.focalY = 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
state,
|
|
121
|
+
handlers: {
|
|
122
|
+
bindtouchstart: onTouchStart,
|
|
123
|
+
bindtouchmove: onTouchMove,
|
|
124
|
+
bindtouchend: onTouchEnd,
|
|
125
|
+
bindtouchcancel: onTouchCancel,
|
|
126
|
+
},
|
|
127
|
+
reset,
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Geometry helpers used by the multi-touch JS-only fallback hooks
|
|
2
|
+
// (`usePinch`, `useRotation`). The arena-driven gesture surface
|
|
3
|
+
// (`Gesture.*` + `useGestureDetector`) computes its own deltas natively;
|
|
4
|
+
// this file is only relevant while the platform's pinch/rotation handlers
|
|
5
|
+
// are unfinished.
|
|
6
|
+
|
|
7
|
+
export function distance(x1: number, y1: number, x2: number, y2: number): number {
|
|
8
|
+
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function midpoint(x1: number, y1: number, x2: number, y2: number): [number, number] {
|
|
12
|
+
return [(x1 + x2) / 2, (y1 + y2) / 2];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Signed angle in radians from p1 to p2, range (-π, π]. */
|
|
16
|
+
export function angle(x1: number, y1: number, x2: number, y2: number): number {
|
|
17
|
+
return Math.atan2(y2 - y1, x2 - x1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Shortest signed angular delta between two radians, range (-π, π]. */
|
|
21
|
+
export function angleDelta(from: number, to: number): number {
|
|
22
|
+
let d = to - from;
|
|
23
|
+
while (d > Math.PI) d -= 2 * Math.PI;
|
|
24
|
+
while (d <= -Math.PI) d += 2 * Math.PI;
|
|
25
|
+
return d;
|
|
26
|
+
}
|