@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,391 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ Gesture,
4
+ type GestureStateChangeEvent,
5
+ type LongPressGesture,
6
+ type LongPressGestureHandlerEventPayload,
7
+ } from 'react-native-gesture-handler'
8
+ import { useSharedValue } from 'react-native-reanimated'
9
+ import { scheduleOnRN } from 'react-native-worklets'
10
+ import {
11
+ useGestureMemo,
12
+ type GestureMemoOptions,
13
+ } from '../internal/useGestureMemo'
14
+ import { buildIntentResult } from '../internal/intentResult'
15
+ import { useLatestCallback } from '../internal/useLatestCallback'
16
+ import { useStableRecord } from '../internal/useStableRecord'
17
+ import {
18
+ type HitSlop,
19
+ type IntentEndInfo,
20
+ type IntentResult,
21
+ type Point,
22
+ } from '../types'
23
+
24
+ /**
25
+ * How long the finger must stay down before the press is recognized, in
26
+ * milliseconds.
27
+ *
28
+ * RNGH's own default, restated rather than inherited so the value is visible
29
+ * at the call site's documentation and cannot move underneath Impulse in an
30
+ * RNGH release.
31
+ */
32
+ const DEFAULT_MIN_DURATION = 500
33
+
34
+ /**
35
+ * How far the finger may travel while waiting, in points.
36
+ *
37
+ * This one **is** RNGH's own default — unlike `useTap`'s `maxDistance`, where
38
+ * RNGH defers to the platform and Impulse had to choose a number. Restated
39
+ * for the same reason as `minDuration`.
40
+ */
41
+ const DEFAULT_MAX_DISTANCE = 10
42
+
43
+ /** The intent-shaped payload a {@link useLongPress} callback receives. */
44
+ export interface LongPressEvent {
45
+ /** X of the press, in points, relative to the view the gesture is attached to. */
46
+ readonly x: number
47
+ /** Y of the press, in points, relative to the view the gesture is attached to. */
48
+ readonly y: number
49
+ /**
50
+ * The same point relative to the window.
51
+ *
52
+ * Prefer it over `x` / `y` when the view itself is being transformed by the
53
+ * gesture — a press on a view that is mid-animation reports a moving `x`.
54
+ */
55
+ readonly absolute: Point
56
+ /**
57
+ * How long the finger has been down, in milliseconds.
58
+ *
59
+ * At `onLongPress` this is roughly `minDuration`, because that is the
60
+ * moment the press was recognized. At `onLongPressEnd` it is the whole hold
61
+ * — which is the number a hold-to-record affordance wants, and the reason
62
+ * this field is in the payload rather than derived by the consumer from two
63
+ * timestamps.
64
+ */
65
+ readonly duration: number
66
+ /** How many fingers are down. */
67
+ readonly pointers: number
68
+ }
69
+
70
+ /** Options for {@link useLongPress}. */
71
+ export interface UseLongPressOptions extends GestureMemoOptions {
72
+ /**
73
+ * How long the finger must stay down before the press is recognized, in
74
+ * milliseconds. Default `500`.
75
+ *
76
+ * Lower it for an affordance the user is expected to discover; raise it for
77
+ * one that must not fire by accident. Below roughly 200ms it stops being
78
+ * distinguishable from a tap that was held a moment too long.
79
+ */
80
+ minDuration?: number
81
+ /**
82
+ * How far the finger may travel, in points. Default `10`.
83
+ *
84
+ * **What this bounds is platform-dependent, and the split is verified.**
85
+ * RNGH documents it as bounding the wait only: "if the finger travels
86
+ * further than the defined distance and the handler hasn't yet activated,
87
+ * it will fail". Its web implementation does not match that — it checks the
88
+ * distance on every pointer move and *cancels* a press that is already
89
+ * active.
90
+ *
91
+ * So on web the finger may not travel past this once the press is
92
+ * recognized, and hold-then-drag needs this raised explicitly rather than
93
+ * relying on the documented behaviour. Native is unverified.
94
+ */
95
+ maxDistance?: number
96
+ /**
97
+ * Exactly how many fingers must be down. Unset by default, which leaves
98
+ * RNGH's own behaviour in place.
99
+ *
100
+ * Setting it fixes the count exactly rather than setting a minimum.
101
+ */
102
+ pointers?: number
103
+ /**
104
+ * Extra touchable area around the view, in points.
105
+ *
106
+ * Written inline as an object is fine — the gesture is not rebuilt when the
107
+ * contents are unchanged.
108
+ */
109
+ hitSlop?: HitSlop
110
+ /**
111
+ * Whether the gesture is recognized at all. Default `true`.
112
+ *
113
+ * Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
114
+ * keeps its identity and its relations, so re-enabling it does not
115
+ * re-attach anything.
116
+ */
117
+ enabled?: boolean
118
+ /**
119
+ * The press was held long enough and is now recognized. **Runs on the JS
120
+ * thread** — Impulse owns the `scheduleOnRN` boundary, so this is an ordinary
121
+ * function and may touch React state.
122
+ *
123
+ * **The finger is still down when this fires.** That is the point: a
124
+ * context menu opens under a finger that has not lifted, which is what
125
+ * makes a long press feel like a long press rather than like a slow tap.
126
+ * Fire the haptic here.
127
+ */
128
+ onLongPress?: (event: LongPressEvent) => void
129
+ /**
130
+ * A recognized press ended. **Runs on the JS thread.**
131
+ *
132
+ * Always preceded by `onLongPress`, because a touch that never became a
133
+ * long press never reaches here on either path.
134
+ *
135
+ * It fires for both endings, and `cancelled` says which. `false` is the
136
+ * finger lifting. `true` is the system taking the press away — a competing
137
+ * gesture won, the app went to the background, or the finger moved past
138
+ * `maxDistance` while still down. Read it before you treat the press as
139
+ * completed: a hold-to-record affordance stops the recording on both paths
140
+ * but keeps the take only on the first.
141
+ *
142
+ * `duration` carries the whole hold, which is what that affordance stops
143
+ * on.
144
+ */
145
+ onLongPressEnd?: (event: LongPressEvent, info: IntentEndInfo) => void
146
+ /**
147
+ * The finger went down and the gesture is now a candidate. **This is a
148
+ * worklet** — mark it with the `'worklet'` directive, and do not touch
149
+ * React state from it.
150
+ *
151
+ * It fires on every touch, including the ordinary taps that will never
152
+ * become a long press. Use it to show a candidate state, and undo that
153
+ * state in `onFinalize`.
154
+ */
155
+ onBegin?: (event: LongPressEvent) => void
156
+ /**
157
+ * The gesture is over, whether it was recognized or not. **This is a
158
+ * worklet.**
159
+ *
160
+ * `success` is `true` when the press was recognized and ended normally.
161
+ * This is the right place to clear anything `onBegin` set, because it runs
162
+ * on both paths.
163
+ */
164
+ onFinalize?: (event: LongPressEvent, success: boolean) => void
165
+ }
166
+
167
+ /**
168
+ * What {@link useLongPress} returns.
169
+ *
170
+ * An alias rather than an extending interface, because a long press produces
171
+ * no continuous value of its own — the duration is in the payload, not in a
172
+ * shared value, because nothing on the UI thread can read a clock per frame
173
+ * without Impulse starting an animation to drive it.
174
+ */
175
+ export type UseLongPressResult = IntentResult<LongPressGesture>
176
+
177
+ /**
178
+ * Shape RNGH's flat state-change event into the long-press payload.
179
+ *
180
+ * A worklet, because every caller is one. Keeping the normalizer out of the
181
+ * gesture callbacks means the four call sites cannot disagree about which
182
+ * RNGH field means what — which is the defect the intent payload exists to
183
+ * remove.
184
+ */
185
+ function toLongPressEvent(
186
+ event: GestureStateChangeEvent<LongPressGestureHandlerEventPayload>,
187
+ ): LongPressEvent {
188
+ 'worklet'
189
+ return {
190
+ x: event.x,
191
+ y: event.y,
192
+ absolute: { x: event.absoluteX, y: event.absoluteY },
193
+ duration: event.duration,
194
+ pointers: event.numberOfPointers,
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Recognize a press held past a duration.
200
+ *
201
+ * ```tsx
202
+ * const hold = useLongPress({ minDuration: 400, onLongPress: openMenu })
203
+ *
204
+ * return (
205
+ * <GestureDetector gesture={hold.gesture}>
206
+ * <View />
207
+ * </GestureDetector>
208
+ * )
209
+ * ```
210
+ *
211
+ * `onLongPress` runs on the JS thread and may set React state directly, and
212
+ * it fires **while the finger is still down** — that is the moment a context
213
+ * menu should open and a haptic should fire. `onLongPressEnd` runs on the JS
214
+ * thread too, when the finger lifts. `onBegin` and `onFinalize` are worklets
215
+ * and run on the UI thread — the name states the thread, so there is nothing
216
+ * to configure and no `scheduleOnRN` to write.
217
+ *
218
+ * `isActive` is a shared value that is `true` from the moment the press is
219
+ * recognized until the finger lifts — **not** from the moment the finger goes
220
+ * down. It is the held state, not a pressed state: every ordinary tap on the
221
+ * view would set a finger-down flag, and almost none of them are this
222
+ * gesture. Drive a pressed state from a `useTap` instead, and use this for
223
+ * whatever should show only while the press is genuinely being held.
224
+ *
225
+ * **Pairing with a tap.** A tap and a long press on one view race, and the
226
+ * race resolves itself: a tap that is held too long fails, and a press
227
+ * released too early never activates.
228
+ *
229
+ * ```tsx
230
+ * useGestures([useLongPress({ onLongPress: openMenu }), tap], { mode: 'race' })
231
+ * ```
232
+ *
233
+ * **Hold, then drag.** RNGH has no "activate after this one activates"
234
+ * relation, so this is two gestures and a gate rather than one option. Run
235
+ * them `alongside` each other and let the drag read a flag the press sets:
236
+ *
237
+ * ```tsx
238
+ * const hold = useLongPress({ alongside: dragRef })
239
+ * const drag = useDrag({
240
+ * alongside: hold.ref,
241
+ * onUpdate: () => {
242
+ * 'worklet'
243
+ * // ignore the movement until the press has been held
244
+ * },
245
+ * })
246
+ * ```
247
+ *
248
+ * `hold.isActive` is the flag, and it is a shared value precisely so the
249
+ * drag's worklets can read it without a round trip to the JS thread. Raise
250
+ * the drag's `threshold` as well, or the drag activates before the press ever
251
+ * does — and raise this hook's `maxDistance`, or the press is cancelled by
252
+ * the drag's own movement on web.
253
+ *
254
+ * **Activation criteria.** `minDuration` defaults to 500ms and `maxDistance`
255
+ * to 10 points, and both are RNGH's own numbers restated. What `maxDistance`
256
+ * bounds differs by platform: RNGH documents it as bounding the wait only,
257
+ * but its web implementation cancels a press that is already active once the
258
+ * finger travels past it. Raise it explicitly rather than relying on travel
259
+ * being free after recognition.
260
+ *
261
+ * **Web.** RNGH recognizes long press from pointer events, so a held mouse
262
+ * button works. The browser's own context menu is not suppressed by this
263
+ * hook, so a long press with the right button — or a held touch on some
264
+ * browsers — can open both. Suppress it on the view if that matters.
265
+ *
266
+ * **Accessibility.** A long press is invisible to a screen reader and
267
+ * unreachable from a keyboard, and this hook does not fix that. It has the
268
+ * best fallback of any gesture in the library, so use it: React Native's
269
+ * `<Pressable>` takes `onLongPress` directly and is reachable by every
270
+ * assistive technology, and `accessibilityActions` with
271
+ * `onAccessibilityAction` names the same action explicitly. A long-press-only
272
+ * affordance is a bug, not a trade-off.
273
+ *
274
+ * @param options - Activation criteria, callbacks, and the `alongside` /
275
+ * `blocks` / `deferTo` coexistence options every Impulse hook accepts.
276
+ */
277
+ export function useLongPress(
278
+ options: UseLongPressOptions = {},
279
+ ): UseLongPressResult {
280
+ const {
281
+ minDuration = DEFAULT_MIN_DURATION,
282
+ maxDistance = DEFAULT_MAX_DISTANCE,
283
+ pointers,
284
+ enabled,
285
+ onLongPress,
286
+ onLongPressEnd,
287
+ onBegin,
288
+ onFinalize,
289
+ } = options
290
+
291
+ const isActive = useSharedValue(false)
292
+ // `hitSlop` is the one option a consumer writes as an object literal, so it
293
+ // is the one that would rebuild the gesture every render if taken as-is.
294
+ const hitSlop = useStableRecord(options.hitSlop)
295
+ // JS-thread callbacks reach the gesture through stable identities, so they
296
+ // are never gesture dependencies. The worklet callbacks stay direct
297
+ // dependencies, because a worklet is captured as written.
298
+ const handleLongPress = useLatestCallback(onLongPress)
299
+ const handleLongPressEnd = useLatestCallback(onLongPressEnd)
300
+ // Attaching a handler is not the same as calling it: RNGH decides which
301
+ // thread a gesture's callbacks run on by inspecting the ones it was given,
302
+ // so the gesture does have to change when a handler appears or disappears.
303
+ // Booleans, so they change only when that is actually true.
304
+ const hasLongPress = onLongPress !== undefined
305
+ const hasLongPressEnd = onLongPressEnd !== undefined
306
+
307
+ const built = useGestureMemo(
308
+ 'useLongPress',
309
+ () => {
310
+ const press = Gesture.LongPress()
311
+ .minDuration(minDuration)
312
+ .maxDistance(maxDistance)
313
+ .onBegin((event) => {
314
+ 'worklet'
315
+ onBegin?.(toLongPressEvent(event))
316
+ })
317
+ .onStart((event) => {
318
+ 'worklet'
319
+ // Recognition, not touch-down: the press has now been held past
320
+ // `minDuration` and the finger is still on the view. `isActive`
321
+ // tracks that held state rather than the candidate state, which is
322
+ // what makes it worth reading at all.
323
+ isActive.value = true
324
+ if (hasLongPress) {
325
+ scheduleOnRN(handleLongPress, toLongPressEvent(event))
326
+ }
327
+ })
328
+ .onEnd((event, success) => {
329
+ 'worklet'
330
+ // Not guarded on `success`: RNGH calls this for a cancelled press
331
+ // too, and `cancelled` is what separates the two. The guard used to
332
+ // be here, which left the cancel reportable only from `onFinalize`
333
+ // — a worklet — so a consumer holding phase in React state had to
334
+ // cross the thread boundary by hand.
335
+ //
336
+ // Reporting the cancel is safe because RNGH calls `onEnd` only when
337
+ // the old state was ACTIVE. A touch that never became a long press
338
+ // reaches `onFinalize` and never gets here, so this never announces
339
+ // the end of a press that never started.
340
+ if (hasLongPressEnd) {
341
+ scheduleOnRN(handleLongPressEnd, toLongPressEvent(event), {
342
+ cancelled: !success,
343
+ })
344
+ }
345
+ })
346
+ .onFinalize((event, success) => {
347
+ 'worklet'
348
+ isActive.value = false
349
+ onFinalize?.(toLongPressEvent(event), success)
350
+ })
351
+
352
+ // Applied conditionally rather than with a default, so an option the
353
+ // consumer did not set leaves RNGH's own default in place instead of
354
+ // Impulse overwriting it with a guess.
355
+ if (pointers !== undefined) {
356
+ press.numberOfPointers(pointers)
357
+ }
358
+ if (hitSlop !== undefined) {
359
+ press.hitSlop(hitSlop)
360
+ }
361
+ if (enabled !== undefined) {
362
+ press.enabled(enabled)
363
+ }
364
+ return press
365
+ },
366
+ [
367
+ minDuration,
368
+ maxDistance,
369
+ pointers,
370
+ hitSlop,
371
+ enabled,
372
+ hasLongPress,
373
+ hasLongPressEnd,
374
+ handleLongPress,
375
+ handleLongPressEnd,
376
+ isActive,
377
+ onBegin,
378
+ onFinalize,
379
+ ],
380
+ options,
381
+ )
382
+
383
+ // Memoised for the same reason `useGestureMemo` memoises its own result: a
384
+ // consumer may put the whole hook result in a dependency list, and a fresh
385
+ // object every render would make that dependency useless. `isActive` is
386
+ // stable for the life of the hook, so `built` is the only real input.
387
+ return useMemo(
388
+ () => buildIntentResult(built, { isActive }),
389
+ [built, isActive],
390
+ )
391
+ }