@octane-xplat/motion 0.7.3 → 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 +295 -27
- 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 +25 -16
- package/src/clock.ts +13 -1
- package/src/components.mobile.test.tsrx +58 -1
- package/src/components.tsrx +185 -39
- package/src/components.web.test.tsrx +236 -3
- 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 +99 -0
- package/src/engine.ts +140 -18
- 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-input.ts +11 -4
- package/src/presence-input.web.ts +13 -4
- package/src/presence-state.ts +27 -7
- package/src/presence.tsrx +6 -7
- package/src/reduced-motion.ts +23 -4
- package/src/types.ts +100 -11
- package/src/value.ts +18 -8
- package/src/variants.test.ts +197 -0
- package/src/variants.ts +226 -0
- package/types/engine.d.ts +8 -2
- package/types/index.d.ts +34 -6
- package/types/types.d.ts +94 -11
- package/types/value.d.ts +7 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { act, createRoot, useEffect } from 'octane'
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { motion, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
|
|
3
|
+
import { motion, Presence, MotionConfig, useMotionValue, useTransform, useSpring } from './index.web'
|
|
4
4
|
|
|
5
5
|
function Box(props: any) @{
|
|
6
6
|
<motion.View id="box" initial={{ x: 20 }} animate={props.target} transition={{ duration: 0 }} onAnimationComplete={props.done} />
|
|
@@ -16,7 +16,7 @@ function Values(props: any) @{
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function Reduced() @{
|
|
19
|
-
<MotionConfig reducedMotion="always"><motion.Pressable id="reduced" initial={{ x: 0 }} animate={{ x: 100 }} transition={{
|
|
19
|
+
<MotionConfig reducedMotion="always"><motion.Pressable id="reduced" initial={{ x: 0 }} animate={{ x: 100 }} transition={{ type: 'spring' }} /></MotionConfig>
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function mount(component: any) {
|
|
@@ -121,7 +121,7 @@ it('retargets from a render-time signal read', () => {
|
|
|
121
121
|
})
|
|
122
122
|
|
|
123
123
|
function UserReduced() @{
|
|
124
|
-
<MotionConfig reducedMotion="user"><motion.View id="user-reduced" animate={{ x: 100 }} transition={{
|
|
124
|
+
<MotionConfig reducedMotion="user"><motion.View id="user-reduced" animate={{ x: 100 }} transition={{ type: 'spring' }} /></MotionConfig>
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
it('observes live system reduced-motion changes', () => {
|
|
@@ -133,3 +133,236 @@ it('observes live system reduced-motion changes', () => {
|
|
|
133
133
|
expect((mounted.container.querySelector('#user-reduced') as HTMLElement).style.transform).toContain('translateX(100px)')
|
|
134
134
|
mounted.close(); expect(callbacks.size).toBe(0); vi.unstubAllGlobals()
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
import { useAnimate } from './index.web'
|
|
138
|
+
import { View } from '@octane-xplat/ui'
|
|
139
|
+
|
|
140
|
+
function TapScale() @{
|
|
141
|
+
<motion.Pressable id="tap" animate={{ scale: 1 }} whileTap={{ scale: 1.5 }} whileTapTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe('upstream-shaped props', () => {
|
|
145
|
+
it('animates whileTap on pointerdown and returns on release', () => {
|
|
146
|
+
const mounted = mount(<TapScale />)
|
|
147
|
+
const node = mounted.container.querySelector('#tap') as HTMLElement
|
|
148
|
+
node.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
|
149
|
+
expect(node.style.transform).toContain('scale(1.5)')
|
|
150
|
+
node.dispatchEvent(new Event('pointerup', { bubbles: true }))
|
|
151
|
+
expect(node.style.transform).toContain('scale(1)')
|
|
152
|
+
mounted.close()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('animates whileFocus on focus and returns on blur', () => {
|
|
156
|
+
const mounted = mount(<TapFocusCase />)
|
|
157
|
+
const node = mounted.container.querySelector('#focusable') as HTMLElement
|
|
158
|
+
node.dispatchEvent(new Event('focus'))
|
|
159
|
+
expect(node.style.transform).toContain('translateX(20px)')
|
|
160
|
+
node.dispatchEvent(new Event('blur'))
|
|
161
|
+
expect(node.style.transform).toContain('translateX(0px)')
|
|
162
|
+
mounted.close()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('fires onAnimationStart and onUpdate for declarative runs', () => {
|
|
166
|
+
const mounted = mount(<CallbackCase />)
|
|
167
|
+
expect(callbackState.started).toBe(1)
|
|
168
|
+
expect(callbackState.updated).toBeGreaterThan(0)
|
|
169
|
+
mounted.close()
|
|
170
|
+
callbackState.started = 0
|
|
171
|
+
callbackState.updated = 0
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('wraps an arbitrary leaf with motion.create', () => {
|
|
175
|
+
const mounted = mount(<CreatedCase />)
|
|
176
|
+
const node = mounted.container.querySelector('#created') as HTMLElement
|
|
177
|
+
expect(node.style.transform).toContain('translateX(30px)')
|
|
178
|
+
mounted.close()
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('animates through useAnimate scope and a MotionValue directly', async () => {
|
|
182
|
+
const mounted = mount(<ScopedCase />)
|
|
183
|
+
const node = mounted.container.querySelector('#scoped') as HTMLElement
|
|
184
|
+
await scopedApi.animate({ x: 40 }, { duration: 0 })
|
|
185
|
+
expect(node.style.transform).toContain('translateX(40px)')
|
|
186
|
+
const direct = scopedApi.animate(scopedApi.value, 77, { duration: 0 })
|
|
187
|
+
await direct.finished
|
|
188
|
+
expect(scopedApi.value.get()).toBe(77)
|
|
189
|
+
mounted.close()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const callbackState = { started: 0, updated: 0 }
|
|
194
|
+
function CallbackCase() @{
|
|
195
|
+
<motion.View
|
|
196
|
+
id="callbacks"
|
|
197
|
+
animate={{ x: 5 }}
|
|
198
|
+
transition={{ duration: 0 }}
|
|
199
|
+
onAnimationStart={() => callbackState.started++}
|
|
200
|
+
onUpdate={() => callbackState.updated++}
|
|
201
|
+
/>
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function TapFocusCase() @{
|
|
205
|
+
<motion.View id="focusable" animate={{ x: 0 }} whileFocus={{ x: 20 }} whileFocusTransition={{ duration: 0 }} transition={{ duration: 0.01 }} />
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const MotionCard = motion.create(function Card(props: any) @{
|
|
209
|
+
<View {...props} />
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
function CreatedCase() @{
|
|
213
|
+
<MotionCard id="created" animate={{ x: 30 }} transition={{ duration: 0 }} />
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const scopedApi: { animate: any; value: MotionValue } = { animate: undefined, value: undefined as any }
|
|
217
|
+
function ScopedCase() @{
|
|
218
|
+
const [scope, animate] = useAnimate()
|
|
219
|
+
const value = useMotionValue(0)
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
scopedApi.animate = animate
|
|
222
|
+
scopedApi.value = value
|
|
223
|
+
}, [])
|
|
224
|
+
|
|
225
|
+
<motion.View id="scoped" ref={scope} />
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function DragBox(props: any) @{
|
|
229
|
+
<MotionConfig reducedMotion="never"><motion.View id="drag" {...props} /></MotionConfig>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function pointer(node: HTMLElement, type: string, x: number, y = 0, pointerId = 1) {
|
|
233
|
+
const event = new MouseEvent(type, { clientX: x, clientY: y, button: 0, bubbles: true })
|
|
234
|
+
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
|
235
|
+
node.dispatchEvent(event)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('declarative pointer drag', () => {
|
|
239
|
+
it('writes a bound MotionValue live, captures one pointer, clamps, and cleans up', () => {
|
|
240
|
+
const x = new MotionValue(10, clock)
|
|
241
|
+
const started = vi.fn(), moved = vi.fn(), ended = vi.fn()
|
|
242
|
+
const mounted = mount(<DragBox drag="x" style={{ x }} dragConstraints={{ left: -30, right: 30 }} dragElastic={false} dragMomentum={false} onDragStart={started} onDrag={moved} onDragEnd={ended} />)
|
|
243
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
244
|
+
node.setPointerCapture = vi.fn()
|
|
245
|
+
expect(node.style.touchAction).toBe('pan-y')
|
|
246
|
+
pointer(node, 'pointerdown', 0)
|
|
247
|
+
pointer(node, 'pointermove', 4)
|
|
248
|
+
expect(started).not.toHaveBeenCalled()
|
|
249
|
+
pointer(node, 'pointermove', 100, 0, 2)
|
|
250
|
+
expect(x.get()).toBe(10)
|
|
251
|
+
pointer(node, 'pointermove', 100)
|
|
252
|
+
expect(x.get()).toBe(30)
|
|
253
|
+
expect(node.style.transform).toContain('translateX(30px)')
|
|
254
|
+
pointer(node, 'pointerup', 100)
|
|
255
|
+
expect(started).toHaveBeenCalledTimes(1)
|
|
256
|
+
expect(ended).toHaveBeenCalledTimes(1)
|
|
257
|
+
expect(node.setPointerCapture).toHaveBeenCalledWith(1)
|
|
258
|
+
mounted.close()
|
|
259
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', -100)
|
|
260
|
+
expect(x.get()).toBe(30)
|
|
261
|
+
expect(node.style.touchAction).toBe('')
|
|
262
|
+
x.destroy()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('updates drag axis and disables listeners on prop changes', () => {
|
|
266
|
+
const mounted = mount(<DragBox drag="x" dragMomentum={false} />)
|
|
267
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
268
|
+
act(() => mounted.root.render(<DragBox drag="y" dragMomentum={false} />))
|
|
269
|
+
expect(node.style.touchAction).toBe('pan-x')
|
|
270
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30); pointer(node, 'pointerup', 0, 30)
|
|
271
|
+
expect(node.style.transform).toContain('translateY(30px)')
|
|
272
|
+
act(() => mounted.root.render(<DragBox drag={false} />))
|
|
273
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 80)
|
|
274
|
+
expect(node.style.transform).toContain('translateY(30px)')
|
|
275
|
+
mounted.close()
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('yields perpendicular movement and reports active cancellation once', () => {
|
|
279
|
+
const end = vi.fn(), start = vi.fn()
|
|
280
|
+
const mounted = mount(<DragBox drag="x" dragMomentum={false} onDragStart={start} onDragEnd={end} />)
|
|
281
|
+
const node = mounted.container.querySelector('#drag') as HTMLElement
|
|
282
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 0, 30)
|
|
283
|
+
expect(start).not.toHaveBeenCalled()
|
|
284
|
+
pointer(node, 'pointerdown', 0); pointer(node, 'pointermove', 30)
|
|
285
|
+
pointer(node, 'pointercancel', 30); pointer(node, 'lostpointercapture', 30)
|
|
286
|
+
expect(end).toHaveBeenCalledTimes(1)
|
|
287
|
+
expect(end.mock.calls[0][1].cancelled).toBe(true)
|
|
288
|
+
mounted.close()
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
function VariantTree(props: any) @{
|
|
293
|
+
<motion.View initial="hidden" animate={props.label} variants={{ hidden: { x: -10 }, show: { x: 10, transition: { duration: 0, when: 'beforeChildren' } }, hide: { x: 0, transition: { duration: 0 } } }}>
|
|
294
|
+
<motion.View id="variant-child" custom={props.custom} variants={{ hidden: { x: -20 }, show: (custom: number) => ({ x: custom, transition: { duration: 0 } }), hide: { x: 0, transition: { duration: 0 } } }} />
|
|
295
|
+
<motion.View id="variant-explicit" animate={{ x: 99 }} transition={{ duration: 0 }} />
|
|
296
|
+
</motion.View>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function VariantGesture() @{
|
|
300
|
+
<motion.View id="variant-gesture" animate={['base', 'position']} whileTap="tap" whileFocus="focus" variants={{ base: { scale: 1, transition: { duration: 0 } }, position: { x: 5 }, tap: { scale: 2, transition: { duration: 0 } }, focus: { x: 30, transition: { duration: 0 } } }} />
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
it('inherits labels and initial targets while explicit animate remains independent', async () => {
|
|
304
|
+
const mounted = mount(<VariantTree label="show" custom={40} />)
|
|
305
|
+
const child = mounted.container.querySelector('#variant-child') as HTMLElement
|
|
306
|
+
await act(async () => {})
|
|
307
|
+
expect(child.style.transform).toContain('translateX(40px)')
|
|
308
|
+
expect((mounted.container.querySelector('#variant-explicit') as HTMLElement).style.transform).toContain('translateX(99px)')
|
|
309
|
+
act(() => mounted.root.render(<VariantTree label="hide" custom={40} />))
|
|
310
|
+
await act(async () => {})
|
|
311
|
+
expect(mounted.container.querySelector('#variant-child')).toBe(child)
|
|
312
|
+
expect(child.style.transform).toContain('translateX(0px)')
|
|
313
|
+
mounted.close()
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('resolves gesture labels and restores the resolved animate target', () => {
|
|
317
|
+
const mounted = mount(<VariantGesture />)
|
|
318
|
+
const host = mounted.container.querySelector('#variant-gesture') as HTMLElement
|
|
319
|
+
host.dispatchEvent(new Event('pointerdown'))
|
|
320
|
+
expect(host.style.transform).toContain('scale(2)')
|
|
321
|
+
host.dispatchEvent(new Event('pointerup'))
|
|
322
|
+
expect(host.style.transform).toContain('scale(1)')
|
|
323
|
+
host.dispatchEvent(new Event('focus'))
|
|
324
|
+
expect(host.style.transform).toContain('translateX(30px)')
|
|
325
|
+
host.dispatchEvent(new Event('blur'))
|
|
326
|
+
expect(host.style.transform).toContain('translateX(5px)')
|
|
327
|
+
mounted.close()
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('retargets an inherited resolver when custom changes without a new parent label', async () => {
|
|
331
|
+
const mounted = mount(<VariantTree label="show" custom={40} />)
|
|
332
|
+
await act(async () => {})
|
|
333
|
+
act(() => mounted.root.render(<VariantTree label="show" custom={60} />))
|
|
334
|
+
await act(async () => {})
|
|
335
|
+
expect((mounted.container.querySelector('#variant-child') as HTMLElement).style.transform).toContain('translateX(60px)')
|
|
336
|
+
mounted.close()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
function InitialVariantTree() @{
|
|
340
|
+
<motion.View initial={false} animate="show" variants={{ show: { x: 10 } }}>
|
|
341
|
+
<motion.View id="initial-variant" variants={{ show: { x: 90 } }} />
|
|
342
|
+
</motion.View>
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
it('inherits initial=false and seeds child destinations without playback', () => {
|
|
346
|
+
const mounted = mount(<InitialVariantTree />)
|
|
347
|
+
expect((mounted.container.querySelector('#initial-variant') as HTMLElement).style.transform).toContain('translateX(90px)')
|
|
348
|
+
mounted.close()
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
function VariantExit(props: any) @{
|
|
352
|
+
<Presence present={props.present} onExitComplete={props.done}>
|
|
353
|
+
<motion.View id="variant-exit" initial={false} animate="show" exit={['hide', 'offset']} variants={{ show: { x: 0 }, hide: { x: 70, transition: { duration: 0.1 } }, offset: { x: 100 } }} transition={{ duration: 20 }} />
|
|
354
|
+
</Presence>
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
it('uses exit label arrays and their transition overrides under Presence', async () => {
|
|
358
|
+
vi.useFakeTimers()
|
|
359
|
+
const done = vi.fn()
|
|
360
|
+
const mounted = mount(<VariantExit present={true} done={done} />)
|
|
361
|
+
act(() => mounted.root.render(<VariantExit present={false} done={done} />))
|
|
362
|
+
expect(mounted.container.querySelector('#variant-exit')).not.toBeNull()
|
|
363
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(160) })
|
|
364
|
+
expect(mounted.container.querySelector('#variant-exit')).toBeNull()
|
|
365
|
+
expect(done).toHaveBeenCalledOnce()
|
|
366
|
+
mounted.close()
|
|
367
|
+
vi.useRealTimers()
|
|
368
|
+
})
|
package/src/controller.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { MotionValue } from './value'
|
|
2
2
|
import type { Clock } from './clock-types'
|
|
3
|
-
import type {
|
|
4
|
-
import type {
|
|
3
|
+
import type { HostAdapter, DelegatedRun } from './host-types'
|
|
4
|
+
import type { Target, MotionKey, TransitionInput } from './types'
|
|
5
|
+
import { isBezierEase, isOrchestrated, resolveTransition, type AnimationResult } from './engine'
|
|
5
6
|
|
|
6
7
|
export const keys: MotionKey[] = ['opacity', 'x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
|
|
7
8
|
export const defaults: Required<Target> = {
|
|
@@ -24,10 +25,13 @@ export function validateTarget(target?: Target) {
|
|
|
24
25
|
|
|
25
26
|
export class Controller {
|
|
26
27
|
readonly values = new Map<MotionKey, MotionValue>()
|
|
27
|
-
|
|
28
|
+
/** Per-write snapshot callback (rAF-rate during delegated and JS runs). */
|
|
29
|
+
onUpdate?: (snapshot: Target) => void
|
|
30
|
+
private adapter?: HostAdapter
|
|
31
|
+
private delegated?: { run: DelegatedRun; frame: number; trackSample: () => void }
|
|
28
32
|
private generation = 0
|
|
29
33
|
constructor(private clock: Clock) {}
|
|
30
|
-
|
|
34
|
+
value(key: MotionKey) {
|
|
31
35
|
let value = this.values.get(key)
|
|
32
36
|
if (!value) {
|
|
33
37
|
value = new MotionValue(defaults[key], this.clock)
|
|
@@ -37,15 +41,27 @@ export class Controller {
|
|
|
37
41
|
|
|
38
42
|
return value
|
|
39
43
|
}
|
|
40
|
-
attach(
|
|
41
|
-
this.
|
|
44
|
+
attach(adapter: HostAdapter) {
|
|
45
|
+
this.adapter = adapter
|
|
42
46
|
this.flush()
|
|
43
47
|
}
|
|
48
|
+
private cancelDelegated() {
|
|
49
|
+
if (!this.delegated) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.clock.cancel(this.delegated.frame)
|
|
54
|
+
this.delegated.trackSample()
|
|
55
|
+
this.delegated.run.cancel()
|
|
56
|
+
this.delegated = undefined
|
|
57
|
+
}
|
|
44
58
|
snapshot(): Target {
|
|
45
59
|
return Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
|
|
46
60
|
}
|
|
47
61
|
flush() {
|
|
48
|
-
|
|
62
|
+
const snapshot = Object.fromEntries([...this.values].map(([key, value]) => [key, value.get()]))
|
|
63
|
+
this.adapter?.write(snapshot)
|
|
64
|
+
this.onUpdate?.(snapshot)
|
|
49
65
|
}
|
|
50
66
|
set(key: MotionKey, next: number) {
|
|
51
67
|
this.value(key).jump(next)
|
|
@@ -58,19 +74,114 @@ export class Controller {
|
|
|
58
74
|
}
|
|
59
75
|
async animate(
|
|
60
76
|
target: Target,
|
|
61
|
-
transition:
|
|
77
|
+
transition: TransitionInput,
|
|
62
78
|
reduced: boolean,
|
|
63
79
|
): Promise<AnimationResult> {
|
|
64
80
|
validateTarget(target)
|
|
65
81
|
const generation = ++this.generation
|
|
82
|
+
this.cancelDelegated()
|
|
66
83
|
// A new destination owns the entire declarative animation, including removed keys.
|
|
67
84
|
for (const value of this.values.values()) {
|
|
68
85
|
value.stop('replaced')
|
|
69
86
|
}
|
|
70
87
|
|
|
88
|
+
if (
|
|
89
|
+
!reduced &&
|
|
90
|
+
!isOrchestrated(transition) &&
|
|
91
|
+
transition.type !== 'spring' &&
|
|
92
|
+
(transition.duration ?? 0.3) > 0 &&
|
|
93
|
+
(transition.repeat ?? 0) === 0 &&
|
|
94
|
+
isBezierEase(transition.ease) &&
|
|
95
|
+
this.adapter?.delegate
|
|
96
|
+
) {
|
|
97
|
+
const dest: Target = { ...this.snapshot(), ...target }
|
|
98
|
+
const run = this.adapter.delegate({
|
|
99
|
+
target,
|
|
100
|
+
dest,
|
|
101
|
+
eff: {
|
|
102
|
+
opacity: dest.opacity,
|
|
103
|
+
x: dest.x,
|
|
104
|
+
y: dest.y,
|
|
105
|
+
scaleX: (dest.scale ?? 1) * (dest.scaleX ?? 1),
|
|
106
|
+
scaleY: (dest.scale ?? 1) * (dest.scaleY ?? 1),
|
|
107
|
+
rotate: dest.rotate,
|
|
108
|
+
},
|
|
109
|
+
transition,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (run) {
|
|
113
|
+
const trackSample = () => {
|
|
114
|
+
const eff = run.sample()
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (target[key] === undefined) {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let next: number | undefined
|
|
121
|
+
if (key === 'scale') {
|
|
122
|
+
next = (eff.scaleX ?? 1) / (dest.scaleX || 1)
|
|
123
|
+
} else if (key === 'scaleX') {
|
|
124
|
+
next = (eff.scaleX ?? 1) / (dest.scale || 1)
|
|
125
|
+
} else if (key === 'scaleY') {
|
|
126
|
+
next = (eff.scaleY ?? 1) / (dest.scale || 1)
|
|
127
|
+
} else {
|
|
128
|
+
next = eff[key]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (next !== undefined && Number.isFinite(next)) {
|
|
132
|
+
this.value(key).track(next)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.onUpdate?.(this.snapshot())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const entry = { run, frame: 0, trackSample }
|
|
140
|
+
const tick = () => {
|
|
141
|
+
if (this.delegated !== entry) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
trackSample()
|
|
146
|
+
entry.frame = this.clock.request(tick)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
entry.frame = this.clock.request(tick)
|
|
150
|
+
this.delegated = entry
|
|
151
|
+
const result = await run.finished
|
|
152
|
+
// Only this run cleans up its own sampler — an interrupted run must
|
|
153
|
+
// not clobber the replacement's delegated state.
|
|
154
|
+
if (this.delegated === entry) {
|
|
155
|
+
this.clock.cancel(entry.frame)
|
|
156
|
+
this.delegated = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (generation !== this.generation) {
|
|
160
|
+
return 'replaced'
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (result !== 'finished') {
|
|
164
|
+
return 'cancelled'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const key of keys) {
|
|
168
|
+
if (dest[key] !== undefined) {
|
|
169
|
+
this.value(key).jump(dest[key]!)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return 'finished'
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
71
177
|
const jobs = Object.entries(target).map(([key, to]) => {
|
|
72
178
|
const value = this.value(key as MotionKey)
|
|
73
|
-
return value.animate(
|
|
179
|
+
return value.animate(
|
|
180
|
+
to,
|
|
181
|
+
reduced && key !== 'opacity'
|
|
182
|
+
? { duration: 0 }
|
|
183
|
+
: resolveTransition(transition, key as MotionKey),
|
|
184
|
+
).finished
|
|
74
185
|
})
|
|
75
186
|
|
|
76
187
|
const results = await Promise.all(jobs)
|
|
@@ -82,6 +193,7 @@ export class Controller {
|
|
|
82
193
|
}
|
|
83
194
|
stop() {
|
|
84
195
|
++this.generation
|
|
196
|
+
this.cancelDelegated()
|
|
85
197
|
for (const value of this.values.values()) {
|
|
86
198
|
value.stop()
|
|
87
199
|
}
|
|
@@ -93,6 +205,6 @@ export class Controller {
|
|
|
93
205
|
}
|
|
94
206
|
|
|
95
207
|
this.values.clear()
|
|
96
|
-
this.
|
|
208
|
+
this.adapter = undefined
|
|
97
209
|
}
|
|
98
210
|
}
|