@rafaelaugusto/jolt 0.1.0-beta.10 → 0.1.0-beta.12
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 +55 -6
- package/dist/animate.d.ts +14 -4
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +50 -34
- package/dist/animate.js.map +1 -1
- package/dist/drag.d.ts +17 -3
- package/dist/drag.d.ts.map +1 -1
- package/dist/drag.js +34 -11
- package/dist/drag.js.map +1 -1
- package/dist/driver/android-gradient.d.ts +5 -0
- package/dist/driver/android-gradient.d.ts.map +1 -0
- package/dist/driver/android-gradient.js +89 -0
- package/dist/driver/android-gradient.js.map +1 -0
- package/dist/driver/ios-gradient.d.ts +5 -0
- package/dist/driver/ios-gradient.d.ts.map +1 -0
- package/dist/driver/ios-gradient.js +109 -0
- package/dist/driver/ios-gradient.js.map +1 -0
- package/dist/easing.d.ts +1 -1
- package/dist/easing.d.ts.map +1 -1
- package/dist/easing.js +11 -0
- package/dist/easing.js.map +1 -1
- package/dist/engine.d.ts +18 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +143 -36
- package/dist/engine.js.map +1 -1
- package/dist/gradient.d.ts +90 -0
- package/dist/gradient.d.ts.map +1 -0
- package/dist/gradient.js +253 -0
- package/dist/gradient.js.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/keyboard.d.ts.map +1 -1
- package/dist/keyboard.js +63 -10
- package/dist/keyboard.js.map +1 -1
- package/dist/mixer.d.ts +49 -0
- package/dist/mixer.d.ts.map +1 -0
- package/dist/mixer.js +238 -0
- package/dist/mixer.js.map +1 -0
- package/dist/motion-scope.d.ts +4 -1
- package/dist/motion-scope.d.ts.map +1 -1
- package/dist/motion-scope.js +8 -3
- package/dist/motion-scope.js.map +1 -1
- package/dist/motion-states.d.ts +18 -6
- package/dist/motion-states.d.ts.map +1 -1
- package/dist/motion-states.js +114 -26
- package/dist/motion-states.js.map +1 -1
- package/dist/motion-value.d.ts +115 -0
- package/dist/motion-value.d.ts.map +1 -0
- package/dist/motion-value.js +437 -0
- package/dist/motion-value.js.map +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +2 -2
- package/dist/path.js.map +1 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +31 -12
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +30 -4
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +63 -7
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +28 -2
- package/dist/vue/index.d.ts.map +1 -1
- package/dist/vue/index.js +78 -2
- package/dist/vue/index.js.map +1 -1
- package/package.json +1 -1
- package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +195 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrame.java +53 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFrameFollower.java +57 -0
- package/platforms/android/proguard-rules.pro +2 -0
- package/src/android-plugin.d.ts +56 -0
- package/src/animate.ts +99 -16
- package/src/drag.ts +67 -21
- package/src/driver/android-gradient.ts +109 -0
- package/src/driver/ios-gradient.ts +114 -0
- package/src/easing.ts +14 -0
- package/src/engine.ts +158 -53
- package/src/gradient.ts +378 -0
- package/src/index.ts +34 -3
- package/src/keyboard.ts +83 -18
- package/src/mixer.ts +264 -0
- package/src/motion-scope.ts +14 -6
- package/src/motion-states.ts +145 -41
- package/src/motion-value.ts +563 -0
- package/src/path.ts +2 -1
- package/src/track.ts +37 -12
- package/src/transition.ts +99 -10
- package/src/types.ts +5 -0
- package/src/vue/index.ts +98 -1
package/src/mixer.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { View } from '@nativescript/core'
|
|
2
|
+
import { getDriver } from './driver/index.js'
|
|
3
|
+
import { readValue, writeValue, type Property } from './properties.js'
|
|
4
|
+
import type { Track } from './track.js'
|
|
5
|
+
|
|
6
|
+
/** The channel of regular animations; layers are named next to it. */
|
|
7
|
+
export const BASE = ''
|
|
8
|
+
|
|
9
|
+
const SAMPLES_PER_SECOND = 60
|
|
10
|
+
const MAX_SAMPLES = 6000
|
|
11
|
+
/** Endless channels loop natively over the least common multiple of their periods, up to this. */
|
|
12
|
+
const MAX_LOOP = 30000
|
|
13
|
+
/** Beyond it, the composition is played in windows and sampled again at each one. */
|
|
14
|
+
const WINDOW = 4000
|
|
15
|
+
|
|
16
|
+
export const multiplies = (property: Property) =>
|
|
17
|
+
property === 'opacity' || property === 'scaleX' || property === 'scaleY'
|
|
18
|
+
export const identity = (property: Property) => (multiplies(property) ? 1 : 0)
|
|
19
|
+
export const layerable = (property: Property) =>
|
|
20
|
+
multiplies(property) ||
|
|
21
|
+
property === 'x' ||
|
|
22
|
+
property === 'y' ||
|
|
23
|
+
property === 'rotate' ||
|
|
24
|
+
property === 'rotateX' ||
|
|
25
|
+
property === 'rotateY'
|
|
26
|
+
|
|
27
|
+
interface Channel {
|
|
28
|
+
track: Track
|
|
29
|
+
anchor: number
|
|
30
|
+
offset: number
|
|
31
|
+
paused: boolean
|
|
32
|
+
onEnd: (finished: boolean) => void
|
|
33
|
+
timer?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the engine holds instead of a driver handle while a property is mixed. */
|
|
37
|
+
export class MixHandle {
|
|
38
|
+
constructor(
|
|
39
|
+
readonly mix: Mix,
|
|
40
|
+
readonly key: string,
|
|
41
|
+
) {}
|
|
42
|
+
elapsed() {
|
|
43
|
+
return this.mix.elapsed(this.key)
|
|
44
|
+
}
|
|
45
|
+
control(position: number, paused: boolean) {
|
|
46
|
+
this.mix.control(this.key, position, paused)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const mixes = new WeakMap<View, Map<Property, Mix>>()
|
|
51
|
+
|
|
52
|
+
export function mixOf(view: View, property: Property): Mix | undefined {
|
|
53
|
+
return mixes.get(view)?.get(property)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mixesOf(view: View): Mix[] {
|
|
57
|
+
return [...(mixes.get(view)?.values() ?? [])]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mixFor(view: View, property: Property): Mix {
|
|
61
|
+
let properties = mixes.get(view)
|
|
62
|
+
if (!properties) mixes.set(view, (properties = new Map()))
|
|
63
|
+
let mix = properties.get(property)
|
|
64
|
+
if (!mix) properties.set(property, (mix = new Mix(view, property)))
|
|
65
|
+
return mix
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One property of one view: a base and named layers, played natively as one composite track. */
|
|
69
|
+
export class Mix {
|
|
70
|
+
private readonly rests = new Map<string, number>()
|
|
71
|
+
private readonly channels = new Map<string, Channel>()
|
|
72
|
+
private native: unknown
|
|
73
|
+
private generation = 0
|
|
74
|
+
|
|
75
|
+
constructor(
|
|
76
|
+
readonly view: View,
|
|
77
|
+
readonly property: Property,
|
|
78
|
+
) {
|
|
79
|
+
this.rests.set(BASE, readValue(view, property))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
rest(key: string): number {
|
|
83
|
+
return (
|
|
84
|
+
this.rests.get(key) ??
|
|
85
|
+
(key === BASE ? readValue(this.view, this.property) : identity(this.property))
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setRest(key: string, value: number) {
|
|
90
|
+
this.rests.set(key, value)
|
|
91
|
+
this.compose()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
adopt(
|
|
95
|
+
key: string,
|
|
96
|
+
track: Track,
|
|
97
|
+
startedAt: number,
|
|
98
|
+
onEnd: (finished: boolean) => void,
|
|
99
|
+
) {
|
|
100
|
+
this.channels.get(key)?.timer?.()
|
|
101
|
+
const channel: Channel = { track, anchor: startedAt, offset: 0, paused: false, onEnd }
|
|
102
|
+
this.channels.set(key, channel)
|
|
103
|
+
this.schedule(key, channel)
|
|
104
|
+
this.compose()
|
|
105
|
+
return new MixHandle(this, key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
elapsed(key: string, now = getDriver().now()): number {
|
|
109
|
+
const channel = this.channels.get(key)
|
|
110
|
+
if (!channel) return 0
|
|
111
|
+
return channel.paused ? channel.offset : channel.offset + now - channel.anchor
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
control(key: string, position: number, paused: boolean) {
|
|
115
|
+
const channel = this.channels.get(key)
|
|
116
|
+
if (!channel) return
|
|
117
|
+
channel.timer?.()
|
|
118
|
+
channel.timer = undefined
|
|
119
|
+
channel.offset = position
|
|
120
|
+
channel.anchor = getDriver().now()
|
|
121
|
+
channel.paused = paused
|
|
122
|
+
if (!paused) this.schedule(key, channel)
|
|
123
|
+
this.compose()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A channel leaves: replaced, stopped or done. A stopped layer takes its contribution
|
|
128
|
+
* with it; anything else rests where it is.
|
|
129
|
+
*/
|
|
130
|
+
detach(key: string, stopped: boolean) {
|
|
131
|
+
const channel = this.channels.get(key)
|
|
132
|
+
if (!channel) return
|
|
133
|
+
const value = channel.track.valueAt(this.elapsed(key))
|
|
134
|
+
channel.timer?.()
|
|
135
|
+
this.channels.delete(key)
|
|
136
|
+
this.rests.set(key, stopped && key !== BASE ? identity(this.property) : value)
|
|
137
|
+
this.compose()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Everything stops: what is on screen becomes the base, and layers are cleared. */
|
|
141
|
+
freeze() {
|
|
142
|
+
const value = this.value()
|
|
143
|
+
for (const [key, channel] of [...this.channels]) {
|
|
144
|
+
channel.timer?.()
|
|
145
|
+
this.channels.delete(key)
|
|
146
|
+
channel.onEnd(false)
|
|
147
|
+
}
|
|
148
|
+
this.rests.clear()
|
|
149
|
+
this.rests.set(BASE, value)
|
|
150
|
+
this.compose()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The composed value on screen at `time`. */
|
|
154
|
+
value(time = getDriver().now()): number {
|
|
155
|
+
const now = getDriver().now()
|
|
156
|
+
let result = this.part(BASE, time, now)
|
|
157
|
+
const keys = new Set([...this.rests.keys(), ...this.channels.keys()])
|
|
158
|
+
for (const key of keys) {
|
|
159
|
+
if (key === BASE) continue
|
|
160
|
+
const part = this.part(key, time, now)
|
|
161
|
+
result = multiplies(this.property) ? result * part : result + part
|
|
162
|
+
}
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private part(key: string, time: number, now: number) {
|
|
167
|
+
const channel = this.channels.get(key)
|
|
168
|
+
if (!channel) return this.rest(key)
|
|
169
|
+
const elapsed = channel.paused
|
|
170
|
+
? channel.offset
|
|
171
|
+
: channel.offset + now - channel.anchor + (time - now)
|
|
172
|
+
return channel.track.valueAt(elapsed)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private schedule(key: string, channel: Channel) {
|
|
176
|
+
const remaining = channel.track.end - this.elapsed(key)
|
|
177
|
+
if (!Number.isFinite(remaining)) return
|
|
178
|
+
channel.timer = getDriver().after(Math.max(0, remaining), () => {
|
|
179
|
+
if (this.channels.get(key) !== channel) return
|
|
180
|
+
this.channels.delete(key)
|
|
181
|
+
this.rests.set(key, channel.track.valueAt(channel.track.end))
|
|
182
|
+
channel.onEnd(true)
|
|
183
|
+
this.compose()
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Samples the composition from now and plays it as one native track. */
|
|
188
|
+
private compose() {
|
|
189
|
+
const driver = getDriver()
|
|
190
|
+
const now = driver.now()
|
|
191
|
+
const instance = ++this.generation
|
|
192
|
+
const previous = this.native
|
|
193
|
+
this.native = undefined
|
|
194
|
+
|
|
195
|
+
let finite = 0
|
|
196
|
+
const periods: number[] = []
|
|
197
|
+
for (const [key, channel] of this.channels) {
|
|
198
|
+
if (channel.paused) continue
|
|
199
|
+
const elapsed = this.elapsed(key, now)
|
|
200
|
+
const { track } = channel
|
|
201
|
+
if (Number.isFinite(track.end)) finite = Math.max(finite, track.end - elapsed)
|
|
202
|
+
else if (elapsed < track.delay) finite = Math.max(finite, track.delay - elapsed)
|
|
203
|
+
else periods.push(Math.max(1, Math.round(track.duration * (track.reverse ? 2 : 1))))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (previous !== undefined) driver.stop(this.view, previous)
|
|
207
|
+
const loop = finite <= 0 && periods.length ? leastCommonMultiple(periods) : undefined
|
|
208
|
+
if (finite <= 0 && !periods.length) {
|
|
209
|
+
writeValue(this.view, this.property, this.value(now))
|
|
210
|
+
driver.sync?.(this.view)
|
|
211
|
+
this.dissolve()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const span = finite > 0 ? finite : (loop ?? WINDOW)
|
|
216
|
+
const steps = Math.max(
|
|
217
|
+
1,
|
|
218
|
+
Math.min(MAX_SAMPLES, Math.ceil((span / 1000) * SAMPLES_PER_SECOND)),
|
|
219
|
+
)
|
|
220
|
+
const times = Array.from({ length: steps + 1 }, (_, i) => i / steps)
|
|
221
|
+
const values = times.map((time) => this.value(now + time * span))
|
|
222
|
+
const endless = loop !== undefined
|
|
223
|
+
const at = (elapsed: number) =>
|
|
224
|
+
this.value(now + (endless ? elapsed % span : Math.min(Math.max(elapsed, 0), span)))
|
|
225
|
+
const track: Track = {
|
|
226
|
+
property: this.property,
|
|
227
|
+
delay: 0,
|
|
228
|
+
duration: span,
|
|
229
|
+
repeat: endless ? Infinity : 0,
|
|
230
|
+
reverse: false,
|
|
231
|
+
times,
|
|
232
|
+
values,
|
|
233
|
+
to: endless ? values[0]! : values[steps]!,
|
|
234
|
+
end: endless ? Infinity : span,
|
|
235
|
+
arrival: endless ? Infinity : span,
|
|
236
|
+
valueAt: at,
|
|
237
|
+
velocityAt: (elapsed) => (at(elapsed + 0.5) - at(elapsed - 0.5)) * 1000,
|
|
238
|
+
}
|
|
239
|
+
writeValue(this.view, this.property, track.to)
|
|
240
|
+
this.native = driver.play(this.view, track, now, (finished = true) => {
|
|
241
|
+
if (instance !== this.generation || !finished) return
|
|
242
|
+
this.native = undefined
|
|
243
|
+
this.compose()
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private dissolve() {
|
|
248
|
+
if (this.channels.size) return
|
|
249
|
+
for (const [key, value] of this.rests)
|
|
250
|
+
if (key !== BASE && value !== identity(this.property)) return
|
|
251
|
+
const properties = mixes.get(this.view)
|
|
252
|
+
if (properties?.get(this.property) === this) properties.delete(this.property)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function leastCommonMultiple(periods: readonly number[]): number | undefined {
|
|
257
|
+
const gcd = (a: number, b: number): number => (b ? gcd(b, a % b) : a)
|
|
258
|
+
let result = periods[0]!
|
|
259
|
+
for (const period of periods.slice(1)) {
|
|
260
|
+
result = (result / gcd(result, period)) * period
|
|
261
|
+
if (result > MAX_LOOP) return undefined
|
|
262
|
+
}
|
|
263
|
+
return result
|
|
264
|
+
}
|
package/src/motion-scope.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { animate, decay } from './animate.js'
|
|
2
2
|
import { press } from './press.js'
|
|
3
3
|
import { drag } from './drag.js'
|
|
4
|
+
import { gradient } from './gradient.js'
|
|
4
5
|
import { animateLayout, type LayoutOptions } from './layout.js'
|
|
5
6
|
import type { SpringName } from './transition.js'
|
|
6
7
|
import { sequence } from './sequence.js'
|
|
@@ -39,14 +40,16 @@ export interface MotionScope {
|
|
|
39
40
|
sequence: typeof sequence
|
|
40
41
|
press: typeof press
|
|
41
42
|
drag: typeof drag
|
|
43
|
+
/** Removed with the scope; suspending it stops a gradient animation where it is. */
|
|
44
|
+
gradient: typeof gradient
|
|
42
45
|
states<
|
|
43
|
-
T extends Record<string, Target>,
|
|
46
|
+
T extends Record<string, Target | null>,
|
|
44
47
|
const S extends Record<string, MotionState<keyof T>>,
|
|
45
48
|
>(
|
|
46
49
|
targets: T,
|
|
47
50
|
definitions: MotionDefinitions<T, S>,
|
|
48
|
-
options?: Omit<MotionStatesOptions, 'scope'>,
|
|
49
|
-
): MotionStates<Extract<keyof S, string>>
|
|
51
|
+
options?: Omit<MotionStatesOptions<Extract<keyof T, string>>, 'scope'>,
|
|
52
|
+
): MotionStates<Extract<keyof S, string>, Extract<keyof T, string>>
|
|
50
53
|
/** Adopt an existing handle. Completed animations are released automatically. */
|
|
51
54
|
add<T extends MotionResource>(resource: T): T
|
|
52
55
|
/** Attach a subscription, detach on suspension, and recreate on resume. */
|
|
@@ -189,9 +192,8 @@ export function motionScope(): MotionScope {
|
|
|
189
192
|
: animateLayout(container, changeOrOptions),
|
|
190
193
|
)
|
|
191
194
|
},
|
|
192
|
-
animate(...args)
|
|
193
|
-
|
|
194
|
-
},
|
|
195
|
+
animate: ((...args: Parameters<typeof animate>) =>
|
|
196
|
+
startOwned(() => animate(...args))) as typeof animate,
|
|
195
197
|
decay(...args) {
|
|
196
198
|
return startOwned(() => decay(...args))
|
|
197
199
|
},
|
|
@@ -204,6 +206,12 @@ export function motionScope(): MotionScope {
|
|
|
204
206
|
drag(...args) {
|
|
205
207
|
return startOwned(() => drag(...args))
|
|
206
208
|
},
|
|
209
|
+
gradient(target, options) {
|
|
210
|
+
assertActive()
|
|
211
|
+
const made = gradient(target, options)
|
|
212
|
+
scope.add({ pause: () => made.stop(), play() {}, dispose: () => made.remove() })
|
|
213
|
+
return made
|
|
214
|
+
},
|
|
207
215
|
states(targets, definitions, options) {
|
|
208
216
|
return startOwned(() => motionStates(targets, definitions, options))
|
|
209
217
|
},
|
package/src/motion-states.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { prepareAnimation, toView } from './animate.js'
|
|
1
|
+
import { endingValues, prepareAnimation, set, toView } from './animate.js'
|
|
2
2
|
import { isColorProperty, type ColorValue } from './color.js'
|
|
3
3
|
import { isProperty } from './properties.js'
|
|
4
4
|
import type { Animation } from './engine.js'
|
|
5
5
|
import type { PlaybackAnimation } from './playback.js'
|
|
6
|
-
import type { KeyframeProperty, Keyframes, Target } from './types.js'
|
|
6
|
+
import type { KeyframeProperty, Keyframes, Target, Values } from './types.js'
|
|
7
|
+
import type { View } from '@nativescript/core'
|
|
8
|
+
import { mergeTransitions, springs } from './transition.js'
|
|
7
9
|
import type { Spring, Tween, SpringName } from './transition.js'
|
|
8
10
|
import type { MotionScope } from './motion-scope.js'
|
|
9
11
|
|
|
@@ -20,21 +22,33 @@ export type MotionDefinitions<T, S> = S & {
|
|
|
20
22
|
[K in keyof S[N]]: Record<Exclude<keyof S[N][K], KeyframeProperty>, never>
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
|
-
|
|
25
|
+
type StateCurve =
|
|
24
26
|
| SpringName
|
|
25
27
|
| Omit<Spring, 'repeat' | 'repeatType'>
|
|
26
28
|
| Omit<Tween, 'repeat' | 'repeatType'>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/** One transition for every target, or a main one with some targets set apart by name. */
|
|
30
|
+
export type StateTransition<T extends string = string> =
|
|
31
|
+
| StateCurve
|
|
32
|
+
| (Exclude<StateCurve, SpringName> &
|
|
33
|
+
(string extends T ? unknown : { [K in T]?: StateCurve }))
|
|
34
|
+
export interface MotionStatesOptions<T extends string = string> {
|
|
35
|
+
transition?: StateTransition<T>
|
|
29
36
|
scope?: MotionScope
|
|
30
37
|
}
|
|
31
|
-
export interface MotionStates<S extends string = string> {
|
|
38
|
+
export interface MotionStates<S extends string = string, T extends string = string> {
|
|
32
39
|
/** Latest requested state, not a claim that playback has finished. */
|
|
33
40
|
readonly current: S | undefined
|
|
34
41
|
readonly settled: S | undefined
|
|
35
42
|
readonly disposed: boolean
|
|
36
43
|
readonly paused: boolean
|
|
37
|
-
to(state: S, transition?: StateTransition): Animation
|
|
44
|
+
to(state: S, transition?: StateTransition<T>): Animation
|
|
45
|
+
/**
|
|
46
|
+
* Point a target at a new view, or at none with `null`. The new view takes the current
|
|
47
|
+
* state at once, or joins the transition in progress from where it is.
|
|
48
|
+
*/
|
|
49
|
+
attach(target: T, view: Target | null): void
|
|
50
|
+
/** A stable callback for a template ref: attaches the element and detaches it on unmount. */
|
|
51
|
+
ref(target: T): (view: Target | null) => void
|
|
38
52
|
pause(): void
|
|
39
53
|
play(): void
|
|
40
54
|
stop(): void
|
|
@@ -43,28 +57,38 @@ export interface MotionStates<S extends string = string> {
|
|
|
43
57
|
|
|
44
58
|
/** Coordinated named targets. Omitted properties freeze when leaving their previous state. */
|
|
45
59
|
export function motionStates<
|
|
46
|
-
T extends Record<string, Target>,
|
|
60
|
+
T extends Record<string, Target | null>,
|
|
47
61
|
const S extends Record<string, MotionState<keyof T>>,
|
|
48
62
|
>(
|
|
49
63
|
targets: T,
|
|
50
64
|
definitions: MotionDefinitions<T, S>,
|
|
51
|
-
options: MotionStatesOptions = {},
|
|
52
|
-
): MotionStates<Extract<keyof S, string>> {
|
|
65
|
+
options: MotionStatesOptions<Extract<keyof T, string>> = {},
|
|
66
|
+
): MotionStates<Extract<keyof S, string>, Extract<keyof T, string>> {
|
|
53
67
|
type Name = Extract<keyof S, string>
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
type Slot = Extract<keyof T, string>
|
|
69
|
+
const views = new Map<string, View | null>(
|
|
70
|
+
Object.entries(targets).map(([name, target]) => [
|
|
71
|
+
name,
|
|
72
|
+
target === null ? null : toView(target),
|
|
73
|
+
]),
|
|
56
74
|
)
|
|
57
|
-
|
|
75
|
+
const assigned = [...views.values()].filter(Boolean)
|
|
76
|
+
if (new Set(assigned).size !== assigned.length)
|
|
58
77
|
throw new TypeError('Motion state targets must refer to distinct views.')
|
|
59
|
-
const
|
|
60
|
-
|
|
78
|
+
for (const name of views.keys())
|
|
79
|
+
if (curveKeys.has(name))
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
`A motion target can't be named "${name}", like a transition option.`,
|
|
82
|
+
)
|
|
83
|
+
const states = new Map<string, [string, Keyframes][]>()
|
|
84
|
+
const defaults = normalize(options.transition, views)
|
|
61
85
|
for (const [name, state] of Object.entries(definitions)) {
|
|
62
86
|
if (!state || typeof state !== 'object' || Array.isArray(state))
|
|
63
87
|
throw new TypeError(`Invalid motion state "${name}".`)
|
|
64
|
-
const rows: [
|
|
88
|
+
const rows: [string, Keyframes][] = []
|
|
65
89
|
for (const [target, values] of Object.entries(state)) {
|
|
90
|
+
if (!views.has(target)) throw new TypeError(`Unknown motion target "${target}".`)
|
|
66
91
|
const view = views.get(target)
|
|
67
|
-
if (!view) throw new TypeError(`Unknown motion target "${target}".`)
|
|
68
92
|
if (!values || typeof values !== 'object' || Array.isArray(values))
|
|
69
93
|
throw new TypeError(`Invalid motion values for "${target}".`)
|
|
70
94
|
const frames: Keyframes = {}
|
|
@@ -82,8 +106,9 @@ export function motionStates<
|
|
|
82
106
|
Object.assign(frames, { [property]: value })
|
|
83
107
|
}
|
|
84
108
|
// Validate every definition up front without claiming any native property.
|
|
85
|
-
prepareAnimation(view, frames, defaults)
|
|
86
|
-
|
|
109
|
+
if (view) prepareAnimation(view, frames, timingFor(defaults, target))
|
|
110
|
+
else endingValues(frames, timingFor(defaults, target))
|
|
111
|
+
rows.push([target, frames])
|
|
87
112
|
}
|
|
88
113
|
states.set(name, rows)
|
|
89
114
|
}
|
|
@@ -91,8 +116,14 @@ export function motionStates<
|
|
|
91
116
|
let settled: Name | undefined
|
|
92
117
|
let disposed = false
|
|
93
118
|
let paused = false
|
|
94
|
-
let active:
|
|
95
|
-
|
|
119
|
+
let active:
|
|
120
|
+
{ handle: Animation; children: PlaybackAnimation[]; timing: Timing } | undefined
|
|
121
|
+
const refs = new Map<string, (view: Target | null) => void>()
|
|
122
|
+
const framesFor = (state: Name | undefined, target: string) =>
|
|
123
|
+
state === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: states.get(state)!.find(([slot]) => slot === target)?.[1]
|
|
126
|
+
const controller: MotionStates<Name, Slot> = {
|
|
96
127
|
get current() {
|
|
97
128
|
return current
|
|
98
129
|
},
|
|
@@ -109,10 +140,11 @@ export function motionStates<
|
|
|
109
140
|
if (disposed) throw new Error('Motion states controller is disposed.')
|
|
110
141
|
const rows = states.get(name)
|
|
111
142
|
if (!rows) throw new TypeError(`Unknown motion state "${name}".`)
|
|
112
|
-
const timing = transition
|
|
113
|
-
const prepared = rows.
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
const timing = normalize(transition, views, defaults)
|
|
144
|
+
const prepared = rows.flatMap(([target, frames]) => {
|
|
145
|
+
const view = views.get(target)
|
|
146
|
+
return view ? [prepareAnimation(view, frames, timingFor(timing, target))] : []
|
|
147
|
+
})
|
|
116
148
|
const previous = active
|
|
117
149
|
const children: PlaybackAnimation[] = []
|
|
118
150
|
// Start replacements first: the engine transfers visible position AND velocity.
|
|
@@ -148,12 +180,45 @@ export function motionStates<
|
|
|
148
180
|
},
|
|
149
181
|
then: (resolve, reject) => finished.then(resolve, reject),
|
|
150
182
|
}
|
|
151
|
-
active = { handle, children }
|
|
183
|
+
active = { handle, children, timing }
|
|
152
184
|
current = name
|
|
153
185
|
// Also cancels old, omitted properties, without touching the new owners.
|
|
154
186
|
previous?.handle.stop()
|
|
155
187
|
return handle
|
|
156
188
|
},
|
|
189
|
+
attach(target, next) {
|
|
190
|
+
if (disposed) throw new Error('Motion states controller is disposed.')
|
|
191
|
+
if (!views.has(target)) throw new TypeError(`Unknown motion target "${target}".`)
|
|
192
|
+
const view = next === null ? null : toView(next)
|
|
193
|
+
if (views.get(target) === view) return
|
|
194
|
+
if (view)
|
|
195
|
+
for (const [other, assignedView] of views)
|
|
196
|
+
if (other !== target && assignedView === view)
|
|
197
|
+
throw new TypeError('Motion state targets must refer to distinct views.')
|
|
198
|
+
if (view)
|
|
199
|
+
for (const [, rows] of states)
|
|
200
|
+
for (const [slot, frames] of rows)
|
|
201
|
+
if (slot === target)
|
|
202
|
+
prepareAnimation(view, frames, timingFor(defaults, target))
|
|
203
|
+
views.set(target, view)
|
|
204
|
+
const frames = view ? framesFor(current, target) : undefined
|
|
205
|
+
if (!view || !frames) return
|
|
206
|
+
if (active) {
|
|
207
|
+
const child = prepareAnimation(view, frames, timingFor(active.timing, target))()
|
|
208
|
+
active.children.push(child)
|
|
209
|
+
if (paused) child.pause()
|
|
210
|
+
} else set(view, frames as Values)
|
|
211
|
+
},
|
|
212
|
+
ref(target) {
|
|
213
|
+
let callback = refs.get(target)
|
|
214
|
+
if (!callback) {
|
|
215
|
+
callback = (view) => {
|
|
216
|
+
if (!disposed) controller.attach(target, view)
|
|
217
|
+
}
|
|
218
|
+
refs.set(target, callback)
|
|
219
|
+
}
|
|
220
|
+
return callback
|
|
221
|
+
},
|
|
157
222
|
pause() {
|
|
158
223
|
if (disposed || paused) return
|
|
159
224
|
paused = true
|
|
@@ -177,8 +242,59 @@ export function motionStates<
|
|
|
177
242
|
return options.scope ? options.scope.add(controller) : controller
|
|
178
243
|
}
|
|
179
244
|
|
|
180
|
-
|
|
181
|
-
|
|
245
|
+
const curveKeys = new Set([
|
|
246
|
+
'type',
|
|
247
|
+
'delay',
|
|
248
|
+
'duration',
|
|
249
|
+
'ease',
|
|
250
|
+
'times',
|
|
251
|
+
'stiffness',
|
|
252
|
+
'damping',
|
|
253
|
+
'mass',
|
|
254
|
+
'bounce',
|
|
255
|
+
'velocity',
|
|
256
|
+
])
|
|
257
|
+
|
|
258
|
+
type Curve = Exclude<StateCurve, SpringName>
|
|
259
|
+
interface Timing {
|
|
260
|
+
readonly main: StateCurve
|
|
261
|
+
readonly targets: ReadonlyMap<string, StateCurve>
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The transition one target plays: its own merged over the main one, or the main one. */
|
|
265
|
+
function timingFor(timing: Timing, target: string): StateCurve {
|
|
266
|
+
const own = timing.targets.get(target)
|
|
267
|
+
if (own === undefined) return timing.main
|
|
268
|
+
const main = typeof timing.main === 'string' ? springs[timing.main] : timing.main
|
|
269
|
+
return mergeTransitions(main, typeof own === 'string' ? springs[own] : own) as Curve
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const snappy: Timing = { main: 'snappy', targets: new Map() }
|
|
273
|
+
|
|
274
|
+
/** A transition that only names targets keeps the fallback for everything else. */
|
|
275
|
+
function normalize(
|
|
276
|
+
transition: StateTransition | undefined,
|
|
277
|
+
targets: ReadonlyMap<string, unknown>,
|
|
278
|
+
fallback = snappy,
|
|
279
|
+
): Timing {
|
|
280
|
+
if (transition === undefined) return fallback
|
|
281
|
+
if (typeof transition === 'string')
|
|
282
|
+
return { main: curve(transition), targets: new Map() }
|
|
283
|
+
if (!transition || typeof transition !== 'object')
|
|
284
|
+
throw new TypeError('Invalid state transition.')
|
|
285
|
+
const main: Record<string, unknown> = {}
|
|
286
|
+
const own = new Map<string, StateCurve>()
|
|
287
|
+
for (const [key, value] of Object.entries(transition)) {
|
|
288
|
+
if (targets.has(key)) {
|
|
289
|
+
if (value !== undefined) own.set(key, curve(value as StateCurve))
|
|
290
|
+
} else main[key] = value
|
|
291
|
+
}
|
|
292
|
+
if (own.size && !Object.keys(main).length)
|
|
293
|
+
return { main: fallback.main, targets: new Map([...fallback.targets, ...own]) }
|
|
294
|
+
return { main: curve(main as Curve), targets: own }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function curve(transition: StateCurve): StateCurve {
|
|
182
298
|
if (typeof transition === 'string') {
|
|
183
299
|
if (!['smooth', 'snappy', 'bouncy'].includes(transition))
|
|
184
300
|
throw new TypeError('Unknown state spring.')
|
|
@@ -186,20 +302,8 @@ function normalize(transition: StateTransition | undefined): StateTransition {
|
|
|
186
302
|
}
|
|
187
303
|
if (!transition || typeof transition !== 'object')
|
|
188
304
|
throw new TypeError('Invalid state transition.')
|
|
189
|
-
const allowed = new Set([
|
|
190
|
-
'type',
|
|
191
|
-
'delay',
|
|
192
|
-
'duration',
|
|
193
|
-
'ease',
|
|
194
|
-
'times',
|
|
195
|
-
'stiffness',
|
|
196
|
-
'damping',
|
|
197
|
-
'mass',
|
|
198
|
-
'bounce',
|
|
199
|
-
'velocity',
|
|
200
|
-
])
|
|
201
305
|
for (const key of Object.keys(transition))
|
|
202
|
-
if (!
|
|
306
|
+
if (!curveKeys.has(key))
|
|
203
307
|
throw new TypeError(`State transitions do not support "${key}".`)
|
|
204
308
|
if (
|
|
205
309
|
transition.duration !== undefined &&
|