@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.
Files changed (43) hide show
  1. package/README.md +31 -2
  2. package/UPSTREAM.md +295 -27
  3. package/dist/native/index.js +980 -112
  4. package/dist/native/octane-client-build.json +1 -1
  5. package/dist/web/index.js +1104 -166
  6. package/dist/web/octane-client-build.json +1 -1
  7. package/package.json +25 -16
  8. package/src/clock.ts +13 -1
  9. package/src/components.mobile.test.tsrx +58 -1
  10. package/src/components.tsrx +185 -39
  11. package/src/components.web.test.tsrx +236 -3
  12. package/src/controller.ts +122 -10
  13. package/src/delegation.test.ts +269 -0
  14. package/src/delegation.ts +29 -0
  15. package/src/delegation.web.test.ts +270 -0
  16. package/src/drag.mobile.test.ts +81 -0
  17. package/src/drag.test.ts +167 -0
  18. package/src/drag.ts +157 -0
  19. package/src/driver.ts +249 -0
  20. package/src/driver.web.ts +180 -0
  21. package/src/engine.test.ts +99 -0
  22. package/src/engine.ts +140 -18
  23. package/src/hooks.tsrx +53 -2
  24. package/src/host-types.ts +44 -1
  25. package/src/host.ts +39 -2
  26. package/src/host.web.ts +51 -10
  27. package/src/index.ts +18 -4
  28. package/src/index.web.ts +18 -4
  29. package/src/pan.ts +92 -0
  30. package/src/pan.web.ts +116 -0
  31. package/src/presence-input.ts +11 -4
  32. package/src/presence-input.web.ts +13 -4
  33. package/src/presence-state.ts +27 -7
  34. package/src/presence.tsrx +6 -7
  35. package/src/reduced-motion.ts +23 -4
  36. package/src/types.ts +100 -11
  37. package/src/value.ts +18 -8
  38. package/src/variants.test.ts +197 -0
  39. package/src/variants.ts +226 -0
  40. package/types/engine.d.ts +8 -2
  41. package/types/index.d.ts +34 -6
  42. package/types/types.d.ts +94 -11
  43. 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
+ }
@@ -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 { Transition } from './types'
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' && (t.duration !== undefined || t.ease !== undefined)) {
15
- throw new Error('motion: physical springs do not accept duration or ease')
16
- }
52
+ if (t.type === 'spring' && t.ease !== undefined) {
53
+ throw new Error('motion: physical springs do not accept ease')
54
+ }
17
55
 
18
- const allowed = [
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 (!['linear', 'easeIn', 'easeOut', 'easeInOut'].includes(t.ease)) {
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 generator =
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: [from, to],
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: [from, to],
229
+ keyframes: [legFrom, legTo],
126
230
  duration: duration * 1000,
127
231
  ease: transition.ease ?? 'easeInOut',
128
232
  })
129
233
 
130
- const start = clock.now() + (transition.delay ?? 0) * 1000
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 elapsed = Math.max(0, clock.now() - start)
137
- if (clock.now() >= start) {
138
- const state = generator.next(elapsed)
139
- update(state.done ? to : state.value)
140
- if (state.done) {
141
- settle('finished')
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 { Transition } from './types'
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 { read: () => ({ ...original, scale: 1 }), write, restore: () => write(original) }
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
  }