@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.
- package/README.md +31 -2
- package/UPSTREAM.md +284 -16
- package/dist/native/index.js +980 -112
- package/dist/native/octane-client-build.json +1 -1
- package/dist/web/index.js +1104 -166
- package/dist/web/octane-client-build.json +1 -1
- package/package.json +8 -3
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -0
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +234 -1
- package/src/controller.ts +122 -10
- package/src/delegation.test.ts +269 -0
- package/src/delegation.ts +29 -0
- package/src/delegation.web.test.ts +270 -0
- package/src/drag.mobile.test.ts +81 -0
- package/src/drag.test.ts +167 -0
- package/src/drag.ts +157 -0
- package/src/driver.ts +249 -0
- package/src/driver.web.ts +180 -0
- package/src/engine.test.ts +86 -0
- package/src/engine.ts +137 -15
- package/src/hooks.tsrx +53 -2
- package/src/host-types.ts +44 -1
- package/src/host.ts +39 -2
- package/src/host.web.ts +51 -10
- package/src/index.ts +18 -4
- package/src/index.web.ts +18 -4
- package/src/pan.ts +92 -0
- package/src/pan.web.ts +116 -0
- package/src/presence.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +99 -10
- package/src/value.ts +15 -5
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +7 -1
- package/types/index.d.ts +22 -1
- package/types/types.d.ts +93 -10
- package/types/value.d.ts +4 -1
|
@@ -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
|
+
})
|
package/src/drag.test.ts
ADDED
|
@@ -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
|
+
}
|