@rootnative/impulse 0.0.0-alpha.0 → 0.0.0-alpha.1

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +51 -5
  2. package/README.md +206 -11
  3. package/dist/{chunk-5BMRKYVY.js → chunk-2UZTAWUQ.js} +20 -2
  4. package/dist/chunk-BNSFDNLA.js +75 -0
  5. package/dist/chunk-DXXGWG4Q.js +136 -0
  6. package/dist/{chunk-PMR25UCT.js → chunk-HGBCIL6X.js} +1 -1
  7. package/dist/chunk-HI5PHDJY.js +94 -0
  8. package/dist/{chunk-IG5RXCYR.js → chunk-IH7SQ5X6.js} +11 -15
  9. package/dist/chunk-MWCIEVTA.js +199 -0
  10. package/dist/{chunk-F4RHM4ZK.js → chunk-NYDDZD4G.js} +58 -1
  11. package/dist/chunk-PGOQSKEJ.js +144 -0
  12. package/dist/{chunk-FR242SUF.js → chunk-TGOGIZDH.js} +13 -7
  13. package/dist/chunk-VEPUHGPN.js +12 -0
  14. package/dist/chunk-YZHAQ4XK.js +136 -0
  15. package/dist/compose/index.d.ts +1 -1
  16. package/dist/double-tap/index.d.ts +184 -0
  17. package/dist/double-tap/index.js +5 -0
  18. package/dist/drag/index.d.ts +18 -13
  19. package/dist/drag/index.js +3 -3
  20. package/dist/index.d.ts +10 -3
  21. package/dist/index.js +12 -5
  22. package/dist/long-press/index.d.ts +219 -0
  23. package/dist/long-press/index.js +4 -0
  24. package/dist/pan/index.d.ts +221 -0
  25. package/dist/pan/index.js +4 -0
  26. package/dist/pinch/index.d.ts +239 -0
  27. package/dist/pinch/index.js +4 -0
  28. package/dist/raw/index.d.ts +3 -3
  29. package/dist/raw/index.js +2 -2
  30. package/dist/rotate/index.d.ts +263 -0
  31. package/dist/rotate/index.js +4 -0
  32. package/dist/swipe/index.d.ts +254 -0
  33. package/dist/swipe/index.js +4 -0
  34. package/dist/tap/index.d.ts +34 -29
  35. package/dist/tap/index.js +4 -3
  36. package/dist/tapEvent-KSSojt_l.d.ts +28 -0
  37. package/dist/{types-Ch2HM3aP.d.ts → types-ChGKY28a.d.ts} +27 -1
  38. package/dist/{useGestureMemo-Ccv8rB0C.d.ts → useGestureMemo-BRW1EKcJ.d.ts} +1 -1
  39. package/jest-setup.cjs +32 -1
  40. package/llms.txt +155 -0
  41. package/package.json +35 -2
  42. package/src/index.ts +50 -4
  43. package/src/intents/double-tap/index.ts +6 -0
  44. package/src/intents/long-press/index.ts +6 -0
  45. package/src/intents/pan/index.ts +2 -0
  46. package/src/intents/pinch/index.ts +2 -0
  47. package/src/intents/rotate/index.ts +6 -0
  48. package/src/intents/swipe/index.ts +8 -0
  49. package/src/intents/tapEvent.ts +50 -0
  50. package/src/intents/useDoubleTap.ts +321 -0
  51. package/src/intents/useDrag.ts +49 -28
  52. package/src/intents/useLongPress.ts +391 -0
  53. package/src/intents/usePan.ts +444 -0
  54. package/src/intents/usePinch.ts +483 -0
  55. package/src/intents/useRotate.ts +507 -0
  56. package/src/intents/useSwipe.ts +585 -0
  57. package/src/intents/useTap.ts +51 -60
  58. package/src/internal/intentResult.ts +67 -0
  59. package/src/internal/phaseCallbacks.ts +51 -0
  60. package/src/internal/useGestureMemo.ts +32 -4
  61. package/src/internal/useLatestCallback.ts +2 -2
  62. package/src/raw/useRawGesture.ts +1 -1
  63. package/src/relations/index.ts +94 -0
  64. package/src/types.ts +27 -0
