@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,483 @@
1
+ import { useMemo } from 'react'
2
+ import {
3
+ Gesture,
4
+ type GestureStateChangeEvent,
5
+ type GestureUpdateEvent,
6
+ type PinchGesture,
7
+ type PinchGestureHandlerEventPayload,
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
+ * The scale a pinch starts from when the consumer sets no `initial`.
27
+ *
28
+ * `1` is the only defensible value: a scale is a multiplier on the view's own
29
+ * size, so the identity is the thing that has not been zoomed.
30
+ */
31
+ const DEFAULT_INITIAL_SCALE = 1
32
+
33
+ /** The intent-shaped payload a {@link usePinch} callback receives. */
34
+ export interface PinchEvent {
35
+ /**
36
+ * How much the thing is scaled now — the same number as `scale`, after
37
+ * `min`, `max` and `elastic` were applied.
38
+ *
39
+ * This accumulates across gestures. A second pinch continues from where the
40
+ * first one stopped, which is why `min` and `max` can be written as the
41
+ * zoom range of the whole viewer rather than of one gesture.
42
+ */
43
+ readonly scale: number
44
+ /**
45
+ * How much the fingers scaled during **this gesture alone**, raw.
46
+ *
47
+ * Untouched by `min`, `max` and `elastic`, and reset to `1` at the start of
48
+ * every gesture. This is RNGH's own `scale`. Read `scale` for how big the
49
+ * thing being pinched actually is.
50
+ */
51
+ readonly gestureScale: number
52
+ /**
53
+ * The midpoint between the fingers, relative to the view.
54
+ *
55
+ * This is the point the zoom must happen about. A pinch scaled about the
56
+ * view's centre instead slides the content out from under the fingers, and
57
+ * that is the defect this field exists to prevent.
58
+ */
59
+ readonly focal: Point
60
+ /** How fast the scale is changing, in scale units per second. */
61
+ readonly velocity: number
62
+ /**
63
+ * The nearest scale inside `min` and `max`. Equal to `scale` whenever the
64
+ * pinch is in range, which with the default `elastic` of `0` is always.
65
+ *
66
+ * With `elastic` set, the fingers can pull the scale past an end and
67
+ * Impulse leaves it there on release — moving it back is an animation, and
68
+ * Impulse owns no animation vocabulary. This field is the destination that
69
+ * animation needs, so the consumer does not have to re-derive the clamp
70
+ * from a range it already handed over.
71
+ */
72
+ readonly settled: number
73
+ /** How many fingers are down. */
74
+ readonly pointers: number
75
+ }
76
+
77
+ /** Options for {@link usePinch}. */
78
+ export interface UsePinchOptions extends GestureMemoOptions {
79
+ /**
80
+ * The scale before any pinch. Default `1`.
81
+ *
82
+ * Read once, at mount. `useSharedValue` keeps its first argument and
83
+ * ignores every later one, and that is the behaviour this option
84
+ * documents: after mount `scale` is the pinch's state, and the consumer
85
+ * moves it by writing it.
86
+ */
87
+ initial?: number
88
+ /**
89
+ * The smallest scale. Unset by default, which lets the fingers shrink the
90
+ * thing without limit.
91
+ *
92
+ * Set it to `1` for a viewer that may zoom in but never out.
93
+ */
94
+ min?: number
95
+ /**
96
+ * The largest scale. Unset by default, which lets the fingers grow the
97
+ * thing without limit.
98
+ */
99
+ max?: number
100
+ /**
101
+ * How much of the travel past `min` or `max` reaches `scale`, from `0` to
102
+ * `1`. Default `0`.
103
+ *
104
+ * `0` stops dead at the end. `1` ignores the end while the fingers are
105
+ * down. Anything between is resistance — the pinch keeps moving and moves
106
+ * less than the fingers do.
107
+ *
108
+ * Impulse does not bring the scale back. The end callback carries
109
+ * `settled` — the scale to animate to — and `@rootnative/inertia` or a
110
+ * `withSpring` of your own does the rest.
111
+ */
112
+ elastic?: number
113
+ /**
114
+ * Extra touchable area around the view, in points.
115
+ *
116
+ * Written inline as an object is fine — the gesture is not rebuilt when the
117
+ * contents are unchanged.
118
+ */
119
+ hitSlop?: HitSlop
120
+ /**
121
+ * Whether the gesture is recognized at all. Default `true`.
122
+ *
123
+ * Prefer this over unmounting the `<GestureDetector>`: a disabled gesture
124
+ * keeps its identity and its relations, so re-enabling it does not
125
+ * re-attach anything.
126
+ */
127
+ enabled?: boolean
128
+ /**
129
+ * The pinch now owns the touch. **Runs on the JS thread** — Impulse owns
130
+ * the `scheduleOnRN` boundary, so this is an ordinary function and may
131
+ * touch React state.
132
+ *
133
+ * This is the first moment the pinch has definitely won. `onBegin` fires
134
+ * earlier and promises nothing.
135
+ */
136
+ onPinchStart?: (event: PinchEvent) => void
137
+ /**
138
+ * The pinch is over. **Runs on the JS thread.**
139
+ *
140
+ * Fires only for a pinch that activated, so a touch that never became a
141
+ * pinch never reaches here on either path.
142
+ *
143
+ * It fires for both endings, and `cancelled` says which. `false` is the
144
+ * fingers lifting, and `velocity` then describes the release. `true` is the
145
+ * system taking the pinch away — a competing gesture won, or the app went
146
+ * to the background. **There was no release on that path, so the velocity
147
+ * describes the last movement rather than a throw.**
148
+ *
149
+ * Read `settled` on both paths for where an elastic overshoot belongs.
150
+ */
151
+ onPinchEnd?: (event: PinchEvent, info: IntentEndInfo) => void
152
+ /**
153
+ * A finger went down and the gesture is now a candidate. **This is a
154
+ * worklet** — mark it with the `'worklet'` directive, and do not touch
155
+ * React state from it.
156
+ *
157
+ * Being a candidate is not the same as winning: one finger is not a pinch,
158
+ * and a second one may never arrive. Undo whatever it sets in
159
+ * `onFinalize`.
160
+ */
161
+ onBegin?: (event: PinchEvent) => void
162
+ /**
163
+ * The scale changed. **This is a worklet**, and it runs on every frame the
164
+ * fingers move.
165
+ *
166
+ * `scale` is already written by the time this runs, so a `useAnimatedStyle`
167
+ * reading it needs nothing from here. Use this for the work that scaling
168
+ * alone does not do — holding the focal point still, say.
169
+ *
170
+ * There is no JS-thread counterpart on purpose. A per-frame `scheduleOnRN`
171
+ * is a scheduling cost paid sixty times a second for a value that is
172
+ * already on the thread that needs it.
173
+ */
174
+ onUpdate?: (event: PinchEvent) => void
175
+ /**
176
+ * The gesture is over, whether it activated or not. **This is a worklet.**
177
+ *
178
+ * `success` is `true` when the pinch activated and ended normally. This is
179
+ * the right place to clear anything `onBegin` set, because it runs on both
180
+ * paths.
181
+ */
182
+ onFinalize?: (event: PinchEvent, success: boolean) => void
183
+ }
184
+
185
+ /** What {@link usePinch} returns. */
186
+ export interface UsePinchResult extends IntentResult<PinchGesture> {
187
+ /**
188
+ * How much the thing is scaled, after `min`, `max` and `elastic`.
189
+ *
190
+ * **This is a position, not a movement.** It accumulates across gestures,
191
+ * so a second pinch continues from where the first stopped. `usePan` is the
192
+ * hook whose value zeroes at every gesture.
193
+ *
194
+ * Writing it is allowed and is how a release animation, or a reset button,
195
+ * hands control back. The next pinch continues from whatever it holds.
196
+ */
197
+ readonly scale: SharedValue<number>
198
+ /**
199
+ * The midpoint between the fingers, relative to the view.
200
+ *
201
+ * It keeps the last gesture's focal point after the fingers lift, so a
202
+ * release animation scales about the same place the pinch did rather than
203
+ * snapping to the origin.
204
+ */
205
+ readonly focal: SharedValue<Point>
206
+ }
207
+
208
+ /**
209
+ * Hold a scale inside a range, letting `elastic` of the excess through.
210
+ *
211
+ * A worklet, because the pinch's whole value path runs on the UI thread. The
212
+ * resistance is computed on the scale itself rather than on its logarithm:
213
+ * the two differ only past an end, where the number is already a deliberate
214
+ * overshoot rather than a measurement, and the linear form is the one
215
+ * `useDrag` uses for the same option name.
216
+ */
217
+ function resist(
218
+ value: number,
219
+ min: number | undefined,
220
+ max: number | undefined,
221
+ elastic: number,
222
+ ): number {
223
+ 'worklet'
224
+ if (min !== undefined && value < min) {
225
+ return min + (value - min) * elastic
226
+ }
227
+ if (max !== undefined && value > max) {
228
+ return max + (value - max) * elastic
229
+ }
230
+ return value
231
+ }
232
+
233
+ /**
234
+ * The nearest scale inside the range. What {@link resist} would have returned
235
+ * with `elastic` at `0`.
236
+ */
237
+ function clamp(
238
+ value: number,
239
+ min: number | undefined,
240
+ max: number | undefined,
241
+ ): number {
242
+ 'worklet'
243
+ if (min !== undefined && value < min) {
244
+ return min
245
+ }
246
+ if (max !== undefined && value > max) {
247
+ return max
248
+ }
249
+ return value
250
+ }
251
+
252
+ /**
253
+ * Recognize a two-finger pinch, and own the scale it produces.
254
+ *
255
+ * ```tsx
256
+ * const pinch = usePinch({ min: 1, max: 4 })
257
+ *
258
+ * const style = useAnimatedStyle(() => ({
259
+ * transform: [{ scale: pinch.scale.value }],
260
+ * }))
261
+ *
262
+ * return (
263
+ * <GestureDetector gesture={pinch.gesture}>
264
+ * <Animated.Image style={style} source={source} />
265
+ * </GestureDetector>
266
+ * )
267
+ * ```
268
+ *
269
+ * **The scale accumulates; RNGH's does not.** A bare `Gesture.Pinch()`
270
+ * reports a factor that restarts at `1` on every gesture, so a viewer built
271
+ * on it snaps back to its original size the moment the fingers lift again.
272
+ * Carrying the scale across gestures is the multiplication and the stored
273
+ * start that every consumer otherwise writes, and it is why `min` and `max`
274
+ * can be the range of the viewer rather than of one gesture.
275
+ *
276
+ * **`focal` is the field a zoom viewer cannot skip.** Scaling about the
277
+ * view's centre slides the content out from under the fingers. The focal
278
+ * point is where the zoom has to happen, and it is reported relative to the
279
+ * view so it can go straight into a `translate` / `scale` / `translate`
280
+ * transform.
281
+ *
282
+ * **Activation criteria.** There are none to set. RNGH's pinch takes the
283
+ * touch as soon as a second finger moves, and it exposes no threshold, so
284
+ * Impulse has none to pass on. This is the one continuous intent whose
285
+ * coexistence is decided entirely by relations — see below.
286
+ *
287
+ * **Coexistence.** A pinch almost always shares its view with a pan, and it
288
+ * has no threshold to separate them with. Say it: `alongside` on both, so the
289
+ * two recognize at once and the same two fingers can move and scale the
290
+ * thing. `useGestures` with `mode: 'simultaneous'` is the same statement for
291
+ * gestures this screen owns.
292
+ *
293
+ * **Web.** RNGH recognizes pinch from pointer events, so it needs two
294
+ * pointers — a touchscreen or a device that reports them. A trackpad's pinch
295
+ * arrives as a `wheel` event with `ctrlKey`, which is not a pointer pair and
296
+ * never reaches this hook, so a desktop browser with a trackpad alone cannot
297
+ * zoom. Give it a control that sets `scale` directly, which the
298
+ * accessibility fallback needs anyway. The browser's own page zoom is
299
+ * unaffected either way.
300
+ *
301
+ * **Accessibility.** A pinch is invisible to a screen reader and unreachable
302
+ * from a keyboard, and this hook does not fix that. Whatever the pinch scales
303
+ * must be reachable another way: zoom-in and zoom-out buttons that write
304
+ * `scale`, a control that resets it, or `accessibilityActions` with
305
+ * `onAccessibilityAction`. A pinch-only zoom is a bug, not a trade-off.
306
+ *
307
+ * @param options - The scale range, callbacks, and the `alongside` /
308
+ * `blocks` / `deferTo` coexistence options every Impulse hook accepts.
309
+ */
310
+ export function usePinch(options: UsePinchOptions = {}): UsePinchResult {
311
+ const {
312
+ initial = DEFAULT_INITIAL_SCALE,
313
+ min,
314
+ max,
315
+ elastic = 0,
316
+ enabled,
317
+ onPinchStart,
318
+ onPinchEnd,
319
+ onBegin,
320
+ onUpdate,
321
+ onFinalize,
322
+ } = options
323
+
324
+ const scale = useSharedValue(initial)
325
+ const focal = useSharedValue<Point>({ x: 0, y: 0 })
326
+ const isActive = useSharedValue(false)
327
+ // What `scale` would be at a gesture scale of 1, so the position can be
328
+ // rebuilt as `start * event.scale` on every frame.
329
+ const startScale = useSharedValue(initial)
330
+
331
+ const hitSlop = useStableRecord(options.hitSlop)
332
+
333
+ // JS-thread callbacks reach the gesture through stable identities, so they
334
+ // are never gesture dependencies. The worklet callbacks stay direct
335
+ // dependencies, because a worklet is captured as written.
336
+ const handlePinchStart = useLatestCallback(onPinchStart)
337
+ const handlePinchEnd = useLatestCallback(onPinchEnd)
338
+ // Attaching a handler is not the same as calling it: RNGH decides which
339
+ // thread a gesture's callbacks run on by inspecting the ones it was given,
340
+ // so the gesture does have to change when a handler appears or disappears.
341
+ const hasPinchStart = onPinchStart !== undefined
342
+ const hasPinchEnd = onPinchEnd !== undefined
343
+
344
+ const built = useGestureMemo(
345
+ 'usePinch',
346
+ () => {
347
+ /**
348
+ * Shape RNGH's flat event into the pinch payload.
349
+ *
350
+ * Built inside the gesture rather than at module scope because it
351
+ * closes over the range — which is also why it is rebuilt only when
352
+ * that changes. Called from the callbacks, and only when one is
353
+ * present: a payload nobody reads is three objects allocated on the UI
354
+ * thread for every frame of every pinch.
355
+ */
356
+ const toPinchEvent = (
357
+ event:
358
+ | GestureStateChangeEvent<PinchGestureHandlerEventPayload>
359
+ | GestureUpdateEvent<PinchGestureHandlerEventPayload>,
360
+ ): PinchEvent => {
361
+ 'worklet'
362
+ return {
363
+ scale: scale.value,
364
+ gestureScale: event.scale,
365
+ focal: focal.value,
366
+ velocity: event.velocity,
367
+ settled: clamp(scale.value, min, max),
368
+ pointers: event.numberOfPointers,
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Record the focal point.
374
+ *
375
+ * A fresh object rather than two writes into the existing one: a
376
+ * shared value notifies on assignment, so mutating the object in place
377
+ * would leave a `useAnimatedStyle` reading a point that never changed.
378
+ *
379
+ * Called from `onStart` as well as `onUpdate`, so `onPinchStart`
380
+ * reports where the fingers are rather than where the previous gesture
381
+ * left them. Deliberately not called from `onEnd` or `onFinalize`: the
382
+ * fingers are lifting there and the point RNGH reports is no longer the
383
+ * one the zoom happened about.
384
+ */
385
+ const trackFocal = (
386
+ event:
387
+ | GestureStateChangeEvent<PinchGestureHandlerEventPayload>
388
+ | GestureUpdateEvent<PinchGestureHandlerEventPayload>,
389
+ ) => {
390
+ 'worklet'
391
+ focal.value = { x: event.focalX, y: event.focalY }
392
+ }
393
+
394
+ const pinch = Gesture.Pinch()
395
+ .onBegin((event) => {
396
+ 'worklet'
397
+ onBegin?.(toPinchEvent(event))
398
+ })
399
+ .onStart((event) => {
400
+ 'worklet'
401
+ // Divide rather than assign. RNGH reports `1` here in the ordinary
402
+ // case, but it activates on movement, so the fingers may already
403
+ // have travelled. Dividing means the first `onUpdate` reproduces
404
+ // the current scale instead of multiplying it by that head start —
405
+ // the difference between a pinch that picks the image up at its
406
+ // size and one that jumps before it grows.
407
+ startScale.value =
408
+ event.scale === 0 ? scale.value : scale.value / event.scale
409
+ trackFocal(event)
410
+ isActive.value = true
411
+ if (hasPinchStart) {
412
+ scheduleOnRN(handlePinchStart, toPinchEvent(event))
413
+ }
414
+ })
415
+ .onUpdate((event) => {
416
+ 'worklet'
417
+ trackFocal(event)
418
+ scale.value = resist(
419
+ startScale.value * event.scale,
420
+ min,
421
+ max,
422
+ elastic,
423
+ )
424
+ onUpdate?.(toPinchEvent(event))
425
+ })
426
+ .onEnd((event, success) => {
427
+ 'worklet'
428
+ // Not guarded on `success`: RNGH calls this for a cancelled pinch
429
+ // too, and `cancelled` is what carries that to the JS thread. RNGH
430
+ // reaches `onEnd` only from the ACTIVE state, so a touch that never
431
+ // became a pinch goes to `onFinalize` and never gets here.
432
+ if (hasPinchEnd) {
433
+ scheduleOnRN(handlePinchEnd, toPinchEvent(event), {
434
+ cancelled: !success,
435
+ })
436
+ }
437
+ })
438
+ .onFinalize((event, success) => {
439
+ 'worklet'
440
+ isActive.value = false
441
+ onFinalize?.(toPinchEvent(event), success)
442
+ })
443
+
444
+ // Applied conditionally rather than with a default, so an option the
445
+ // consumer did not set leaves RNGH's own default in place instead of
446
+ // Impulse overwriting it with a guess.
447
+ if (hitSlop !== undefined) {
448
+ pinch.hitSlop(hitSlop)
449
+ }
450
+ if (enabled !== undefined) {
451
+ pinch.enabled(enabled)
452
+ }
453
+ return pinch
454
+ },
455
+ [
456
+ min,
457
+ max,
458
+ elastic,
459
+ hitSlop,
460
+ enabled,
461
+ hasPinchStart,
462
+ hasPinchEnd,
463
+ handlePinchStart,
464
+ handlePinchEnd,
465
+ scale,
466
+ focal,
467
+ startScale,
468
+ isActive,
469
+ onBegin,
470
+ onUpdate,
471
+ onFinalize,
472
+ ],
473
+ options,
474
+ )
475
+
476
+ // Memoised so a consumer can put the whole hook result in a dependency
477
+ // list. The shared values are stable for the life of the hook, so `built`
478
+ // is the only real input.
479
+ return useMemo(
480
+ () => buildIntentResult(built, { scale, focal, isActive }),
481
+ [built, scale, focal, isActive],
482
+ )
483
+ }