@octane-xplat/motion 0.9.0 → 0.10.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.
@@ -0,0 +1,29 @@
1
+ import type { DelegatedRun } from './host-types'
2
+
3
+ // Probe-visible counters so retained probes can distinguish a real delegated
4
+ // run from a silent JS-engine fallback without widening the public API.
5
+ export function delegationStats() {
6
+ const platform = globalThis as any
7
+ return (platform.__xplatMotionDelegations ??= {
8
+ started: 0,
9
+ finished: 0,
10
+ cancelled: 0,
11
+ fallback: 0,
12
+ })
13
+ }
14
+
15
+ /** Count a delegated run and fold its settled result into the counters. */
16
+ export function trackDelegatedRun(run: DelegatedRun): DelegatedRun {
17
+ delegationStats().started++
18
+ const finished = run.finished.then((result) => {
19
+ if (result === 'finished') {
20
+ delegationStats().finished++
21
+ } else {
22
+ delegationStats().cancelled++
23
+ }
24
+
25
+ return result
26
+ })
27
+
28
+ return { ...run, finished }
29
+ }
@@ -0,0 +1,270 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { Controller } from './controller'
3
+ import { delegatedRun } from './driver.web'
4
+ import { attachHost } from './host.web'
5
+ import type { Clock } from './clock-types'
6
+ import type { Target } from './types'
7
+
8
+ function fakeClock() {
9
+ let time = 0,
10
+ id = 0
11
+
12
+ const callbacks = new Map<number, () => void>()
13
+ const clock: Clock = {
14
+ now: () => time,
15
+ request: (fn) => {
16
+ callbacks.set(++id, fn)
17
+ return id
18
+ },
19
+ cancel: (key) => {
20
+ callbacks.delete(key)
21
+ },
22
+ }
23
+
24
+ return {
25
+ clock,
26
+ advance(ms: number) {
27
+ time += ms
28
+ const pending = [...callbacks.values()]
29
+ callbacks.clear()
30
+ pending.forEach((fn) => fn())
31
+ },
32
+ count: () => callbacks.size,
33
+ }
34
+ }
35
+
36
+ // Web delegated driver — jsdom ships no `element.animate`, so a fake
37
+ // Animation models the browser: `presented` stands in for the animation
38
+ // cascade over the element's base styles, and cancel() reveals the base
39
+ // again like a real reverted WAAPI run.
40
+ function styleToMatrix(style: string): string {
41
+ if (!style || style === 'none' || style.startsWith('matrix')) {
42
+ return style || 'none'
43
+ }
44
+
45
+ const channel = (name: string, dflt: number) => {
46
+ const match = new RegExp(`${name}\\((-?[\\d.eE+-]+)`).exec(style)
47
+ return match ? Number.parseFloat(match[1]) : dflt
48
+ }
49
+
50
+ const x = channel('translateX', 0)
51
+ const y = channel('translateY', 0)
52
+ const sx = channel('scale', 1) * channel('scaleX', 1)
53
+ const sy = channel('scale', 1) * channel('scaleY', 1)
54
+ const rad = (channel('rotate', 0) * Math.PI) / 180
55
+ return `matrix(${sx * Math.cos(rad)}, ${sy * Math.sin(rad)}, ${-sx * Math.sin(rad)}, ${sy * Math.cos(rad)}, ${x}, ${y})`
56
+ }
57
+
58
+ function fakeWebElement() {
59
+ const animations: any[] = []
60
+ let presented: { transform?: string; opacity?: string } | null = null
61
+ const el: any = {
62
+ style: { transform: '', opacity: '', transformOrigin: '' },
63
+ }
64
+
65
+ el.animate = (keyframes: any[], options: any) => {
66
+ let resolve!: (value: any) => void
67
+ let reject!: (reason: any) => void
68
+ const animation: any = {
69
+ keyframes,
70
+ options,
71
+ cancelled: false,
72
+ finished: new Promise((res, rej) => {
73
+ resolve = res
74
+ reject = rej
75
+ }),
76
+ present(transform: string, opacity?: string) {
77
+ presented = { transform, opacity }
78
+ },
79
+ finish() {
80
+ const last = keyframes[keyframes.length - 1] ?? {}
81
+ presented = {
82
+ transform: last.transform,
83
+ opacity: last.opacity === undefined ? undefined : String(last.opacity),
84
+ }
85
+
86
+ resolve(animation)
87
+ },
88
+ cancel() {
89
+ animation.cancelled = true
90
+ presented = null
91
+ reject(new Error('AbortError'))
92
+ },
93
+ }
94
+
95
+ animations.push(animation)
96
+ return animation
97
+ }
98
+
99
+ const computedFor = (node: any) => ({
100
+ transform: node === el ? (presented?.transform ?? styleToMatrix(el.style.transform)) : 'none',
101
+ opacity:
102
+ node === el
103
+ ? (presented?.opacity ?? (el.style.opacity === '' ? '1' : el.style.opacity))
104
+ : '1',
105
+ transformOrigin: node === el ? el.style.transformOrigin || '0px 0px' : '0px 0px',
106
+ })
107
+
108
+ vi.stubGlobal('getComputedStyle', computedFor)
109
+ return { el, animations }
110
+ }
111
+
112
+ const delegationCounters = () =>
113
+ ((globalThis as any).__xplatMotionDelegations ??= {
114
+ started: 0,
115
+ finished: 0,
116
+ cancelled: 0,
117
+ fallback: 0,
118
+ })
119
+
120
+ describe('web WAAPI delegation', () => {
121
+ beforeEach(() => {
122
+ delete (globalThis as any).__xplatMotionDelegations
123
+ })
124
+
125
+ afterEach(() => {
126
+ vi.unstubAllGlobals()
127
+ })
128
+
129
+ const request = (target: Target, transition: any = { duration: 0.5, ease: 'easeOut' }) => ({
130
+ target,
131
+ dest: { opacity: 1, x: 0, y: 0, scale: 1, scaleX: 1, scaleY: 1, rotate: 0, ...target },
132
+ eff: { opacity: 1, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0, ...target },
133
+ transition,
134
+ })
135
+
136
+ it('hands a bezier tween to element.animate and commits the destination on finish', async () => {
137
+ const { el, animations } = fakeWebElement()
138
+ const written: Target[] = []
139
+ const run = delegatedRun(
140
+ el,
141
+ request({ x: 100, opacity: 0.5 }, { duration: 0.5, delay: 0.1, ease: 'easeOut' }),
142
+ (target) => written.push({ ...target }),
143
+ )
144
+
145
+ expect(run).not.toBeNull()
146
+ expect(animations).toHaveLength(1)
147
+ const animation = animations[0]
148
+ expect(animation.options).toMatchObject({
149
+ duration: 500,
150
+ delay: 100,
151
+ easing: 'cubic-bezier(0, 0, 0.58, 1)',
152
+ fill: 'forwards',
153
+ })
154
+
155
+ expect(animation.keyframes[0].transform).toBe(
156
+ 'translateX(0px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
157
+ )
158
+
159
+ expect(animation.keyframes[1].transform).toBe(
160
+ 'translateX(100px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
161
+ )
162
+
163
+ expect(animation.keyframes[1].opacity).toBe(0.5)
164
+ expect(delegationCounters().started).toBe(1)
165
+
166
+ animation.present('matrix(1, 0, 0, 1, 40, 0)')
167
+ expect(run!.sample()).toMatchObject({ x: 40, scaleX: 1 })
168
+ animation.finish()
169
+ await expect(run!.finished).resolves.toBe('finished')
170
+ expect(written.at(-1)).toMatchObject({ x: 100, opacity: 0.5 })
171
+ expect(delegationCounters().finished).toBe(1)
172
+ })
173
+
174
+ it('samples the presentation before cancelling so the frozen values hold', async () => {
175
+ const { el, animations } = fakeWebElement()
176
+ const written: Target[] = []
177
+ const run = delegatedRun(el, request({ x: 100 }), (target) => written.push({ ...target }))
178
+
179
+ const animation = animations[0]
180
+ animation.present('matrix(0.5, 0, 0, 0.5, 42, 7)')
181
+ run!.cancel()
182
+ expect(animation.cancelled).toBe(true)
183
+ // cancel() reveals the base style — if the sample ran after it, the
184
+ // write would carry identity values instead of the mid-flight frame.
185
+ expect(written.at(-1)).toMatchObject({ x: 42, y: 7, scaleX: 0.5, scaleY: 0.5, rotate: 0 })
186
+ await expect(run!.finished).resolves.toBe('cancelled')
187
+ expect(delegationCounters().cancelled).toBe(1)
188
+ })
189
+
190
+ it('decomposes matrix3d and peels transform-origin out of x/y', () => {
191
+ const { el, animations } = fakeWebElement()
192
+ el.style.transformOrigin = '10px 20px'
193
+ const run = delegatedRun(el, request({ rotate: 90 }), () => {})
194
+
195
+ const animation = animations[0]
196
+ animation.present('matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 25, 5, 0, 1)')
197
+ expect(run!.sample()).toMatchObject({ x: 25, y: 5, scaleX: 1, scaleY: 1, rotate: 0 })
198
+ // rotate(90deg) about a (10, 20) origin resolves to this matrix.
199
+ animation.present('matrix(0, 1, -1, 0, 30, 10)')
200
+ const sampled = run!.sample()
201
+ expect(sampled.rotate).toBeCloseTo(90)
202
+ expect(sampled.x).toBeCloseTo(0)
203
+ expect(sampled.y).toBeCloseTo(0)
204
+ })
205
+
206
+ it('refuses non-bezier eases and elements without a WAAPI driver', () => {
207
+ const { el } = fakeWebElement()
208
+ const run = delegatedRun(el, request({ x: 100 }, { duration: 0.5, ease: 'backOut' }), () => {})
209
+
210
+ expect(run).toBeNull()
211
+ expect(delegatedRun({ style: {} }, request({ x: 50 }), () => {})).toBeNull()
212
+ expect(delegationCounters().fallback).toBe(2)
213
+ expect(delegationCounters().started).toBe(0)
214
+ })
215
+
216
+ it('delegates through the real web adapter and hands mid-flight state to the next run', async () => {
217
+ const { el, animations } = fakeWebElement()
218
+ const time = fakeClock()
219
+ const controller = new Controller(time.clock)
220
+ controller.attach(attachHost(el))
221
+ controller.seed({ x: 0 })
222
+
223
+ const doneFirst = controller.animate({ x: 100 }, { duration: 0.5, ease: 'linear' }, false)
224
+ expect(animations).toHaveLength(1)
225
+ animations[0].present('matrix(1, 0, 0, 1, 30, 0)')
226
+ time.advance(16)
227
+
228
+ const doneSecond = controller.animate({ x: -50 }, { duration: 0.5 }, false)
229
+ expect(animations[0].cancelled).toBe(true)
230
+ // Interruption sampled into the channel value and velocity.
231
+ expect(controller.values.get('x')?.get()).toBe(30)
232
+ expect(controller.values.get('x')?.getVelocity()).toBeGreaterThan(0)
233
+ // The replacement run's "from" keyframe continues the frozen frame.
234
+ expect(animations[1].keyframes[0].transform).toBe(
235
+ 'translateX(30px) translateY(0px) scaleX(1) scaleY(1) rotate(0deg)',
236
+ )
237
+
238
+ expect(await doneFirst).toBe('replaced')
239
+ animations[1].finish()
240
+ expect(await doneSecond).toBe('finished')
241
+ expect(controller.values.get('x')?.get()).toBe(-50)
242
+ expect(el.style.transform).toBe(
243
+ 'translateX(-50px) translateY(0px) scale(1) scaleX(1) scaleY(1) rotate(0deg)',
244
+ )
245
+ })
246
+
247
+ it.each([
248
+ ['a spring', { type: 'spring', stiffness: 100, damping: 10 }, false],
249
+ ['a non-bezier ease', { duration: 0.5, ease: 'backOut' as const }, false],
250
+ ['a repeating run', { duration: 0.5, repeat: 2 }, false],
251
+ ['per-key transitions', { default: { duration: 0.5 }, x: { duration: 1 } }, false],
252
+ ['an instant run', { duration: 0 }, false],
253
+ ['reduced motion', { duration: 0.5 }, true],
254
+ ])('stays on the JS engine for %s', async (_label, transition, reduced) => {
255
+ const { el, animations } = fakeWebElement()
256
+ const time = fakeClock()
257
+ const controller = new Controller(time.clock)
258
+ controller.attach(attachHost(el))
259
+ controller.seed({ x: 0 })
260
+
261
+ const done = controller.animate({ x: 100 }, transition as any, reduced)
262
+ expect(animations).toHaveLength(0)
263
+ for (let i = 0; i < 500; i++) {
264
+ time.advance(16)
265
+ }
266
+
267
+ expect(await done).toBe('finished')
268
+ expect(controller.values.get('x')?.get()).toBe(100)
269
+ })
270
+ })
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { attachPan } from './pan'
3
+ import {
4
+ handlers,
5
+ GestureHandlerStateEvent,
6
+ GestureHandlerTouchEvent,
7
+ GestureState,
8
+ } from '../test/gesturehandler'
9
+
10
+ describe('native handler drag surface', () => {
11
+ it('configures arbitration, normalizes velocity, and detaches handlers', () => {
12
+ const start = vi.fn(),
13
+ move = vi.fn(),
14
+ end = vi.fn()
15
+
16
+ const node = {}
17
+ const off = attachPan(node, 'x', { start, move, end })
18
+ const h = handlers.at(-1)
19
+ expect(h.node).toBe(node)
20
+ expect(h.options).toMatchObject({
21
+ activeOffsetXStart: -8,
22
+ activeOffsetXEnd: 8,
23
+ failOffsetYStart: -8,
24
+ failOffsetYEnd: 8,
25
+ })
26
+
27
+ h.emit(GestureHandlerStateEvent, { state: GestureState.BEGAN })
28
+ expect(start).not.toHaveBeenCalled()
29
+ h.emit(GestureHandlerStateEvent, {
30
+ state: GestureState.ACTIVE,
31
+ extraData: { translationX: 10, velocityX: 300 },
32
+ })
33
+
34
+ h.emit(GestureHandlerTouchEvent, {
35
+ state: GestureState.ACTIVE,
36
+ extraData: { translationX: 25, velocityX: 400 },
37
+ })
38
+
39
+ expect(move.mock.calls.at(-1)?.[1]).toMatchObject({
40
+ offset: { x: 25, y: 0 },
41
+ delta: { x: 15, y: 0 },
42
+ velocity: { x: 400, y: 0 },
43
+ })
44
+
45
+ h.emit(GestureHandlerStateEvent, {
46
+ state: GestureState.CANCELLED,
47
+ extraData: { translationX: 25 },
48
+ })
49
+
50
+ h.emit(GestureHandlerStateEvent, { state: GestureState.END })
51
+ expect(end).toHaveBeenCalledTimes(1)
52
+ expect(end.mock.calls[0][1].cancelled).toBe(true)
53
+ off()
54
+ expect(h.node).toBeUndefined()
55
+ h.emit(GestureHandlerTouchEvent, { state: GestureState.ACTIVE })
56
+ expect(move).toHaveBeenCalledTimes(2)
57
+ })
58
+
59
+ it('uses minDist for both axes and fails perpendicular movement for vertical drag', () => {
60
+ const callbacks = { start() {}, move() {}, end() {} }
61
+ const both = attachPan({}, true, callbacks)
62
+ expect(handlers.at(-1).options.minDist).toBe(8)
63
+ both()
64
+ const vertical = attachPan({}, 'y', callbacks)
65
+ expect(handlers.at(-1).options).toMatchObject({ activeOffsetYEnd: 8, failOffsetXStart: -8 })
66
+ vertical()
67
+ vi.stubGlobal('android', {})
68
+ const density = attachPan({}, true, callbacks)
69
+ expect(handlers.at(-1).options.minDist).toBe(16)
70
+ density()
71
+ const directional = attachPan({}, 'x', callbacks)
72
+ expect(handlers.at(-1).options).toMatchObject({
73
+ minDist: 1e9,
74
+ activeOffsetXEnd: 16,
75
+ failOffsetYEnd: 16,
76
+ })
77
+
78
+ directional()
79
+ vi.unstubAllGlobals()
80
+ })
81
+ })
@@ -0,0 +1,167 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { attachDrag, validateDrag } from './drag'
3
+ import { Controller } from './controller'
4
+ import type { Clock } from './clock-types'
5
+ import type { PanCallbacks, HostAdapter } from './host-types'
6
+ import type { DragInfo, MotionProps } from './types'
7
+
8
+ function fixture(props: MotionProps, reduced = false) {
9
+ let time = 0
10
+ let id = 0
11
+ const frames = new Map<number, () => void>()
12
+ const clock: Clock = {
13
+ now: () => time,
14
+ request: (fn) => {
15
+ frames.set(++id, fn)
16
+ return id
17
+ },
18
+ cancel: (key) => {
19
+ frames.delete(key)
20
+ },
21
+ }
22
+
23
+ const controller = new Controller(clock)
24
+ controller.seed({ x: 10, y: 20 })
25
+ let callbacks: PanCallbacks
26
+ const detach = vi.fn()
27
+ const delegate = vi.fn()
28
+ const writes: number[] = []
29
+ const adapter: HostAdapter = {
30
+ read: () => ({}),
31
+ write: (next) => {
32
+ writes.push(next.x ?? 0)
33
+ },
34
+ restore() {},
35
+ delegate,
36
+ pan(_axis, cb) {
37
+ callbacks = cb
38
+ return detach
39
+ },
40
+ }
41
+
42
+ controller.attach(adapter)
43
+ const off = attachDrag(
44
+ adapter,
45
+ controller,
46
+ props,
47
+ (axis) => controller.value(axis),
48
+ () => reduced,
49
+ () => props,
50
+ )
51
+
52
+ return {
53
+ controller,
54
+ off,
55
+ detach,
56
+ delegate,
57
+ writes,
58
+ emit(phase: keyof PanCallbacks, x: number, velocity = 0, cancelled = false) {
59
+ const info: DragInfo = {
60
+ point: { x, y: 0 },
61
+ offset: { x, y: x },
62
+ delta: { x, y: 0 },
63
+ velocity: { x: velocity, y: velocity },
64
+ cancelled,
65
+ }
66
+
67
+ callbacks[phase]({}, info)
68
+ },
69
+ advance(ms: number) {
70
+ time += ms
71
+ const pending = [...frames.values()]
72
+ frames.clear()
73
+ pending.forEach((fn) => fn())
74
+ },
75
+ count: () => frames.size,
76
+ }
77
+ }
78
+
79
+ describe('bounded drag', () => {
80
+ it('clamps live displacement, keeps the other axis, and emits callbacks once', () => {
81
+ const onDragStart = vi.fn(),
82
+ onDrag = vi.fn(),
83
+ onDragEnd = vi.fn()
84
+
85
+ const f = fixture({
86
+ drag: 'x',
87
+ dragConstraints: { left: -20, right: 30 },
88
+ dragElastic: 0,
89
+ dragMomentum: false,
90
+ onDragStart,
91
+ onDrag,
92
+ onDragEnd,
93
+ })
94
+
95
+ f.emit('start', 0)
96
+ f.emit('move', 100)
97
+ expect(f.controller.snapshot()).toEqual({ x: 30, y: 20 })
98
+ f.emit('end', 100, 500)
99
+ f.emit('end', 100)
100
+ expect(onDragStart).toHaveBeenCalledTimes(1)
101
+ expect(onDrag).toHaveBeenCalledTimes(1)
102
+ expect(onDragEnd).toHaveBeenCalledTimes(1)
103
+ expect(f.count()).toBe(0)
104
+ f.off()
105
+ expect(f.detach).toHaveBeenCalledOnce()
106
+ })
107
+
108
+ it('uses default elasticity and springs back inside constraints without momentum', () => {
109
+ const f = fixture({ drag: 'x', dragConstraints: { right: 30 }, dragMomentum: false })
110
+ f.emit('start', 0)
111
+ f.emit('move', 100)
112
+ expect(f.controller.value('x').get()).toBe(58)
113
+ f.emit('end', 100)
114
+ for (let i = 0; i < 200; i++) {
115
+ f.advance(16)
116
+ }
117
+
118
+ expect(f.controller.value('x').get()).toBe(30)
119
+ expect(f.delegate).not.toHaveBeenCalled()
120
+ f.off()
121
+ })
122
+
123
+ it('hands release velocity to a JS spring and bounds every hard-constraint sample', () => {
124
+ const f = fixture({ drag: 'x', dragConstraints: { left: -20, right: 50 }, dragElastic: false })
125
+ f.emit('start', 0)
126
+ f.emit('move', 10)
127
+ f.emit('end', 10, 500)
128
+ f.advance(16)
129
+ expect(f.controller.value('x').get()).toBeGreaterThan(20)
130
+ for (let i = 0; i < 200; i++) {
131
+ f.advance(16)
132
+ }
133
+
134
+ expect(f.controller.value('x').get()).toBe(50)
135
+ expect(Math.max(...f.writes)).toBeLessThanOrEqual(50)
136
+ expect(f.delegate).not.toHaveBeenCalled()
137
+ f.off()
138
+ })
139
+
140
+ it('cancellation drops momentum; reduced motion snaps; teardown cancels settlement', () => {
141
+ const cancel = fixture({ drag: true })
142
+ cancel.emit('start', 0)
143
+ cancel.emit('move', 20)
144
+ cancel.emit('end', 20, 500, true)
145
+ expect(cancel.count()).toBe(0)
146
+ cancel.off()
147
+ const reduced = fixture({ drag: 'x' }, true)
148
+ reduced.emit('start', 0)
149
+ reduced.emit('end', 10, 500)
150
+ expect(reduced.controller.value('x').get()).toBe(120)
151
+ expect(reduced.count()).toBe(0)
152
+ reduced.off()
153
+ const f = fixture({ drag: 'x' })
154
+ f.emit('start', 0)
155
+ f.emit('end', 10, 500)
156
+ expect(f.count()).toBe(1)
157
+ f.off()
158
+ expect(f.count()).toBe(0)
159
+ })
160
+
161
+ it('rejects refs, invalid bounds, elasticity and competing axis targets', () => {
162
+ expect(() => validateDrag({ dragConstraints: { current: null } } as any)).toThrow('refs')
163
+ expect(() => validateDrag({ dragConstraints: { left: 20, right: 10 } })).toThrow('reversed')
164
+ expect(() => validateDrag({ dragElastic: 2 })).toThrow('dragElastic')
165
+ expect(() => validateDrag({ drag: 'x', animate: { x: 20 } })).toThrow('both own x')
166
+ })
167
+ })
package/src/drag.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { Controller } from './controller'
2
+ import type { HostAdapter } from './host-types'
3
+ import type { DragInfo, MotionProps } from './types'
4
+ import type { MotionValue } from './value'
5
+
6
+ export function validateDrag(props: MotionProps) {
7
+ if (props.drag !== undefined && ![true, false, 'x', 'y'].includes(props.drag)) {
8
+ throw new Error('motion: drag must be boolean, x, or y')
9
+ }
10
+
11
+ const bounds = props.dragConstraints ?? {}
12
+ for (const [key, value] of Object.entries(bounds)) {
13
+ if (!['left', 'right', 'top', 'bottom'].includes(key) || !Number.isFinite(value)) {
14
+ throw new Error('motion: dragConstraints requires finite numeric edges; refs are deferred')
15
+ }
16
+ }
17
+
18
+ if (
19
+ (bounds.left ?? -Infinity) > (bounds.right ?? Infinity) ||
20
+ (bounds.top ?? -Infinity) > (bounds.bottom ?? Infinity)
21
+ ) {
22
+ throw new Error('motion: dragConstraints edges are reversed')
23
+ }
24
+
25
+ if (
26
+ typeof props.dragElastic !== 'boolean' &&
27
+ props.dragElastic !== undefined &&
28
+ (!Number.isFinite(props.dragElastic) || props.dragElastic < 0 || props.dragElastic > 1)
29
+ ) {
30
+ throw new Error('motion: dragElastic must be boolean or a number from 0 to 1')
31
+ }
32
+
33
+ if (props.drag) {
34
+ for (const axis of ['x', 'y'] as const) {
35
+ if (props.drag !== true && props.drag !== axis) {
36
+ continue
37
+ }
38
+
39
+ for (const target of [props.animate, props.whileTap, props.whileFocus]) {
40
+ if (
41
+ target &&
42
+ typeof target === 'object' &&
43
+ !Array.isArray(target) &&
44
+ target[axis] !== undefined
45
+ ) {
46
+ throw new Error(`motion: drag and animation both own ${axis}`)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
54
+
55
+ /** Gesture writes and release springs bypass host delegation. */
56
+ export function attachDrag(
57
+ adapter: HostAdapter,
58
+ controller: Controller,
59
+ props: MotionProps,
60
+ valueFor: (axis: 'x' | 'y') => MotionValue,
61
+ reduced: () => boolean,
62
+ callbacks: () => MotionProps,
63
+ ) {
64
+ if (!props.drag) {
65
+ return () => {}
66
+ }
67
+
68
+ if (!adapter.pan) {
69
+ throw new Error('motion: this host does not support drag')
70
+ }
71
+
72
+ const axes = (['x', 'y'] as const).filter((axis) => props.drag === true || props.drag === axis)
73
+ const values = new Map(axes.map((axis) => [axis, valueFor(axis)]))
74
+ const value = (axis: 'x' | 'y') => values.get(axis)!
75
+ const bounds = props.dragConstraints ?? {}
76
+ const elastic =
77
+ typeof props.dragElastic === 'number'
78
+ ? props.dragElastic
79
+ : props.dragElastic === false
80
+ ? 0
81
+ : 0.35
82
+
83
+ const origin = { x: 0, y: 0 }
84
+ let active = false
85
+ const limits = (axis: 'x' | 'y') =>
86
+ axis === 'x'
87
+ ? [bounds.left ?? -Infinity, bounds.right ?? Infinity]
88
+ : [bounds.top ?? -Infinity, bounds.bottom ?? Infinity]
89
+
90
+ const move = (info: DragInfo) => {
91
+ for (const axis of axes) {
92
+ const [min, max] = limits(axis)
93
+ const next = origin[axis] + info.offset[axis]
94
+ const bounded = clamp(next, min, max)
95
+ value(axis).jump(bounded + (next - bounded) * elastic)
96
+ }
97
+ }
98
+
99
+ const off = adapter.pan(props.drag, {
100
+ start(event, info) {
101
+ controller.stop()
102
+ active = true
103
+ for (const axis of axes) {
104
+ const channel = value(axis)
105
+ channel.stop()
106
+ origin[axis] = channel.get()
107
+ }
108
+
109
+ callbacks().onDragStart?.(event, info)
110
+ },
111
+ move(event, info) {
112
+ if (!active) {
113
+ return
114
+ }
115
+
116
+ move(info)
117
+ callbacks().onDrag?.(event, info)
118
+ },
119
+ end(event, info) {
120
+ if (!active) {
121
+ return
122
+ }
123
+
124
+ active = false
125
+ move(info)
126
+ for (const axis of axes) {
127
+ const channel = value(axis)
128
+ const velocity = info.cancelled || props.dragMomentum === false ? 0 : info.velocity[axis]
129
+ const [min, max] = limits(axis)
130
+ const target = clamp(channel.get() + velocity * 0.2, min, max)
131
+ if (elastic === 0) {
132
+ channel.jump(clamp(channel.get(), min, max))
133
+ }
134
+
135
+ if (reduced()) {
136
+ channel.jump(target)
137
+ } else if (target !== channel.get() || velocity !== 0) {
138
+ channel.animate(
139
+ target,
140
+ { type: 'spring', stiffness: 200, damping: 30, velocity },
141
+ elastic === 0 ? (next) => clamp(next, min, max) : undefined,
142
+ )
143
+ }
144
+ }
145
+
146
+ callbacks().onDragEnd?.(event, info)
147
+ },
148
+ })
149
+
150
+ return () => {
151
+ active = false
152
+ off()
153
+ for (const channel of values.values()) {
154
+ channel.stop()
155
+ }
156
+ }
157
+ }