@@ -0,0 +1,444 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ Gesture,
4
+ type GestureStateChangeEvent,
5
+ type GestureUpdateEvent,
6
+ type PanGesture,
7
+ type PanGestureHandlerEventPayload,
8
+ } from 'react-native-gesture-handler'
9
+ import { useSharedValue, type SharedValue } from 'react-native-reanimated'
10
+ import { scheduleOnRN } from 'react-native-worklets'
11
+ import {
12
+ useGestureMemo,
13
+ type GestureMemoOptions,
14
+ } from '../internal/useGestureMemo'
15
+ import { buildIntentResult } from '../internal/intentResult'
16
+ import { useLatestCallback } from '../internal/useLatestCallback'
17
+ import { useStableRecord } from '../internal/useStableRecord'
18
+ import {
19
+ type HitSlop,
20
+ type IntentEndInfo,
21
+ type IntentResult,
22
+ type Point,
23
+ } from '../types'
24
+
25
+ /**
26
+ * How far the finger must travel before the pan takes the touch, in points.
27
+ *
28
+ * The same number `useDrag` uses, and for the same reason: a bare
29
+ * `Gesture.Pan()` activates almost immediately, which is what makes a pan
30
+ * inside a scroll view steal the scroll.
31
+ *
32
+ * **This number is a design intention, not a measurement.** The device sweep
33
+ * of 2026-09-20 drove `usePan` and did not test it. See Known gaps in
34
+ * docs/docs/roadmap.md.
35
+ */
36
+ const DEFAULT_THRESHOLD = 10
37
+
38
+ /** Which way the pan is allowed to move. */
39
+ export type PanAxis = 'x' | 'y' | 'both'
40
+
41
+ /** The intent-shaped payload a {@link usePan} callback receives. */
42
+ export interface PanEvent {
43
+ /**
44
+ * How far the finger has moved since the pan activated.
45
+ *
46
+ * Measured from the activation point, not from touch-down, so it does not
47
+ * include the `threshold` travel the finger spent before the pan existed.
48
+ * It resets to zero at the start of every gesture — a pan reports movement
49
+ * and owns no position.
50
+ */
51
+ readonly translation: Point
52
+ /**
53
+ * How far the finger moved since the previous frame.
54
+ *
55
+ * The field `useDrag` cannot give, and the reason to reach for this hook:
56
+ * a value the consumer owns is advanced by adding this, rather than by
57
+ * re-deriving it from a translation Impulse already clamped.
58
+ *
59
+ * Zero outside `onUpdate`. The first frame after activation reports the
60
+ * movement since activation, so a value advanced by `change` never jumps
61
+ * by `threshold`.
62
+ */
63
+ readonly change: Point
64
+ /** Finger speed, in points per second. */
65
+ readonly velocity: Point
66
+ /** The touch point relative to the window. */
67
+ readonly absolute: Point
68
+ /** How many fingers are down. */
69
+ readonly pointers: number
70
+ }
71
+
72
+ /** Options for {@link usePan}. */
73
+ export interface UsePanOptions extends GestureMemoOptions {
74
+ /**
75
+ * Which way the pan may move. Default `'both'`.
76
+ *
77
+ * The locked axis reports zero for the life of the hook — `axis: 'x'`
78
+ * leaves `y`, `translation.y`, and `change.y` at zero. The axis also
79
+ * decides the activation criterion, which is the part that matters inside
80
+ * a scroll view: see `threshold`.
81
+ */
82
+ axis?: PanAxis
83
+ /**
84
+ * How far the finger must travel before the pan activates, in points.
85
+ * Default `10`.
86
+ *
87
+ * On a single axis this is a directional threshold, so a pan with
88
+ * `axis: 'x'` ignores vertical movement entirely and a vertical scroll view
89
+ * under it keeps working. On `'both'` it is a radial distance.
90
+ */
91
+ threshold?: number
92
+ /**
93
+ * Movement across the axis that makes the pan fail, in points. Unset by
94
+ * default, which leaves RNGH's own behaviour in place.
95
+ *
96
+ * `threshold` decides when the pan wins; this decides when it gives up.
97
+ * Ignored when `axis` is `'both'`, which has no cross axis.
98
+ */
99
+ failOffset?: number
100
+ /**
101
+ * How many fingers must be on the view. Unset by default, which leaves
102
+ * RNGH's range of one to ten in place.
103
+ *
104
+ * Setting it fixes the count exactly, so `pointers: 2` is a two-finger pan
105
+ * that neither starts with one finger nor survives a third.
106
+ */
107
+ pointers?: number
108
+ /**
109
+ * Extra touchable area around the view, in points.
110
+ *
111
+ * Written inline as an object is fine — the gesture is not rebuilt when the
112
+ * contents are unchanged.
113
+ */
114
+ hitSlop?: HitSlop
115
+ /**
116
+ * Whether the gesture is recognized at all. Default `true`.
117
+ *
118
+ * Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
119
+ * keeps its identity and its relations, so re-enabling it does not
120
+ * re-attach anything.
121
+ */
122
+ enabled?: boolean
123
+ /**
124
+ * The pan passed `threshold` and now owns the touch. **Runs on the JS
125
+ * thread** — Impulse owns the `scheduleOnRN` boundary, so this is an
126
+ * ordinary function and may touch React state.
127
+ *
128
+ * This is the first moment the pan has definitely won. `onBegin` fires
129
+ * earlier and promises nothing.
130
+ */
131
+ onPanStart?: (event: PanEvent) => void
132
+ /**
133
+ * The pan is over. **Runs on the JS thread.**
134
+ *
135
+ * Fires only for a pan that activated, so a touch that never passed the
136
+ * threshold never reaches here on either path.
137
+ *
138
+ * It fires for both endings, and `cancelled` says which. `false` is the
139
+ * finger lifting, and `velocity` then describes the release. `true` is the
140
+ * system taking the pan away — a competing gesture won, or the app went to
141
+ * the background. **There was no release on that path, so the velocity
142
+ * describes the last movement rather than a throw.**
143
+ */
144
+ onPanEnd?: (event: PanEvent, info: IntentEndInfo) => void
145
+ /**
146
+ * The finger went down and the gesture is now a candidate. **This is a
147
+ * worklet** — mark it with the `'worklet'` directive, and do not touch
148
+ * React state from it.
149
+ *
150
+ * Being a candidate is not the same as winning: the pan has not passed
151
+ * `threshold` yet and may never. Undo whatever it sets in `onFinalize`.
152
+ */
153
+ onBegin?: (event: PanEvent) => void
154
+ /**
155
+ * The pan moved. **This is a worklet**, and it runs on every frame the
156
+ * finger moves.
157
+ *
158
+ * This is where `change` is read, and it is the reason the hook exists:
159
+ * advance the value the consumer owns from here, on the thread that already
160
+ * holds it.
161
+ *
162
+ * There is no JS-thread counterpart on purpose. A per-frame `scheduleOnRN`
163
+ * is a scheduling cost paid sixty times a second for a value that is
164
+ * already on the thread that needs it.
165
+ */
166
+ onUpdate?: (event: PanEvent) => void
167
+ /**
168
+ * The gesture is over, whether it activated or not. **This is a worklet.**
169
+ *
170
+ * `success` is `true` when the pan activated and ended normally. This is
171
+ * the right place to clear anything `onBegin` set, because it runs on both
172
+ * paths.
173
+ */
174
+ onFinalize?: (event: PanEvent, success: boolean) => void
175
+ }
176
+
177
+ /** What {@link usePan} returns. */
178
+ export interface UsePanResult extends IntentResult<PanGesture> {
179
+ /**
180
+ * How far the finger has moved along the x axis since the pan activated.
181
+ *
182
+ * **This is movement, not a position.** It is set to zero at the start of
183
+ * every gesture, so a second pan does not continue from where the first
184
+ * stopped. `useDrag` is the hook whose value accumulates.
185
+ *
186
+ * It keeps its final number after the gesture ends, so a release animation
187
+ * has something to animate from.
188
+ *
189
+ * Frozen at zero when `axis` is `'y'`.
190
+ */
191
+ readonly x: SharedValue<number>
192
+ /**
193
+ * How far the finger has moved along the y axis since the pan activated.
194
+ * Same terms as `x`. Frozen at zero when `axis` is `'x'`.
195
+ */
196
+ readonly y: SharedValue<number>
197
+ }
198
+
199
+ /**
200
+ * Recognize a pan, and report how the finger moved.
201
+ *
202
+ * ```tsx
203
+ * const camera = { x: useSharedValue(0), y: useSharedValue(0) }
204
+ * const pan = usePan({
205
+ * onUpdate: (event) => {
206
+ * 'worklet'
207
+ * camera.x.value += event.change.x
208
+ * camera.y.value += event.change.y
209
+ * },
210
+ * })
211
+ *
212
+ * return (
213
+ * <GestureDetector gesture={pan.gesture}>
214
+ * <Animated.View style={style} />
215
+ * </GestureDetector>
216
+ * )
217
+ * ```
218
+ *
219
+ * **`usePan` reports movement; `useDrag` owns a position.** That is the whole
220
+ * difference, and it decides which one a screen wants. `useDrag` holds `x`
221
+ * and `y` as the place a thing sits: they accumulate across gestures, they
222
+ * are clamped by `bounds`, and `elastic` bends them. `usePan` holds no
223
+ * position at all — it hands over `translation`, `velocity`, and a per-frame
224
+ * `change`, and the consumer advances whatever it drives. Reach for `useDrag`
225
+ * to move a view. Reach for this one to pan a camera, scrub a value, or feed
226
+ * a number Impulse has no business clamping.
227
+ *
228
+ * **Activation criteria.** `threshold` defaults to 10 points. With
229
+ * `axis: 'x'` or `'y'` it is directional, so a horizontal pan inside a
230
+ * vertical `ScrollView` leaves the scroll alone until the finger commits
231
+ * sideways; with `'both'` it is a radial distance. The device sweep did not
232
+ * test the default.
233
+ *
234
+ * **Coexistence.** A threshold decides who moves first; it does not decide
235
+ * who wins a contested touch. Say which gesture the touch belongs to as well
236
+ * — `deferTo` for a pan that is the fallback, `blocks` for one that is the
237
+ * foreground affordance, `alongside` for a pan that shares the touch with a
238
+ * pinch.
239
+ *
240
+ * **Web.** RNGH recognizes pan from pointer events, so a mouse drag behaves
241
+ * the same as a touch drag and `velocity` is reported in the same units. A
242
+ * trackpad's momentum scroll is not a pan and never reaches this hook.
243
+ * `pointers` above 1 is unreliable on web.
244
+ *
245
+ * **Accessibility.** A pan is invisible to a screen reader and unreachable
246
+ * from a keyboard, and this hook does not fix that. Whatever the pan moves
247
+ * must be reachable another way: buttons that step the value, a reset
248
+ * control for a panned canvas, or `accessibilityActions` with
249
+ * `onAccessibilityAction`. A pan-only affordance is a bug, not a trade-off.
250
+ *
251
+ * @param options - Activation criteria, callbacks, and the `alongside` /
252
+ * `blocks` / `deferTo` coexistence options every Impulse hook accepts.
253
+ */
254
+ export function usePan(options: UsePanOptions = {}): UsePanResult {
255
+ const {
256
+ axis = 'both',
257
+ threshold = DEFAULT_THRESHOLD,
258
+ failOffset,
259
+ pointers,
260
+ enabled,
261
+ onPanStart,
262
+ onPanEnd,
263
+ onBegin,
264
+ onUpdate,
265
+ onFinalize,
266
+ } = options
267
+
268
+ const x = useSharedValue(0)
269
+ const y = useSharedValue(0)
270
+ const isActive = useSharedValue(false)
271
+ // RNGH's `translationX` at the moment the pan activated. Everything this
272
+ // hook reports is measured from here, so the `threshold` travel the finger
273
+ // spent before the pan existed is not counted as movement.
274
+ const originX = useSharedValue(0)
275
+ const originY = useSharedValue(0)
276
+ // The previous frame's translation, so `change` is a delta this hook
277
+ // computes rather than RNGH's `changeX`. RNGH reports the first change as
278
+ // the whole translation, which includes the threshold travel — a consumer
279
+ // accumulating it would jump ten points on the first frame.
280
+ const lastX = useSharedValue(0)
281
+ const lastY = useSharedValue(0)
282
+
283
+ const hitSlop = useStableRecord(options.hitSlop)
284
+
285
+ const movesX = axis !== 'y'
286
+ const movesY = axis !== 'x'
287
+
288
+ // JS-thread callbacks reach the gesture through stable identities, so they
289
+ // are never gesture dependencies. The worklet callbacks stay direct
290
+ // dependencies, because a worklet is captured as written.
291
+ const handlePanStart = useLatestCallback(onPanStart)
292
+ const handlePanEnd = useLatestCallback(onPanEnd)
293
+ // Attaching a handler is not the same as calling it: RNGH decides which
294
+ // thread a gesture's callbacks run on by inspecting the ones it was given,
295
+ // so the gesture does have to change when a handler appears or disappears.
296
+ const hasPanStart = onPanStart !== undefined
297
+ const hasPanEnd = onPanEnd !== undefined
298
+
299
+ const built = useGestureMemo(
300
+ 'usePan',
301
+ () => {
302
+ /**
303
+ * Shape RNGH's flat event into the pan payload.
304
+ *
305
+ * `change` is passed in rather than read off the event: only an update
306
+ * event carries a delta at all, and the one RNGH computes counts the
307
+ * threshold travel on the first frame.
308
+ */
309
+ const toPanEvent = (
310
+ event:
311
+ | GestureStateChangeEvent<PanGestureHandlerEventPayload>
312
+ | GestureUpdateEvent<PanGestureHandlerEventPayload>,
313
+ changeX: number,
314
+ changeY: number,
315
+ ): PanEvent => {
316
+ 'worklet'
317
+ return {
318
+ translation: { x: x.value, y: y.value },
319
+ change: { x: changeX, y: changeY },
320
+ velocity: { x: event.velocityX, y: event.velocityY },
321
+ absolute: { x: event.absoluteX, y: event.absoluteY },
322
+ pointers: event.numberOfPointers,
323
+ }
324
+ }
325
+
326
+ const pan = Gesture.Pan()
327
+ .onBegin((event) => {
328
+ 'worklet'
329
+ onBegin?.(toPanEvent(event, 0, 0))
330
+ })
331
+ .onStart((event) => {
332
+ 'worklet'
333
+ // Zero the report, and remember where RNGH's own translation stood
334
+ // when it did. A pan owns no position, so every gesture starts from
335
+ // nothing rather than from the last one's total.
336
+ originX.value = event.translationX
337
+ originY.value = event.translationY
338
+ lastX.value = 0
339
+ lastY.value = 0
340
+ x.value = 0
341
+ y.value = 0
342
+ isActive.value = true
343
+ if (hasPanStart) {
344
+ scheduleOnRN(handlePanStart, toPanEvent(event, 0, 0))
345
+ }
346
+ })
347
+ .onUpdate((event) => {
348
+ 'worklet'
349
+ const nextX = movesX ? event.translationX - originX.value : 0
350
+ const nextY = movesY ? event.translationY - originY.value : 0
351
+ const changeX = nextX - lastX.value
352
+ const changeY = nextY - lastY.value
353
+ lastX.value = nextX
354
+ lastY.value = nextY
355
+ x.value = nextX
356
+ y.value = nextY
357
+ onUpdate?.(toPanEvent(event, changeX, changeY))
358
+ })
359
+ .onEnd((event, success) => {
360
+ 'worklet'
361
+ // Not guarded on `success`: RNGH calls this for a cancelled pan
362
+ // too, and `cancelled` is what carries that to the JS thread. RNGH
363
+ // reaches `onEnd` only from the ACTIVE state, so a touch that never
364
+ // passed the threshold goes to `onFinalize` and never gets here.
365
+ if (hasPanEnd) {
366
+ scheduleOnRN(handlePanEnd, toPanEvent(event, 0, 0), {
367
+ cancelled: !success,
368
+ })
369
+ }
370
+ })
371
+ .onFinalize((event, success) => {
372
+ 'worklet'
373
+ isActive.value = false
374
+ onFinalize?.(toPanEvent(event, 0, 0), success)
375
+ })
376
+
377
+ // A directional threshold on a single axis, a radial one on both. The
378
+ // directional form is what lets a horizontal pan and a vertical
379
+ // scroller share a view: vertical movement never reaches the offset, so
380
+ // the pan never claims the touch.
381
+ if (axis === 'x') {
382
+ pan.activeOffsetX([-threshold, threshold])
383
+ } else if (axis === 'y') {
384
+ pan.activeOffsetY([-threshold, threshold])
385
+ } else {
386
+ pan.minDistance(threshold)
387
+ }
388
+
389
+ // Applied conditionally rather than with a default, so an option the
390
+ // consumer did not set leaves RNGH's own default in place instead of
391
+ // Impulse overwriting it with a guess.
392
+ if (failOffset !== undefined) {
393
+ if (axis === 'x') {
394
+ pan.failOffsetY([-failOffset, failOffset])
395
+ } else if (axis === 'y') {
396
+ pan.failOffsetX([-failOffset, failOffset])
397
+ }
398
+ }
399
+ if (pointers !== undefined) {
400
+ pan.minPointers(pointers).maxPointers(pointers)
401
+ }
402
+ if (hitSlop !== undefined) {
403
+ pan.hitSlop(hitSlop)
404
+ }
405
+ if (enabled !== undefined) {
406
+ pan.enabled(enabled)
407
+ }
408
+ return pan
409
+ },
410
+ [
411
+ axis,
412
+ threshold,
413
+ failOffset,
414
+ pointers,
415
+ hitSlop,
416
+ enabled,
417
+ movesX,
418
+ movesY,
419
+ hasPanStart,
420
+ hasPanEnd,
421
+ handlePanStart,
422
+ handlePanEnd,
423
+ x,
424
+ y,
425
+ originX,
426
+ originY,
427
+ lastX,
428
+ lastY,
429
+ isActive,
430
+ onBegin,
431
+ onUpdate,
432
+ onFinalize,
433
+ ],
434
+ options,
435
+ )
436
+
437
+ // Memoised so a consumer can put the whole hook result in a dependency
438
+ // list. The shared values are stable for the life of the hook, so `built`
439
+ // is the only real input.
440
+ return useMemo(
441
+ () => buildIntentResult(built, { x, y, isActive }),
442
+ [built, x, y, isActive],
443
+ )
444
+ }