@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.6
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 +110 -3
- package/dist/animate.d.ts +4 -2
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +48 -11
- package/dist/animate.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +7 -0
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +2 -0
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.js +1 -1
- package/dist/driver/ios-color.js.map +1 -1
- package/dist/driver/ios-decoration.js +1 -1
- package/dist/driver/ios-decoration.js.map +1 -1
- package/dist/driver/ios-playback.d.ts +1 -1
- package/dist/driver/ios-playback.d.ts.map +1 -1
- package/dist/driver/ios-playback.js +14 -0
- package/dist/driver/ios-playback.js.map +1 -1
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +1 -0
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +16 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +33 -1
- package/dist/engine.js.map +1 -1
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/items.d.ts +20 -0
- package/dist/items.d.ts.map +1 -0
- package/dist/items.js +109 -0
- package/dist/items.js.map +1 -0
- package/dist/keyboard.d.ts +17 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +116 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/label.d.ts +7 -0
- package/dist/label.d.ts.map +1 -0
- package/dist/label.js +60 -0
- package/dist/label.js.map +1 -0
- package/dist/liquid.d.ts.map +1 -1
- package/dist/liquid.js +2 -1
- package/dist/liquid.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +2 -1
- package/dist/loader.js.map +1 -1
- package/dist/particles.d.ts.map +1 -1
- package/dist/particles.js +4 -3
- package/dist/particles.js.map +1 -1
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +90 -0
- package/dist/path.js.map +1 -0
- package/dist/playback-group.d.ts.map +1 -1
- package/dist/playback-group.js +29 -1
- package/dist/playback-group.js.map +1 -1
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +3 -3
- package/dist/press.js.map +1 -1
- package/dist/reveal.d.ts.map +1 -1
- package/dist/reveal.js +3 -2
- package/dist/reveal.js.map +1 -1
- package/dist/roll-diff.d.ts +22 -0
- package/dist/roll-diff.d.ts.map +1 -0
- package/dist/roll-diff.js +36 -0
- package/dist/roll-diff.js.map +1 -0
- package/dist/roll.d.ts +29 -0
- package/dist/roll.d.ts.map +1 -0
- package/dist/roll.js +442 -0
- package/dist/roll.js.map +1 -0
- package/dist/scroll-linked.d.ts +10 -0
- package/dist/scroll-linked.d.ts.map +1 -0
- package/dist/scroll-linked.js +58 -0
- package/dist/scroll-linked.js.map +1 -0
- package/dist/sequence.d.ts +10 -5
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +202 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts.map +1 -1
- package/dist/shimmer.js +2 -1
- package/dist/shimmer.js.map +1 -1
- package/dist/svg-path.d.ts +14 -0
- package/dist/svg-path.d.ts.map +1 -0
- package/dist/svg-path.js +281 -0
- package/dist/svg-path.js.map +1 -0
- package/dist/symbol-effect.d.ts.map +1 -1
- package/dist/symbol-effect.js +4 -3
- package/dist/symbol-effect.js.map +1 -1
- package/dist/text-layout.d.ts +8 -0
- package/dist/text-layout.d.ts.map +1 -1
- package/dist/text-layout.js +8 -2
- package/dist/text-layout.js.map +1 -1
- package/dist/text.d.ts +1 -1
- package/dist/text.d.ts.map +1 -1
- package/dist/text.js +60 -64
- package/dist/text.js.map +1 -1
- package/dist/track.d.ts +2 -0
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +62 -15
- package/dist/track.js.map +1 -1
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +89 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +134 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +15 -3
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +6 -0
- package/src/android-plugin.d.ts +64 -0
- package/src/animate.ts +80 -30
- package/src/config.ts +46 -0
- package/src/driver/android.ts +14 -0
- package/src/driver/index.ts +2 -0
- package/src/driver/ios-color.ts +1 -1
- package/src/driver/ios-decoration.ts +1 -1
- package/src/driver/ios-playback.ts +15 -1
- package/src/driver/ios.ts +2 -0
- package/src/engine.ts +47 -2
- package/src/index.ts +11 -1
- package/src/items.ts +140 -0
- package/src/keyboard.ts +132 -0
- package/src/label.ts +76 -0
- package/src/liquid.ts +2 -1
- package/src/loader.ts +2 -1
- package/src/particles.ts +4 -4
- package/src/path.ts +124 -0
- package/src/playback-group.ts +34 -1
- package/src/press.ts +3 -5
- package/src/reveal.ts +3 -3
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +199 -14
- package/src/shimmer.ts +2 -1
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +4 -3
- package/src/text-layout.ts +14 -2
- package/src/text.ts +62 -81
- package/src/track.ts +89 -24
- package/src/types.ts +8 -1
- package/src/vue/index.ts +189 -0
package/src/sequence.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
|
+
import { timeScale } from './config.js'
|
|
1
2
|
import { getDriver } from './driver/index.js'
|
|
2
3
|
import { isDecorationProperty, validateDecorationTarget } from './decoration.js'
|
|
3
4
|
import type { View } from '@nativescript/core'
|
|
4
5
|
import { animate, startingValues, toViews } from './animate.js'
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
arrivalOf,
|
|
8
|
+
arrivalTimingOf,
|
|
9
|
+
setArrival,
|
|
10
|
+
stopView,
|
|
11
|
+
type Animation,
|
|
12
|
+
} from './engine.js'
|
|
13
|
+
import { createClock } from './playback.js'
|
|
6
14
|
import type { PresetName } from './presets.js'
|
|
7
15
|
import { writeValue, type Property } from './properties.js'
|
|
8
16
|
import type { SpringName } from './transition.js'
|
|
9
17
|
import type { AnimateOptions, Keyframes, Target } from './types.js'
|
|
10
18
|
|
|
19
|
+
/** Absolute milliseconds, a signed offset from the previous arrival, or its start. */
|
|
20
|
+
export type SequencePosition = number | '<' | `${'+' | '-'}${number}`
|
|
21
|
+
export type SequenceOptions = AnimateOptions & { at?: SequencePosition }
|
|
22
|
+
|
|
11
23
|
export type Step =
|
|
12
24
|
| readonly [
|
|
13
25
|
target: Target | readonly Target[],
|
|
14
26
|
animation: Keyframes | PresetName,
|
|
15
|
-
options?:
|
|
27
|
+
options?: SequenceOptions | SpringName,
|
|
16
28
|
]
|
|
17
29
|
| (() => Animation)
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
32
|
+
* By default a step starts once the previous one looks done. `at` can overlap
|
|
33
|
+
* that arrival, share the previous start, or select an absolute timeline time.
|
|
22
34
|
* Views in later steps take their first absolute keyframe right away, so a title
|
|
23
35
|
* that fades in third doesn't show up before its turn. Relative and null keyframes
|
|
24
36
|
* read the current value when their step starts.
|
|
@@ -26,26 +38,180 @@ export type Step =
|
|
|
26
38
|
* ```ts
|
|
27
39
|
* await sequence([
|
|
28
40
|
* [logo, 'popIn'],
|
|
29
|
-
* [title, 'fadeInUp'],
|
|
30
|
-
* [buttons, 'fadeInUp', { stagger: 50 }],
|
|
41
|
+
* [title, 'fadeInUp', { at: '-150' }],
|
|
42
|
+
* [buttons, 'fadeInUp', { stagger: 50, at: '<' }],
|
|
31
43
|
* ])
|
|
32
44
|
* ```
|
|
33
45
|
*/
|
|
34
46
|
export function sequence(steps: readonly Step[]): Animation {
|
|
35
|
-
|
|
47
|
+
const prepared = steps.map((step) => {
|
|
48
|
+
if (typeof step === 'function') return { step, position: undefined }
|
|
49
|
+
const [target, animation, options] = step
|
|
50
|
+
if (typeof options !== 'object' || options === null)
|
|
51
|
+
return { step, position: undefined }
|
|
52
|
+
const { at, ...transition } = options
|
|
53
|
+
const position = parsePosition(at)
|
|
54
|
+
return { step: [target, animation, transition] as Step, position }
|
|
55
|
+
})
|
|
56
|
+
applyStartingValues(prepared.map(({ step }) => step))
|
|
36
57
|
|
|
58
|
+
const driver = getDriver()
|
|
59
|
+
const slowdown = 1 / timeScale()
|
|
60
|
+
// Absolute positions share the platform clock, including Android duration scale.
|
|
61
|
+
const clock = prepared.some(({ position }) => typeof position === 'number')
|
|
62
|
+
? (driver.clock?.(Infinity) ?? createClock(() => driver.now()))
|
|
63
|
+
: undefined
|
|
37
64
|
const started: Animation[] = []
|
|
65
|
+
const cancellations = new Set<() => void>()
|
|
38
66
|
let stopped = false
|
|
67
|
+
let resolveScheduled!: () => void
|
|
68
|
+
let rejectScheduled!: (reason: unknown) => void
|
|
69
|
+
const scheduled = new Promise<void>((resolve, reject) => {
|
|
70
|
+
resolveScheduled = resolve
|
|
71
|
+
rejectScheduled = reject
|
|
72
|
+
})
|
|
73
|
+
let pending = prepared.length
|
|
74
|
+
const states = prepared.map(({ step, position }) => {
|
|
75
|
+
return {
|
|
76
|
+
step,
|
|
77
|
+
position,
|
|
78
|
+
onStart: new Set<() => void>(),
|
|
79
|
+
animation: undefined as Animation | undefined,
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
const cancelSchedule = () => {
|
|
83
|
+
for (const cancel of cancellations) cancel()
|
|
84
|
+
cancellations.clear()
|
|
85
|
+
clock?.cancel()
|
|
86
|
+
}
|
|
87
|
+
const fail = (error: unknown) => {
|
|
88
|
+
if (stopped) return
|
|
89
|
+
stopped = true
|
|
90
|
+
cancelSchedule()
|
|
91
|
+
states.forEach((state) => state.onStart.clear())
|
|
92
|
+
started.forEach((child) => child.stop())
|
|
93
|
+
rejectScheduled(error)
|
|
94
|
+
}
|
|
95
|
+
const launch = (index: number) => {
|
|
96
|
+
const state = states[index]!
|
|
97
|
+
if (stopped || state.animation) return
|
|
98
|
+
try {
|
|
99
|
+
const step = state.step
|
|
100
|
+
const child = typeof step === 'function' ? step() : animate(...step)
|
|
101
|
+
state.animation = child
|
|
102
|
+
started.push(child)
|
|
103
|
+
// Observe setup failures immediately, even while other steps are still
|
|
104
|
+
// scheduled. Nested sequences can reject before this one reaches arrival.
|
|
105
|
+
void child.finished.catch(fail)
|
|
106
|
+
pending--
|
|
107
|
+
for (const callback of [...state.onStart]) callback()
|
|
108
|
+
state.onStart.clear()
|
|
109
|
+
if (pending === 0) {
|
|
110
|
+
cancelSchedule()
|
|
111
|
+
resolveScheduled()
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
fail(error)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const wait = (ms: number, callback: () => void) => {
|
|
118
|
+
if (stopped) return
|
|
119
|
+
if (ms <= 0) {
|
|
120
|
+
callback()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
let cancel = () => {}
|
|
124
|
+
let fired = false
|
|
125
|
+
const off = driver.after(ms, () => {
|
|
126
|
+
fired = true
|
|
127
|
+
cancellations.delete(cancel)
|
|
128
|
+
if (!stopped) callback()
|
|
129
|
+
})
|
|
130
|
+
cancel = off
|
|
131
|
+
if (!fired) cancellations.add(cancel)
|
|
132
|
+
}
|
|
39
133
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
134
|
+
for (let index = 0; index < states.length; index++) {
|
|
135
|
+
const { position } = states[index]!
|
|
136
|
+
const begin = () => launch(index)
|
|
137
|
+
if (typeof position === 'number') wait(position * slowdown - clock!.time(), begin)
|
|
138
|
+
else if (index === 0)
|
|
139
|
+
wait(
|
|
140
|
+
typeof position === 'object' ? Math.max(0, position.offset) * slowdown : 0,
|
|
141
|
+
begin,
|
|
142
|
+
)
|
|
143
|
+
else {
|
|
144
|
+
const previous = states[index - 1]!
|
|
145
|
+
const follow = () => {
|
|
146
|
+
if (stopped || !previous.animation) return
|
|
147
|
+
if (position === '<') {
|
|
148
|
+
begin()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const offset = (position?.offset ?? 0) * slowdown
|
|
152
|
+
if (offset < 0) {
|
|
153
|
+
const timing = arrivalTimingOf(previous.animation)
|
|
154
|
+
if (!timing) {
|
|
155
|
+
fail(
|
|
156
|
+
new TypeError(
|
|
157
|
+
'A negative at offset needs an animate() or decay() arrival forecast.',
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
let off = () => {}
|
|
163
|
+
let cancelTimer = () => {}
|
|
164
|
+
let fired = false
|
|
165
|
+
let disposed = false
|
|
166
|
+
let queued = false
|
|
167
|
+
const dispose = () => {
|
|
168
|
+
disposed = true
|
|
169
|
+
off()
|
|
170
|
+
cancelTimer()
|
|
171
|
+
cancellations.delete(dispose)
|
|
172
|
+
}
|
|
173
|
+
const update = () => {
|
|
174
|
+
cancelTimer()
|
|
175
|
+
if (stopped || fired || disposed) return
|
|
176
|
+
const state = timing.read()
|
|
177
|
+
if (!state.arrived && (state.paused || state.remaining === undefined)) return
|
|
178
|
+
const remaining = state.arrived ? 0 : state.remaining! + offset
|
|
179
|
+
if (!Number.isFinite(remaining)) return
|
|
180
|
+
const fire = () => {
|
|
181
|
+
if (stopped || fired || disposed) return
|
|
182
|
+
fired = true
|
|
183
|
+
dispose()
|
|
184
|
+
begin()
|
|
185
|
+
}
|
|
186
|
+
if (remaining <= 0) fire()
|
|
187
|
+
else {
|
|
188
|
+
const cancel = driver.after(remaining, fire)
|
|
189
|
+
if (disposed) cancel()
|
|
190
|
+
else cancelTimer = cancel
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
off = timing.subscribe(() => {
|
|
194
|
+
cancelTimer()
|
|
195
|
+
// An interruption can notify from inside another animation's
|
|
196
|
+
// constructor. Start followers after its ownership changes finish.
|
|
197
|
+
if (queued || disposed) return
|
|
198
|
+
queued = true
|
|
199
|
+
void Promise.resolve().then(() => {
|
|
200
|
+
queued = false
|
|
201
|
+
update()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
cancellations.add(dispose)
|
|
205
|
+
update()
|
|
206
|
+
} else void arrivalOf(previous.animation).then(() => wait(offset, begin), fail)
|
|
207
|
+
}
|
|
208
|
+
if (previous.animation) follow()
|
|
209
|
+
else previous.onStart.add(follow)
|
|
46
210
|
}
|
|
47
|
-
}
|
|
211
|
+
}
|
|
212
|
+
if (pending === 0) resolveScheduled()
|
|
48
213
|
|
|
214
|
+
const arrived = scheduled.then(() => Promise.all(started.map(arrivalOf)))
|
|
49
215
|
const finished = arrived.then(async () => {
|
|
50
216
|
const results = await Promise.all(started.map((animation) => animation.finished))
|
|
51
217
|
return !stopped && results.length === steps.length && results.every(Boolean)
|
|
@@ -54,7 +220,11 @@ export function sequence(steps: readonly Step[]): Animation {
|
|
|
54
220
|
const animation: Animation = {
|
|
55
221
|
finished,
|
|
56
222
|
stop() {
|
|
223
|
+
if (stopped) return
|
|
57
224
|
stopped = true
|
|
225
|
+
cancelSchedule()
|
|
226
|
+
states.forEach((state) => state.onStart.clear())
|
|
227
|
+
resolveScheduled()
|
|
58
228
|
started.forEach((animation) => animation.stop())
|
|
59
229
|
},
|
|
60
230
|
then: (onfulfilled, onrejected) => finished.then(onfulfilled, onrejected),
|
|
@@ -63,6 +233,21 @@ export function sequence(steps: readonly Step[]): Animation {
|
|
|
63
233
|
return animation
|
|
64
234
|
}
|
|
65
235
|
|
|
236
|
+
type Position = number | '<' | { offset: number } | undefined
|
|
237
|
+
function parsePosition(at: SequencePosition | undefined): Position {
|
|
238
|
+
if (at === undefined || at === '<') return at
|
|
239
|
+
if (typeof at === 'number' && Number.isFinite(at) && at >= 0) return at
|
|
240
|
+
if (
|
|
241
|
+
typeof at === 'string' &&
|
|
242
|
+
/^[+-](?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(at) &&
|
|
243
|
+
Number.isFinite(Number(at))
|
|
244
|
+
)
|
|
245
|
+
return { offset: Number(at) }
|
|
246
|
+
throw new RangeError(
|
|
247
|
+
'Sequence at must be a nonnegative finite time, "<", or a signed finite offset.',
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
66
251
|
function applyStartingValues(steps: readonly Step[]) {
|
|
67
252
|
const claimed = new Map<View, Set<Property>>()
|
|
68
253
|
for (const step of steps) {
|
package/src/shimmer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Application, isIOS, type View } from '@nativescript/core'
|
|
2
2
|
import { toView } from './animate.js'
|
|
3
3
|
import type { Target } from './types.js'
|
|
4
|
+
import { reducedMotion } from './config.js'
|
|
4
5
|
|
|
5
6
|
export interface ShimmerOptions {
|
|
6
7
|
/** Milliseconds per sweep. Defaults to 1500. */
|
|
@@ -98,7 +99,7 @@ class Shimmer {
|
|
|
98
99
|
UIAccessibilityReduceMotionStatusDidChangeNotification,
|
|
99
100
|
this.update,
|
|
100
101
|
)
|
|
101
|
-
if (
|
|
102
|
+
if (reducedMotion()) return this.remove()
|
|
102
103
|
const bounds = native.layer.bounds
|
|
103
104
|
if (bounds.size.width <= 0 || bounds.size.height <= 0) return this.remove()
|
|
104
105
|
if (this.native && this.native !== native) this.remove()
|
package/src/svg-path.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/** SVG path geometry in local DIPs. No DOM or platform path API is used. */
|
|
2
|
+
export interface PathPoint {
|
|
3
|
+
x: number
|
|
4
|
+
y: number
|
|
5
|
+
}
|
|
6
|
+
export interface PathPose extends PathPoint {
|
|
7
|
+
rotate: number
|
|
8
|
+
}
|
|
9
|
+
type Segment = { point(t: number): PathPoint; tangent(t: number): PathPoint }
|
|
10
|
+
type Vertex = PathPoint & {
|
|
11
|
+
segment: number
|
|
12
|
+
t: number
|
|
13
|
+
distance: number
|
|
14
|
+
heading: number
|
|
15
|
+
}
|
|
16
|
+
export interface MotionPath {
|
|
17
|
+
readonly length: number
|
|
18
|
+
at(progress: number): PathPose
|
|
19
|
+
}
|
|
20
|
+
const LIMIT = 1_000_000
|
|
21
|
+
const MAX_SEGMENTS = 1024
|
|
22
|
+
const MAX_VERTICES = 8192
|
|
23
|
+
const TOLERANCE = 0.025
|
|
24
|
+
const distance = (a: PathPoint, b: PathPoint) => Math.hypot(b.x - a.x, b.y - a.y)
|
|
25
|
+
const mix = (a: PathPoint, b: PathPoint, t: number): PathPoint => ({
|
|
26
|
+
x: a.x + (b.x - a.x) * t,
|
|
27
|
+
y: a.y + (b.y - a.y) * t,
|
|
28
|
+
})
|
|
29
|
+
const checked = (p: PathPoint) => {
|
|
30
|
+
if (![p.x, p.y].every((n) => Number.isFinite(n) && Math.abs(n) <= LIMIT))
|
|
31
|
+
throw new RangeError('Path coordinates must stay within ±1000000 DIPs.')
|
|
32
|
+
return p
|
|
33
|
+
}
|
|
34
|
+
const unwrap = (angle: number, reference: number) =>
|
|
35
|
+
angle + 360 * Math.round((reference - angle) / 360)
|
|
36
|
+
|
|
37
|
+
class Scanner {
|
|
38
|
+
index = 0
|
|
39
|
+
private hasNumber = false
|
|
40
|
+
constructor(readonly text: string) {}
|
|
41
|
+
peek() {
|
|
42
|
+
while (/[\t\n\r ]/.test(this.text[this.index] ?? '\0')) this.index++
|
|
43
|
+
return this.text[this.index]
|
|
44
|
+
}
|
|
45
|
+
command() {
|
|
46
|
+
const value = this.peek()!
|
|
47
|
+
this.index++
|
|
48
|
+
this.hasNumber = false
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
number(flag = false) {
|
|
52
|
+
this.peek()
|
|
53
|
+
if (this.text[this.index] === ',') {
|
|
54
|
+
if (!this.hasNumber) this.error()
|
|
55
|
+
this.index++
|
|
56
|
+
this.peek()
|
|
57
|
+
}
|
|
58
|
+
const rest = this.text.slice(this.index)
|
|
59
|
+
const token = flag
|
|
60
|
+
? /^[01]/.exec(rest)?.[0]
|
|
61
|
+
: /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?/i.exec(rest)?.[0]
|
|
62
|
+
if (!token) this.error()
|
|
63
|
+
const value = Number(token)
|
|
64
|
+
if (!Number.isFinite(value) || Math.abs(value) > LIMIT)
|
|
65
|
+
throw new RangeError('Path numbers must be finite and within ±1000000.')
|
|
66
|
+
this.index += token!.length
|
|
67
|
+
this.hasNumber = true
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
error(): never {
|
|
71
|
+
throw new SyntaxError(`Invalid SVG path at character ${this.index}.`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function line(a: PathPoint, b: PathPoint): Segment {
|
|
76
|
+
return { point: (t) => mix(a, b, t), tangent: () => ({ x: b.x - a.x, y: b.y - a.y }) }
|
|
77
|
+
}
|
|
78
|
+
function bezier(points: readonly PathPoint[]): Segment {
|
|
79
|
+
const evaluate = (values: readonly PathPoint[], t: number): PathPoint => {
|
|
80
|
+
let row = [...values]
|
|
81
|
+
while (row.length > 1) row = row.slice(1).map((p, i) => mix(row[i]!, p, t))
|
|
82
|
+
return row[0]!
|
|
83
|
+
}
|
|
84
|
+
const derivative = points.slice(1).map((p, i) => ({
|
|
85
|
+
x: (p.x - points[i]!.x) * (points.length - 1),
|
|
86
|
+
y: (p.y - points[i]!.y) * (points.length - 1),
|
|
87
|
+
}))
|
|
88
|
+
return { point: (t) => evaluate(points, t), tangent: (t) => evaluate(derivative, t) }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Endpoint-to-center conversion: https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter */
|
|
92
|
+
function arc(
|
|
93
|
+
a: PathPoint,
|
|
94
|
+
b: PathPoint,
|
|
95
|
+
rx: number,
|
|
96
|
+
ry: number,
|
|
97
|
+
degrees: number,
|
|
98
|
+
large: boolean,
|
|
99
|
+
sweep: boolean,
|
|
100
|
+
): Segment {
|
|
101
|
+
rx = Math.abs(rx)
|
|
102
|
+
ry = Math.abs(ry)
|
|
103
|
+
if (!rx || !ry || distance(a, b) === 0) return line(a, b)
|
|
104
|
+
const phi = ((degrees % 360) * Math.PI) / 180,
|
|
105
|
+
cos = Math.cos(phi),
|
|
106
|
+
sin = Math.sin(phi)
|
|
107
|
+
const dx = (a.x - b.x) / 2,
|
|
108
|
+
dy = (a.y - b.y) / 2
|
|
109
|
+
const xp = cos * dx + sin * dy,
|
|
110
|
+
yp = -sin * dx + cos * dy
|
|
111
|
+
const correction = Math.max(1, Math.hypot(xp / rx, yp / ry))
|
|
112
|
+
rx *= correction
|
|
113
|
+
ry *= correction
|
|
114
|
+
const u = xp / rx,
|
|
115
|
+
v = yp / ry,
|
|
116
|
+
norm = u * u + v * v
|
|
117
|
+
const factor = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, (1 - norm) / norm))
|
|
118
|
+
const cxp = factor * rx * v,
|
|
119
|
+
cyp = -factor * ry * u
|
|
120
|
+
const cx = cos * cxp - sin * cyp + (a.x + b.x) / 2,
|
|
121
|
+
cy = sin * cxp + cos * cyp + (a.y + b.y) / 2
|
|
122
|
+
const ux = (xp - cxp) / rx,
|
|
123
|
+
uy = (yp - cyp) / ry,
|
|
124
|
+
vx = (-xp - cxp) / rx,
|
|
125
|
+
vy = (-yp - cyp) / ry
|
|
126
|
+
const start = Math.atan2(uy, ux)
|
|
127
|
+
let delta = Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
|
|
128
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI
|
|
129
|
+
if (sweep && delta < 0) delta += 2 * Math.PI
|
|
130
|
+
if (![cx, cy, rx, ry, start, delta].every(Number.isFinite))
|
|
131
|
+
throw new RangeError('Path arc exceeds finite geometry limits.')
|
|
132
|
+
return {
|
|
133
|
+
point(t) {
|
|
134
|
+
if (t === 0) return a
|
|
135
|
+
if (t === 1) return b
|
|
136
|
+
const angle = start + t * delta,
|
|
137
|
+
x = rx * Math.cos(angle),
|
|
138
|
+
y = ry * Math.sin(angle)
|
|
139
|
+
return checked({ x: cx + cos * x - sin * y, y: cy + sin * x + cos * y })
|
|
140
|
+
},
|
|
141
|
+
tangent(t) {
|
|
142
|
+
const angle = start + t * delta,
|
|
143
|
+
x = -rx * Math.sin(angle) * delta,
|
|
144
|
+
y = ry * Math.cos(angle) * delta
|
|
145
|
+
return { x: cos * x - sin * y, y: sin * x + cos * y }
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function parsePath(data: string): MotionPath {
|
|
151
|
+
if (typeof data !== 'string' || !data.trim())
|
|
152
|
+
throw new TypeError('Path must be a nonempty SVG path string.')
|
|
153
|
+
if (data.length > 100_000)
|
|
154
|
+
throw new RangeError('SVG path is too long (100000 characters maximum).')
|
|
155
|
+
const scanner = new Scanner(data)
|
|
156
|
+
const segments: Segment[] = []
|
|
157
|
+
let current: PathPoint = { x: 0, y: 0 },
|
|
158
|
+
first: PathPoint | undefined
|
|
159
|
+
let command = '',
|
|
160
|
+
previous = '',
|
|
161
|
+
control: PathPoint = current
|
|
162
|
+
const add = (segment: Segment, end: PathPoint) => {
|
|
163
|
+
if (segments.length >= MAX_SEGMENTS)
|
|
164
|
+
throw new RangeError('SVG path has too many segments (1024 maximum).')
|
|
165
|
+
segments.push(segment)
|
|
166
|
+
current = checked(end)
|
|
167
|
+
}
|
|
168
|
+
while (scanner.peek() !== undefined) {
|
|
169
|
+
const token = scanner.peek()!
|
|
170
|
+
if (/[a-z]/i.test(token)) {
|
|
171
|
+
command = scanner.command()
|
|
172
|
+
if (!/[mlhvcsqtaz]/i.test(command)) scanner.error()
|
|
173
|
+
} else if (!command) scanner.error()
|
|
174
|
+
const upper = command.toUpperCase(),
|
|
175
|
+
relative = command !== upper,
|
|
176
|
+
origin = current
|
|
177
|
+
const point = () =>
|
|
178
|
+
checked({
|
|
179
|
+
x: scanner.number() + (relative ? origin.x : 0),
|
|
180
|
+
y: scanner.number() + (relative ? origin.y : 0),
|
|
181
|
+
})
|
|
182
|
+
if (!first && upper !== 'M') throw new SyntaxError('SVG path must start with M or m.')
|
|
183
|
+
if (upper === 'M') {
|
|
184
|
+
if (first)
|
|
185
|
+
throw new SyntaxError(
|
|
186
|
+
'Motion paths support one continuous subpath; use a single M command.',
|
|
187
|
+
)
|
|
188
|
+
first = current = point()
|
|
189
|
+
command = relative ? 'l' : 'L'
|
|
190
|
+
} else if (upper === 'Z') {
|
|
191
|
+
add(line(current, first!), first!)
|
|
192
|
+
command = ''
|
|
193
|
+
} else if (upper === 'L') {
|
|
194
|
+
const end = point()
|
|
195
|
+
add(line(current, end), end)
|
|
196
|
+
} else if (upper === 'H' || upper === 'V') {
|
|
197
|
+
const n = scanner.number(),
|
|
198
|
+
end = checked(
|
|
199
|
+
upper === 'H'
|
|
200
|
+
? { x: n + (relative ? origin.x : 0), y: current.y }
|
|
201
|
+
: { x: current.x, y: n + (relative ? origin.y : 0) },
|
|
202
|
+
)
|
|
203
|
+
add(line(current, end), end)
|
|
204
|
+
} else if (upper === 'C' || upper === 'S') {
|
|
205
|
+
const c1 =
|
|
206
|
+
upper === 'C'
|
|
207
|
+
? point()
|
|
208
|
+
: /[CS]/.test(previous)
|
|
209
|
+
? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
|
|
210
|
+
: current
|
|
211
|
+
const c2 = point(),
|
|
212
|
+
end = point()
|
|
213
|
+
checked(c1)
|
|
214
|
+
add(bezier([current, c1, c2, end]), end)
|
|
215
|
+
control = c2
|
|
216
|
+
} else if (upper === 'Q' || upper === 'T') {
|
|
217
|
+
const c =
|
|
218
|
+
upper === 'Q'
|
|
219
|
+
? point()
|
|
220
|
+
: /[QT]/.test(previous)
|
|
221
|
+
? { x: 2 * current.x - control.x, y: 2 * current.y - control.y }
|
|
222
|
+
: current
|
|
223
|
+
const end = point()
|
|
224
|
+
checked(c)
|
|
225
|
+
add(bezier([current, c, end]), end)
|
|
226
|
+
control = c
|
|
227
|
+
} else if (upper === 'A') {
|
|
228
|
+
const rx = scanner.number(),
|
|
229
|
+
ry = scanner.number(),
|
|
230
|
+
rotation = scanner.number(),
|
|
231
|
+
large = scanner.number(true),
|
|
232
|
+
sweep = scanner.number(true),
|
|
233
|
+
end = point()
|
|
234
|
+
add(arc(current, end, rx, ry, rotation, !!large, !!sweep), end)
|
|
235
|
+
}
|
|
236
|
+
previous = upper
|
|
237
|
+
}
|
|
238
|
+
return measure(first!, segments)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function measure(first: PathPoint, segments: readonly Segment[]): MotionPath {
|
|
242
|
+
const vertices: Vertex[] = [{ ...first, segment: -1, t: 0, distance: 0, heading: 0 }]
|
|
243
|
+
const heading = (segment: Segment, t: number, fallback: PathPoint) => {
|
|
244
|
+
let v = segment.tangent(t)
|
|
245
|
+
if (Math.hypot(v.x, v.y) < 1e-9) {
|
|
246
|
+
const a = segment.point(Math.max(0, t - 1e-5)),
|
|
247
|
+
b = segment.point(Math.min(1, t + 1e-5))
|
|
248
|
+
v = { x: b.x - a.x, y: b.y - a.y }
|
|
249
|
+
if (Math.hypot(v.x, v.y) < 1e-9) v = fallback
|
|
250
|
+
}
|
|
251
|
+
return (Math.atan2(v.y, v.x) * 180) / Math.PI
|
|
252
|
+
}
|
|
253
|
+
const append = (p: PathPoint, index: number, t: number) => {
|
|
254
|
+
const last = vertices.at(-1)!,
|
|
255
|
+
length = distance(last, p)
|
|
256
|
+
if (!length) return
|
|
257
|
+
if (vertices.length >= MAX_VERTICES)
|
|
258
|
+
throw new RangeError('Path needs too many geometry samples (8192 maximum).')
|
|
259
|
+
const direction = { x: p.x - last.x, y: p.y - last.y }
|
|
260
|
+
const start = heading(
|
|
261
|
+
segments[index]!,
|
|
262
|
+
last.segment === index ? last.t : 0,
|
|
263
|
+
direction,
|
|
264
|
+
)
|
|
265
|
+
if (vertices.length === 1) last.heading = start
|
|
266
|
+
const angle = unwrap(
|
|
267
|
+
heading(segments[index]!, t, direction),
|
|
268
|
+
unwrap(start, last.heading),
|
|
269
|
+
)
|
|
270
|
+
vertices.push({
|
|
271
|
+
...checked(p),
|
|
272
|
+
segment: index,
|
|
273
|
+
t,
|
|
274
|
+
distance: last.distance + length,
|
|
275
|
+
heading: angle,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
segments.forEach((segment, index) => {
|
|
279
|
+
const flatten = (
|
|
280
|
+
t0: number,
|
|
281
|
+
t1: number,
|
|
282
|
+
a: PathPoint,
|
|
283
|
+
b: PathPoint,
|
|
284
|
+
depth: number,
|
|
285
|
+
) => {
|
|
286
|
+
const span = t1 - t0,
|
|
287
|
+
q1 = segment.point(t0 + span * 0.25),
|
|
288
|
+
mid = segment.point(t0 + span * 0.5),
|
|
289
|
+
q3 = segment.point(t0 + span * 0.75)
|
|
290
|
+
const poly =
|
|
291
|
+
distance(a, q1) + distance(q1, mid) + distance(mid, q3) + distance(q3, b),
|
|
292
|
+
chord = distance(a, b)
|
|
293
|
+
const deviation = Math.max(
|
|
294
|
+
distance(q1, mix(a, b, 0.25)),
|
|
295
|
+
distance(mid, mix(a, b, 0.5)),
|
|
296
|
+
distance(q3, mix(a, b, 0.75)),
|
|
297
|
+
)
|
|
298
|
+
const directions = [
|
|
299
|
+
t0,
|
|
300
|
+
t0 + span * 0.25,
|
|
301
|
+
t0 + span * 0.5,
|
|
302
|
+
t0 + span * 0.75,
|
|
303
|
+
t1,
|
|
304
|
+
].map((t) => segment.tangent(t))
|
|
305
|
+
const turn = directions.some((v, i) => {
|
|
306
|
+
const before = directions[i - 1]
|
|
307
|
+
return (
|
|
308
|
+
before &&
|
|
309
|
+
Math.hypot(v.x, v.y) > 1e-12 &&
|
|
310
|
+
Math.hypot(before.x, before.y) > 1e-12 &&
|
|
311
|
+
Math.abs(
|
|
312
|
+
Math.atan2(before.x * v.y - before.y * v.x, before.x * v.x + before.y * v.y),
|
|
313
|
+
) >
|
|
314
|
+
Math.PI / 8
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
const flat = poly - chord <= TOLERANCE && deviation <= TOLERANCE
|
|
318
|
+
if (flat && (!turn || depth >= 18)) {
|
|
319
|
+
append(b, index, t1)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (depth >= 18)
|
|
323
|
+
throw new RangeError('Path geometry exceeds the sampling precision limit.')
|
|
324
|
+
flatten(t0, (t0 + t1) / 2, a, mid, depth + 1)
|
|
325
|
+
flatten((t0 + t1) / 2, t1, mid, b, depth + 1)
|
|
326
|
+
}
|
|
327
|
+
flatten(0, 1, segment.point(0), segment.point(1), 0)
|
|
328
|
+
})
|
|
329
|
+
const length = vertices.at(-1)!.distance
|
|
330
|
+
return {
|
|
331
|
+
length,
|
|
332
|
+
at(progress) {
|
|
333
|
+
if (!length) return { ...first, rotate: 0 }
|
|
334
|
+
const target = Math.max(0, Math.min(1, progress)) * length
|
|
335
|
+
let low = 1,
|
|
336
|
+
high = vertices.length - 1
|
|
337
|
+
while (low < high) {
|
|
338
|
+
const middle = (low + high) >>> 1
|
|
339
|
+
if (vertices[middle]!.distance <= target) low = middle + 1
|
|
340
|
+
else high = middle
|
|
341
|
+
}
|
|
342
|
+
const b = vertices[low]!,
|
|
343
|
+
a = vertices[low - 1]!,
|
|
344
|
+
fraction = Math.max(
|
|
345
|
+
0,
|
|
346
|
+
Math.min(1, (target - a.distance) / (b.distance - a.distance)),
|
|
347
|
+
)
|
|
348
|
+
const point = mix(a, b, fraction),
|
|
349
|
+
t0 = a.segment === b.segment ? a.t : 0,
|
|
350
|
+
t = t0 + (b.t - t0) * fraction
|
|
351
|
+
const angle = heading(segments[b.segment]!, t, { x: b.x - a.x, y: b.y - a.y })
|
|
352
|
+
return { ...point, rotate: unwrap(angle, b.heading) }
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/symbol-effect.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Image, ImageSource, isIOS } from '@nativescript/core'
|
|
|
2
2
|
import { toView } from './animate.js'
|
|
3
3
|
import type { Animation } from './engine.js'
|
|
4
4
|
import type { Target } from './types.js'
|
|
5
|
+
import { reducedMotion } from './config.js'
|
|
5
6
|
|
|
6
7
|
export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
|
|
7
8
|
|
|
@@ -121,7 +122,7 @@ class SymbolAnimation implements Animation {
|
|
|
121
122
|
return
|
|
122
123
|
this.started = true
|
|
123
124
|
const native = (this.native = view.nativeViewProtected as UIImageView)
|
|
124
|
-
const
|
|
125
|
+
const reduced = reducedMotion()
|
|
125
126
|
|
|
126
127
|
if (typeof effect !== 'string') {
|
|
127
128
|
// Resolve first so a symbol absent on this OS doesn't clear the current image.
|
|
@@ -145,7 +146,7 @@ class SymbolAnimation implements Animation {
|
|
|
145
146
|
const image = native.image
|
|
146
147
|
if (!image) return this.end(false)
|
|
147
148
|
if (
|
|
148
|
-
|
|
149
|
+
reduced ||
|
|
149
150
|
!previous?.symbolImage ||
|
|
150
151
|
!native.respondsToSelector(
|
|
151
152
|
'setSymbolImage:withContentTransition:options:completion:',
|
|
@@ -165,7 +166,7 @@ class SymbolAnimation implements Animation {
|
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
if (
|
|
168
|
-
|
|
169
|
+
reduced ||
|
|
169
170
|
!native.image?.symbolImage ||
|
|
170
171
|
!native.respondsToSelector('addSymbolEffect:options:animated:completion:')
|
|
171
172
|
)
|