@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { DelegatedRequest, DelegatedRun } from './host-types'
|
|
2
|
+
import { bezierPoints, isBezierEase } from './engine'
|
|
3
|
+
import { delegationStats, trackDelegatedRun } from './delegation'
|
|
4
|
+
import type { Target } from './types'
|
|
5
|
+
|
|
6
|
+
// All declarative channels map onto compositor-accelerated WAAPI properties:
|
|
7
|
+
// the spatial channels share one transform keyframe pair, opacity animates
|
|
8
|
+
// on its own property.
|
|
9
|
+
const TRANSFORM_KEYS: (keyof Target)[] = ['x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
|
|
10
|
+
|
|
11
|
+
// Matched function lists make WAAPI interpolate each channel numerically —
|
|
12
|
+
// the same order the adapter's write() composes (matrix = T·S·R). The scale
|
|
13
|
+
// channel arrives already folded into scaleX/scaleY on the request's `eff`.
|
|
14
|
+
const transform = (values: Target) =>
|
|
15
|
+
`translateX(${values.x ?? 0}px) translateY(${values.y ?? 0}px) scaleX(${values.scaleX ?? 1}) scaleY(${values.scaleY ?? 1}) rotate(${values.rotate ?? 0}deg)`
|
|
16
|
+
|
|
17
|
+
// A computed transform resolves to a matrix composed around transform-origin;
|
|
18
|
+
// peel the origin back off so rotation/scale do not leak into x/y. For the
|
|
19
|
+
// T·S·R order: a = sx·cos θ, b = sy·sin θ, c = -sx·sin θ, d = sy·cos θ.
|
|
20
|
+
function decompose(v: number[], origin: string): Target {
|
|
21
|
+
const [ox, oy] = origin.split(' ').map((part) => Number.parseFloat(part) || 0)
|
|
22
|
+
const [a, b, c, d, e, f] = v.length === 16 ? [v[0], v[1], v[4], v[5], v[12], v[13]] : v
|
|
23
|
+
const scaleX = Math.hypot(a, c)
|
|
24
|
+
const scaleY = Math.hypot(b, d)
|
|
25
|
+
let rotate = scaleX && scaleY ? (Math.atan2(b / scaleY, a / scaleX) * 180) / Math.PI : 0
|
|
26
|
+
if (!Number.isFinite(rotate)) {
|
|
27
|
+
rotate = 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let flippedY = scaleY
|
|
31
|
+
// A negative determinant means one axis flipped; folding the sign into
|
|
32
|
+
// scaleY and the angle round-trips the same matrix for every composition.
|
|
33
|
+
if (a * d - b * c < 0) {
|
|
34
|
+
flippedY = -scaleY
|
|
35
|
+
rotate = -rotate
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
x: e + (a - 1) * ox + c * oy,
|
|
40
|
+
y: f + b * ox + (d - 1) * oy,
|
|
41
|
+
scaleX,
|
|
42
|
+
scaleY: flippedY,
|
|
43
|
+
rotate,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readChannels(node: HTMLElement, fallback: Target): Target {
|
|
48
|
+
const computed = getComputedStyle(node)
|
|
49
|
+
const opacity = Number(computed.opacity)
|
|
50
|
+
const base: Target = {
|
|
51
|
+
...fallback,
|
|
52
|
+
opacity: Number.isFinite(opacity) ? opacity : (fallback.opacity ?? 1),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const match = /matrix(?:3d)?\(([^)]*)\)/.exec(computed.transform)
|
|
56
|
+
if (!match) {
|
|
57
|
+
return computed.transform === 'none'
|
|
58
|
+
? { ...base, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 }
|
|
59
|
+
: base
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const values = match[1].split(',').map(Number.parseFloat)
|
|
63
|
+
return values.every(Number.isFinite)
|
|
64
|
+
? { ...base, ...decompose(values, computed.transformOrigin) }
|
|
65
|
+
: base
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function waapiRun(
|
|
69
|
+
node: HTMLElement,
|
|
70
|
+
req: DelegatedRequest,
|
|
71
|
+
write: (t: Target) => void,
|
|
72
|
+
): DelegatedRun | null {
|
|
73
|
+
if (typeof node.animate !== 'function') {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { target, eff, transition: t } = req
|
|
78
|
+
const animatesTransform = TRANSFORM_KEYS.some((key) => target[key] !== undefined)
|
|
79
|
+
if (!animatesTransform && target.opacity === undefined) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Explicit keyframe pairs keep the interpolation in matched-list mode; an
|
|
84
|
+
// implicit "from" resolves to a matrix and would lose rotate windings.
|
|
85
|
+
let last = readChannels(node, { opacity: 1, x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 })
|
|
86
|
+
const frames: Keyframe[] = [{}, {}]
|
|
87
|
+
if (animatesTransform) {
|
|
88
|
+
frames[0].transform = transform(last)
|
|
89
|
+
frames[1].transform = transform(eff)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (target.opacity !== undefined) {
|
|
93
|
+
frames[0].opacity = last.opacity ?? 1
|
|
94
|
+
frames[1].opacity = Math.min(1, Math.max(0, eff.opacity ?? 1))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const [x1, y1, x2, y2] = bezierPoints(t.ease)
|
|
98
|
+
let animation: Animation
|
|
99
|
+
try {
|
|
100
|
+
animation = node.animate(frames, {
|
|
101
|
+
duration: (t.duration ?? 0.3) * 1000,
|
|
102
|
+
delay: (t.delay ?? 0) * 1000,
|
|
103
|
+
easing: `cubic-bezier(${x1}, ${y1}, ${x2}, ${y2})`,
|
|
104
|
+
fill: 'forwards',
|
|
105
|
+
})
|
|
106
|
+
} catch {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sample = (): Target => {
|
|
111
|
+
last = readChannels(node, last)
|
|
112
|
+
return { ...last }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let resolve!: (result: 'finished' | 'cancelled') => void
|
|
116
|
+
const finished = new Promise<'finished' | 'cancelled'>((done) => {
|
|
117
|
+
resolve = done
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
let settled = false
|
|
121
|
+
const settle = (result: 'finished' | 'cancelled') => {
|
|
122
|
+
if (!settled) {
|
|
123
|
+
settled = true
|
|
124
|
+
resolve(result)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The platform run owns presentation; sample() reads the composited state.
|
|
129
|
+
// On finish the destination is committed to the base styles before the
|
|
130
|
+
// fill is released, so later adapter writes still apply.
|
|
131
|
+
const complete = () => {
|
|
132
|
+
write(req.dest)
|
|
133
|
+
settle('finished')
|
|
134
|
+
animation.cancel()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const cancelled = () => settle('cancelled')
|
|
138
|
+
if (typeof animation.finished?.then === 'function') {
|
|
139
|
+
// finished rejects with AbortError on cancel — observed here.
|
|
140
|
+
void animation.finished.then(complete, cancelled)
|
|
141
|
+
} else {
|
|
142
|
+
animation.onfinish = complete
|
|
143
|
+
animation.oncancel = cancelled
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
finished,
|
|
148
|
+
sample,
|
|
149
|
+
cancel() {
|
|
150
|
+
// Cancel reverts the element to its base styles synchronously, so the
|
|
151
|
+
// presentation is sampled first and the adapter write holds the
|
|
152
|
+
// frozen values for the next run's "from" keyframe.
|
|
153
|
+
const current = sample()
|
|
154
|
+
animation.cancel()
|
|
155
|
+
write(current)
|
|
156
|
+
settle('cancelled')
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Mirrors the native delegatedRun contract: cubic-bezier eases only, null on
|
|
162
|
+
// any missing capability so the caller stays on the JS engine.
|
|
163
|
+
export function delegatedRun(
|
|
164
|
+
node: HTMLElement,
|
|
165
|
+
req: DelegatedRequest,
|
|
166
|
+
write: (t: Target) => void,
|
|
167
|
+
): DelegatedRun | null {
|
|
168
|
+
if (!isBezierEase(req.transition.ease)) {
|
|
169
|
+
delegationStats().fallback++
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const run = waapiRun(node, req, write)
|
|
174
|
+
if (!run) {
|
|
175
|
+
delegationStats().fallback++
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return trackDelegatedRun(run)
|
|
180
|
+
}
|
package/src/engine.test.ts
CHANGED
|
@@ -123,4 +123,103 @@ describe('portable playback', () => {
|
|
|
123
123
|
expect(await result).toBe('replaced')
|
|
124
124
|
expect(time.count()).toBe(0)
|
|
125
125
|
})
|
|
126
|
+
|
|
127
|
+
it('reduces positional springs immediately while preserving opacity motion', async () => {
|
|
128
|
+
const time = fakeClock()
|
|
129
|
+
const controller = new Controller(time.clock)
|
|
130
|
+
const result = controller.animate({ x: 100, opacity: 0 }, { type: 'spring' }, true)
|
|
131
|
+
|
|
132
|
+
expect(controller.snapshot()).toMatchObject({ x: 100, opacity: 1 })
|
|
133
|
+
expect(time.count()).toBe(1)
|
|
134
|
+
time.advance(10000)
|
|
135
|
+
expect(await result).toBe('finished')
|
|
136
|
+
expect(controller.snapshot()).toEqual({ x: 100, opacity: 0 })
|
|
137
|
+
controller.destroy()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('upstream-shaped transitions', () => {
|
|
142
|
+
it('repeats a tween for the given number of additional legs', async () => {
|
|
143
|
+
const time = fakeClock()
|
|
144
|
+
const seen: number[] = []
|
|
145
|
+
const controls = runAnimation(
|
|
146
|
+
time.clock,
|
|
147
|
+
0,
|
|
148
|
+
100,
|
|
149
|
+
{ duration: 0.1, ease: 'linear', repeat: 1 },
|
|
150
|
+
(value) => seen.push(value),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
time.advance(120)
|
|
154
|
+
expect(seen.at(-1)).toBe(100)
|
|
155
|
+
time.advance(50)
|
|
156
|
+
expect(seen.at(-1)).toBe(50)
|
|
157
|
+
time.advance(120)
|
|
158
|
+
expect(seen.at(-1)).toBe(100)
|
|
159
|
+
expect(await controls.finished).toBe('finished')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('reverses each repeated leg under repeatType reverse', async () => {
|
|
163
|
+
const time = fakeClock()
|
|
164
|
+
const seen: number[] = []
|
|
165
|
+
const controls = runAnimation(
|
|
166
|
+
time.clock,
|
|
167
|
+
0,
|
|
168
|
+
100,
|
|
169
|
+
{ duration: 0.1, ease: 'linear', repeat: 1, repeatType: 'reverse' },
|
|
170
|
+
(value) => seen.push(value),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
time.advance(120)
|
|
174
|
+
time.advance(50)
|
|
175
|
+
expect(seen.at(-1)).toBe(50)
|
|
176
|
+
time.advance(120)
|
|
177
|
+
expect(seen.at(-1)).toBe(0)
|
|
178
|
+
expect(await controls.finished).toBe('finished')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('accepts a duration/bounce spring spec', async () => {
|
|
182
|
+
const time = fakeClock()
|
|
183
|
+
let value = 0
|
|
184
|
+
const controls = runAnimation(
|
|
185
|
+
time.clock,
|
|
186
|
+
0,
|
|
187
|
+
100,
|
|
188
|
+
{ type: 'spring', duration: 0.4, bounce: 0.2 },
|
|
189
|
+
(next) => {
|
|
190
|
+
value = next
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < 100; i++) {
|
|
195
|
+
time.advance(16)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
expect(await controls.finished).toBe('finished')
|
|
199
|
+
expect(value).toBe(100)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('accepts the full named-ease set on the JS engine', async () => {
|
|
203
|
+
for (const ease of ['circIn', 'backOut', 'anticipate', 'circInOut', 'backInOut'] as const) {
|
|
204
|
+
const time = fakeClock()
|
|
205
|
+
let value = 0
|
|
206
|
+
const controls = runAnimation(time.clock, 0, 50, { duration: 0.1, ease }, (next) => {
|
|
207
|
+
value = next
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < 20; i++) {
|
|
211
|
+
time.advance(16)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
expect(await controls.finished).toBe('finished')
|
|
215
|
+
expect(value).toBe(50)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('rejects fractional repeats', () => {
|
|
220
|
+
const time = fakeClock()
|
|
221
|
+
expect(() => runAnimation(time.clock, 0, 1, { duration: 1, repeat: 1.5 }, () => {})).toThrow(
|
|
222
|
+
'repeat',
|
|
223
|
+
)
|
|
224
|
+
})
|
|
126
225
|
})
|
package/src/engine.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { keyframes, spring } from 'motion-dom'
|
|
2
|
-
import type { Clock } from './clock-types'
|
|
3
|
-
import type {
|
|
2
|
+
import type { Clock } from './clock-types.js'
|
|
3
|
+
import type {
|
|
4
|
+
Transition,
|
|
5
|
+
TransitionInput,
|
|
6
|
+
TransitionOrchestration,
|
|
7
|
+
MotionKey,
|
|
8
|
+
RepeatType,
|
|
9
|
+
} from './types.js'
|
|
4
10
|
|
|
5
11
|
/** Terminal status; cancellation never masquerades as completion. */
|
|
6
12
|
export type AnimationResult = 'finished' | 'cancelled' | 'replaced'
|
|
@@ -10,16 +16,55 @@ export interface AnimationControls {
|
|
|
10
16
|
stop(reason?: Exclude<AnimationResult, 'finished'>): void
|
|
11
17
|
}
|
|
12
18
|
|
|
19
|
+
const MOTION_KEYS: MotionKey[] = ['opacity', 'x', 'y', 'scale', 'scaleX', 'scaleY', 'rotate']
|
|
20
|
+
|
|
21
|
+
/** Platform animators take one timing curve per run — cubic-bezier only. */
|
|
22
|
+
const BEZIER_EASES = new Set(['linear', 'easeIn', 'easeOut', 'easeInOut'])
|
|
23
|
+
export function isBezierEase(ease: Transition['ease']): boolean {
|
|
24
|
+
return ease === undefined || Array.isArray(ease) || BEZIER_EASES.has(ease)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BEZIER_POINTS: Record<string, [number, number, number, number]> = {
|
|
28
|
+
linear: [0, 0, 1, 1],
|
|
29
|
+
easeIn: [0.42, 0, 1, 1],
|
|
30
|
+
easeOut: [0, 0, 0.58, 1],
|
|
31
|
+
easeInOut: [0.42, 0, 0.58, 1],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** CSS cubic-bezier control points for a delegatable ease; tuples pass through. */
|
|
35
|
+
export function bezierPoints(ease: Transition['ease']): [number, number, number, number] {
|
|
36
|
+
return Array.isArray(ease) ? ease : BEZIER_POINTS[ease ?? 'easeInOut']
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isOrchestrated(t: TransitionInput | undefined): t is TransitionOrchestration {
|
|
40
|
+
if (!t || typeof t !== 'object') {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return 'default' in t || MOTION_KEYS.some((key) => key in t)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveTransition(t: TransitionInput, key: MotionKey): Transition {
|
|
48
|
+
return isOrchestrated(t) ? { ...t.default, ...t[key] } : t
|
|
49
|
+
}
|
|
50
|
+
|
|
13
51
|
export function validateTransition(t: Transition): void {
|
|
14
|
-
if (t.type === 'spring' &&
|
|
15
|
-
|
|
16
|
-
|
|
52
|
+
if (t.type === 'spring' && t.ease !== undefined) {
|
|
53
|
+
throw new Error('motion: physical springs do not accept ease')
|
|
54
|
+
}
|
|
17
55
|
|
|
18
|
-
|
|
56
|
+
const allowed = [
|
|
57
|
+
'staggerChildren',
|
|
58
|
+
'delayChildren',
|
|
59
|
+
'when',
|
|
19
60
|
'type',
|
|
20
61
|
'duration',
|
|
21
62
|
'delay',
|
|
22
63
|
'ease',
|
|
64
|
+
'repeat',
|
|
65
|
+
'repeatType',
|
|
66
|
+
'repeatDelay',
|
|
67
|
+
'bounce',
|
|
23
68
|
'stiffness',
|
|
24
69
|
'damping',
|
|
25
70
|
'mass',
|
|
@@ -34,13 +79,21 @@ export function validateTransition(t: Transition): void {
|
|
|
34
79
|
}
|
|
35
80
|
}
|
|
36
81
|
|
|
82
|
+
if (t.when !== undefined && !['beforeChildren', 'afterChildren'].includes(t.when)) {
|
|
83
|
+
throw new Error('motion: unsupported when')
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
if (t.type !== undefined && t.type !== 'tween' && t.type !== 'spring') {
|
|
38
87
|
throw new Error('motion: unsupported transition type')
|
|
39
88
|
}
|
|
40
89
|
|
|
41
90
|
for (const key of [
|
|
91
|
+
'staggerChildren',
|
|
92
|
+
'delayChildren',
|
|
42
93
|
'duration',
|
|
43
94
|
'delay',
|
|
95
|
+
'repeatDelay',
|
|
96
|
+
'bounce',
|
|
44
97
|
'stiffness',
|
|
45
98
|
'damping',
|
|
46
99
|
'mass',
|
|
@@ -54,6 +107,17 @@ export function validateTransition(t: Transition): void {
|
|
|
54
107
|
}
|
|
55
108
|
}
|
|
56
109
|
|
|
110
|
+
if (
|
|
111
|
+
t.repeat !== undefined &&
|
|
112
|
+
(t.repeat < 0 || (!Number.isInteger(t.repeat) && t.repeat !== Infinity))
|
|
113
|
+
) {
|
|
114
|
+
throw new Error('motion: repeat must be a non-negative integer or Infinity')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (t.repeatType !== undefined && !['loop', 'reverse', 'mirror'].includes(t.repeatType)) {
|
|
118
|
+
throw new Error('motion: unsupported repeatType')
|
|
119
|
+
}
|
|
120
|
+
|
|
57
121
|
for (const key of ['mass', 'stiffness', 'damping', 'restSpeed', 'restDelta'] as const) {
|
|
58
122
|
if (t[key] === 0) {
|
|
59
123
|
throw new Error(`motion: ${key} must be positive`)
|
|
@@ -72,12 +136,44 @@ export function validateTransition(t: Transition): void {
|
|
|
72
136
|
) {
|
|
73
137
|
throw new Error('motion: invalid cubic Bezier')
|
|
74
138
|
}
|
|
75
|
-
} else if (
|
|
139
|
+
} else if (
|
|
140
|
+
![
|
|
141
|
+
'linear',
|
|
142
|
+
'easeIn',
|
|
143
|
+
'easeOut',
|
|
144
|
+
'easeInOut',
|
|
145
|
+
'circIn',
|
|
146
|
+
'circOut',
|
|
147
|
+
'circInOut',
|
|
148
|
+
'backIn',
|
|
149
|
+
'backOut',
|
|
150
|
+
'backInOut',
|
|
151
|
+
'anticipate',
|
|
152
|
+
].includes(t.ease)
|
|
153
|
+
) {
|
|
76
154
|
throw new Error('motion: unsupported easing')
|
|
77
155
|
}
|
|
78
156
|
}
|
|
79
157
|
}
|
|
80
158
|
|
|
159
|
+
export function validateTransitionInput(t: TransitionInput): void {
|
|
160
|
+
if (isOrchestrated(t)) {
|
|
161
|
+
const { staggerChildren, delayChildren, when, ...channels } = t
|
|
162
|
+
validateTransition({ staggerChildren, delayChildren, when })
|
|
163
|
+
for (const [key, value] of Object.entries(channels)) {
|
|
164
|
+
if (key !== 'default' && !MOTION_KEYS.includes(key as MotionKey)) {
|
|
165
|
+
throw new Error(`motion: unsupported transition ${key}`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (value) {
|
|
169
|
+
validateTransition(value as Transition)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
validateTransition(t)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
81
177
|
// Pure upstream generators share trajectories across platforms; host scheduling
|
|
82
178
|
// stays outside Motion's browser-global frame loop.
|
|
83
179
|
export function runAnimation(
|
|
@@ -110,37 +206,63 @@ export function runAnimation(
|
|
|
110
206
|
}
|
|
111
207
|
|
|
112
208
|
const duration = transition.duration ?? 0.3
|
|
113
|
-
const
|
|
209
|
+
const repeat = transition.repeat ?? 0
|
|
210
|
+
const repeatType: RepeatType = transition.repeatType ?? 'loop'
|
|
211
|
+
const repeatDelay = (transition.repeatDelay ?? 0) * 1000
|
|
212
|
+
|
|
213
|
+
const makeGenerator = (legFrom: number, legTo: number) =>
|
|
114
214
|
transition.type === 'spring'
|
|
115
215
|
? spring({
|
|
116
|
-
keyframes: [
|
|
216
|
+
keyframes: [legFrom, legTo],
|
|
117
217
|
stiffness: transition.stiffness ?? 100,
|
|
118
218
|
damping: transition.damping ?? 10,
|
|
119
219
|
mass: transition.mass ?? 1,
|
|
120
220
|
velocity: transition.velocity ?? 0,
|
|
121
221
|
restSpeed: transition.restSpeed,
|
|
122
222
|
restDelta: transition.restDelta,
|
|
223
|
+
// Visual-duration spring spec — duration/bounce instead of
|
|
224
|
+
// stiffness/damping; motion-dom takes milliseconds.
|
|
225
|
+
duration: transition.duration !== undefined ? duration * 1000 : undefined,
|
|
226
|
+
bounce: transition.bounce,
|
|
123
227
|
})
|
|
124
228
|
: keyframes({
|
|
125
|
-
keyframes: [
|
|
229
|
+
keyframes: [legFrom, legTo],
|
|
126
230
|
duration: duration * 1000,
|
|
127
231
|
ease: transition.ease ?? 'easeInOut',
|
|
128
232
|
})
|
|
129
233
|
|
|
130
|
-
|
|
234
|
+
let leg = 0
|
|
235
|
+
let generator = makeGenerator(from, to)
|
|
236
|
+
let legStart = clock.now() + (transition.delay ?? 0) * 1000
|
|
131
237
|
const tick = () => {
|
|
132
238
|
if (settled) {
|
|
133
239
|
return
|
|
134
240
|
}
|
|
135
241
|
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
242
|
+
const now = clock.now()
|
|
243
|
+
if (now < legStart) {
|
|
244
|
+
frame = clock.request(tick)
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const elapsed = Math.max(0, now - legStart)
|
|
249
|
+
const state = generator.next(elapsed)
|
|
250
|
+
const legEnd = repeatType === 'loop' || leg % 2 === 0 ? to : from
|
|
251
|
+
update(state.done ? legEnd : state.value)
|
|
252
|
+
if (state.done) {
|
|
253
|
+
if (leg < repeat) {
|
|
254
|
+
leg++
|
|
255
|
+
// 'loop' replays the same direction; 'reverse'/'mirror' alternate
|
|
256
|
+
// legs (identical for two keyframes, which is all we generate).
|
|
257
|
+
const [legFrom, legTo] = repeatType === 'loop' || leg % 2 === 0 ? [from, to] : [to, from]
|
|
258
|
+
generator = makeGenerator(legFrom, legTo)
|
|
259
|
+
legStart = now + repeatDelay
|
|
260
|
+
frame = clock.request(tick)
|
|
142
261
|
return
|
|
143
262
|
}
|
|
263
|
+
|
|
264
|
+
settle('finished')
|
|
265
|
+
return
|
|
144
266
|
}
|
|
145
267
|
|
|
146
268
|
if (!settled) {
|
|
@@ -148,7 +270,7 @@ export function runAnimation(
|
|
|
148
270
|
}
|
|
149
271
|
}
|
|
150
272
|
|
|
151
|
-
if (transition.type !== 'spring' && duration === 0 && !transition.delay) {
|
|
273
|
+
if (transition.type !== 'spring' && duration === 0 && !transition.delay && repeat === 0) {
|
|
152
274
|
update(to)
|
|
153
275
|
settle('finished')
|
|
154
276
|
} else {
|
package/src/hooks.tsrx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import { useRef, useLayoutEffect } from 'octane'
|
|
1
|
+
import { useRef, useLayoutEffect, useContext } from 'octane'
|
|
2
2
|
import { interpolate } from 'motion-dom'
|
|
3
3
|
import { clock } from './clock'
|
|
4
|
+
import { ConfigContext, useReducedMotion } from './config.tsrx'
|
|
5
|
+
import { controllers } from './components.tsrx'
|
|
4
6
|
import { MotionValue, type MotionValueEvents } from './value'
|
|
5
|
-
import type {
|
|
7
|
+
import type { AnimationControls, AnimationResult } from './engine'
|
|
8
|
+
import type { Target, Transition, TransitionInput } from './types'
|
|
6
9
|
|
|
7
10
|
/** Create an owned numeric value that writes to bound hosts without rendering. */
|
|
8
11
|
export function useMotionValue(initial: number): MotionValue {
|
|
@@ -54,3 +57,51 @@ export function useSpring(source: number | MotionValue, options: Omit<Transition
|
|
|
54
57
|
|
|
55
58
|
return value
|
|
56
59
|
}
|
|
60
|
+
|
|
61
|
+
/** Scoped imperative animation: `const [scope, animate] = useAnimate()` then
|
|
62
|
+
* `ref={scope}` on a motion host. `animate(target, transition)` drives that
|
|
63
|
+
* host; `animate(value, to, transition)` drives a MotionValue directly. */
|
|
64
|
+
export function useAnimate(): [
|
|
65
|
+
{ current: any },
|
|
66
|
+
(
|
|
67
|
+
target: Target | MotionValue | object,
|
|
68
|
+
transitionOrTo?: TransitionInput | number,
|
|
69
|
+
transition?: Transition,
|
|
70
|
+
) => Promise<AnimationResult> | AnimationControls,
|
|
71
|
+
] {
|
|
72
|
+
const scope = useRef<any>(null)
|
|
73
|
+
const config = useContext(ConfigContext)
|
|
74
|
+
const systemReduced = useReducedMotion()
|
|
75
|
+
const reducedRef = useRef(config.reducedMotion === 'always' || (config.reducedMotion === 'user' && systemReduced))
|
|
76
|
+
reducedRef.current = config.reducedMotion === 'always' || (config.reducedMotion === 'user' && systemReduced)
|
|
77
|
+
const animate = useRef(
|
|
78
|
+
(
|
|
79
|
+
target: any,
|
|
80
|
+
transitionOrTo?: any,
|
|
81
|
+
transition?: any,
|
|
82
|
+
): Promise<AnimationResult> | AnimationControls => {
|
|
83
|
+
if (target instanceof MotionValue) {
|
|
84
|
+
return target.animate(transitionOrTo, transition)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A registered host node targets that element's controller; a plain
|
|
88
|
+
// channel target drives the scope's host.
|
|
89
|
+
const controller =
|
|
90
|
+
target && typeof target === 'object' && controllers.has(target)
|
|
91
|
+
? controllers.get(target)!
|
|
92
|
+
: scope.current
|
|
93
|
+
? controllers.get(scope.current)
|
|
94
|
+
: undefined
|
|
95
|
+
|
|
96
|
+
if (!controller) {
|
|
97
|
+
return Promise.resolve('cancelled' as const)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const destination = controllers.has(target) ? (transitionOrTo ?? {}) : target
|
|
101
|
+
const spec = controllers.has(target) ? (transition ?? {}) : (transitionOrTo ?? {})
|
|
102
|
+
return controller.animate(destination, spec, reducedRef.current)
|
|
103
|
+
},
|
|
104
|
+
).current
|
|
105
|
+
|
|
106
|
+
return [scope, animate]
|
|
107
|
+
}
|
package/src/host-types.ts
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
|
-
import type { Target } from './types'
|
|
1
|
+
import type { Target, Transition, DragInfo } from './types'
|
|
2
|
+
|
|
3
|
+
export interface DelegatedRequest {
|
|
4
|
+
/** Channels the caller asked to animate. */
|
|
5
|
+
target: Target
|
|
6
|
+
/** Full channel-space destination: retained values plus the target. */
|
|
7
|
+
dest: Target
|
|
8
|
+
/** Effective native destination — `scale` folded into scaleX/scaleY. */
|
|
9
|
+
eff: Target
|
|
10
|
+
transition: Transition
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A run handed to the platform animator; presentation state stays readable. */
|
|
14
|
+
export interface DelegatedRun {
|
|
15
|
+
/** Reads current rendered values in effective channels (scale folded). */
|
|
16
|
+
sample(): Target
|
|
17
|
+
/** Stops the run at its presentation state; the model is synced to it. */
|
|
18
|
+
cancel(): void
|
|
19
|
+
finished: Promise<'finished' | 'cancelled'>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Gesture-target kinds: press covers whileTap, focus covers whileFocus. */
|
|
23
|
+
export type GestureKind = 'press' | 'focus'
|
|
24
|
+
|
|
25
|
+
export interface GestureCallbacks {
|
|
26
|
+
start(): void
|
|
27
|
+
end(): void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PanCallbacks {
|
|
31
|
+
start(event: any, info: DragInfo): void
|
|
32
|
+
move(event: any, info: DragInfo): void
|
|
33
|
+
end(event: any, info: DragInfo): void
|
|
34
|
+
}
|
|
35
|
+
|
|
2
36
|
export interface HostAdapter {
|
|
37
|
+
pan?(axis: true | 'x' | 'y', callbacks: PanCallbacks): () => void
|
|
3
38
|
read(): Target
|
|
4
39
|
write(values: Target): void
|
|
5
40
|
restore(): void
|
|
41
|
+
/**
|
|
42
|
+
* Run a declarative tween on the platform animator. Returns null to fall
|
|
43
|
+
* back to the JS engine.
|
|
44
|
+
*/
|
|
45
|
+
delegate?(request: DelegatedRequest): DelegatedRun | null
|
|
46
|
+
/** Subscribe to a gesture lifecycle; returns an unsubscribe, or null if the
|
|
47
|
+
* host cannot emit it (focus events only exist on focusable hosts). */
|
|
48
|
+
gesture?(kind: GestureKind, callbacks: GestureCallbacks): (() => void) | null
|
|
6
49
|
}
|
package/src/host.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { HostAdapter } from './host-types'
|
|
1
|
+
import type { DelegatedRequest, DelegatedRun, HostAdapter } from './host-types'
|
|
2
|
+
import { attachPan } from './pan'
|
|
3
|
+
import { delegatedRun } from './driver'
|
|
2
4
|
// The retained NativeScript View exposes direct transform channels in DIP.
|
|
3
5
|
export function attachHost(node: any): HostAdapter {
|
|
4
6
|
const original = {
|
|
@@ -46,5 +48,40 @@ export function attachHost(node: any): HostAdapter {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
pan: (axis, callbacks) => attachPan(node, axis, callbacks),
|
|
53
|
+
read: () => ({ ...original, scale: 1 }),
|
|
54
|
+
write,
|
|
55
|
+
restore: () => write(original),
|
|
56
|
+
delegate: (req: DelegatedRequest): DelegatedRun | null => delegatedRun(node, req, write),
|
|
57
|
+
gesture(kind, callbacks) {
|
|
58
|
+
if (typeof node.on !== 'function' || typeof node.off !== 'function') {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (kind === 'press') {
|
|
63
|
+
// NativeScript's `touch` event: action down/up/cancel.
|
|
64
|
+
const onTouch = (event: any) => {
|
|
65
|
+
if (event.action === 'down') {
|
|
66
|
+
callbacks.start()
|
|
67
|
+
} else if (event.action === 'up' || event.action === 'cancel') {
|
|
68
|
+
callbacks.end()
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
node.on('touch', onTouch)
|
|
73
|
+
return () => node.off('touch', onTouch)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// focus/blur exist only on focusable hosts (editable text, buttons).
|
|
77
|
+
const onFocus = () => callbacks.start()
|
|
78
|
+
const onBlur = () => callbacks.end()
|
|
79
|
+
node.on('focus', onFocus)
|
|
80
|
+
node.on('blur', onBlur)
|
|
81
|
+
return () => {
|
|
82
|
+
node.off('focus', onFocus)
|
|
83
|
+
node.off('blur', onBlur)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}
|
|
50
87
|
}
|