@rafaelaugusto/jolt 0.1.0-beta.1 → 0.1.0-beta.11
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 +385 -18
- package/dist/animate.d.ts +19 -3
- package/dist/animate.d.ts.map +1 -1
- package/dist/animate.js +186 -14
- package/dist/animate.js.map +1 -1
- package/dist/color.d.ts +11 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +73 -0
- package/dist/color.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/config.js.map +1 -0
- package/dist/decay.d.ts +11 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +22 -0
- package/dist/decay.js.map +1 -0
- package/dist/decoration.d.ts +18 -0
- package/dist/decoration.d.ts.map +1 -0
- package/dist/decoration.js +155 -0
- package/dist/decoration.js.map +1 -0
- package/dist/drag-input.d.ts +15 -0
- package/dist/drag-input.d.ts.map +1 -0
- package/dist/drag-input.js +66 -0
- package/dist/drag-input.js.map +1 -0
- package/dist/drag-physics.d.ts +20 -0
- package/dist/drag-physics.d.ts.map +1 -0
- package/dist/drag-physics.js +75 -0
- package/dist/drag-physics.js.map +1 -0
- package/dist/drag.d.ts +58 -0
- package/dist/drag.d.ts.map +1 -0
- package/dist/drag.js +324 -0
- package/dist/drag.js.map +1 -0
- package/dist/driver/android-color.d.ts +20 -0
- package/dist/driver/android-color.d.ts.map +1 -0
- package/dist/driver/android-color.js +136 -0
- package/dist/driver/android-color.js.map +1 -0
- package/dist/driver/android-decoration.d.ts +22 -0
- package/dist/driver/android-decoration.d.ts.map +1 -0
- package/dist/driver/android-decoration.js +139 -0
- package/dist/driver/android-decoration.js.map +1 -0
- 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/android-playback.d.ts +13 -0
- package/dist/driver/android-playback.d.ts.map +1 -0
- package/dist/driver/android-playback.js +141 -0
- package/dist/driver/android-playback.js.map +1 -0
- package/dist/driver/android.d.ts +1 -1
- package/dist/driver/android.d.ts.map +1 -1
- package/dist/driver/android.js +165 -13
- package/dist/driver/android.js.map +1 -1
- package/dist/driver/index.d.ts +25 -2
- package/dist/driver/index.d.ts.map +1 -1
- package/dist/driver/index.js.map +1 -1
- package/dist/driver/ios-color.d.ts +16 -0
- package/dist/driver/ios-color.d.ts.map +1 -0
- package/dist/driver/ios-color.js +392 -0
- package/dist/driver/ios-color.js.map +1 -0
- package/dist/driver/ios-decoration.d.ts +17 -0
- package/dist/driver/ios-decoration.d.ts.map +1 -0
- package/dist/driver/ios-decoration.js +271 -0
- package/dist/driver/ios-decoration.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/driver/ios-layout.d.ts +9 -0
- package/dist/driver/ios-layout.d.ts.map +1 -0
- package/dist/driver/ios-layout.js +52 -0
- package/dist/driver/ios-layout.js.map +1 -0
- package/dist/driver/ios-playback.d.ts +10 -0
- package/dist/driver/ios-playback.d.ts.map +1 -0
- package/dist/driver/ios-playback.js +47 -0
- package/dist/driver/ios-playback.js.map +1 -0
- package/dist/driver/ios-shadow.d.ts +6 -0
- package/dist/driver/ios-shadow.d.ts.map +1 -0
- package/dist/driver/ios-shadow.js +52 -0
- package/dist/driver/ios-shadow.js.map +1 -0
- package/dist/driver/ios.d.ts.map +1 -1
- package/dist/driver/ios.js +163 -28
- package/dist/driver/ios.js.map +1 -1
- package/dist/engine.d.ts +75 -5
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +293 -23
- 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 +247 -0
- package/dist/gradient.js.map +1 -0
- package/dist/haptics.d.ts +21 -0
- package/dist/haptics.d.ts.map +1 -0
- package/dist/haptics.js +120 -0
- package/dist/haptics.js.map +1 -0
- package/dist/image-effect-native.d.ts +26 -0
- package/dist/image-effect-native.d.ts.map +1 -0
- package/dist/image-effect-native.js +73 -0
- package/dist/image-effect-native.js.map +1 -0
- package/dist/image-effect.d.ts +50 -0
- package/dist/image-effect.d.ts.map +1 -0
- package/dist/image-effect.js +290 -0
- package/dist/image-effect.js.map +1 -0
- package/dist/image-owner.d.ts +3 -0
- package/dist/image-owner.d.ts.map +1 -0
- package/dist/image-owner.js +11 -0
- package/dist/image-owner.js.map +1 -0
- package/dist/index.d.ts +47 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/dist/interaction-surface.d.ts +12 -0
- package/dist/interaction-surface.d.ts.map +1 -0
- package/dist/interaction-surface.js +36 -0
- package/dist/interaction-surface.js.map +1 -0
- 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 +41 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +256 -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/layout.d.ts +13 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +348 -0
- package/dist/layout.js.map +1 -0
- package/dist/liquid-grid.d.ts +3 -0
- package/dist/liquid-grid.d.ts.map +1 -0
- package/dist/liquid-grid.js +24 -0
- package/dist/liquid-grid.js.map +1 -0
- package/dist/liquid.d.ts +18 -0
- package/dist/liquid.d.ts.map +1 -0
- package/dist/liquid.js +234 -0
- package/dist/liquid.js.map +1 -0
- package/dist/loader.d.ts +19 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +173 -0
- package/dist/loader.js.map +1 -0
- package/dist/motion-scope.d.ts +39 -0
- package/dist/motion-scope.d.ts.map +1 -0
- package/dist/motion-scope.js +238 -0
- package/dist/motion-scope.js.map +1 -0
- package/dist/motion-states.d.ts +43 -0
- package/dist/motion-states.d.ts.map +1 -0
- package/dist/motion-states.js +220 -0
- package/dist/motion-states.js.map +1 -0
- package/dist/particles.d.ts +39 -0
- package/dist/particles.d.ts.map +1 -0
- package/dist/particles.js +265 -0
- package/dist/particles.js.map +1 -0
- 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 +6 -0
- package/dist/playback-group.d.ts.map +1 -0
- package/dist/playback-group.js +141 -0
- package/dist/playback-group.js.map +1 -0
- package/dist/playback.d.ts +25 -0
- package/dist/playback.d.ts.map +1 -0
- package/dist/playback.js +35 -0
- package/dist/playback.js.map +1 -0
- package/dist/press.d.ts.map +1 -1
- package/dist/press.js +23 -7
- package/dist/press.js.map +1 -1
- package/dist/properties.d.ts +6 -3
- package/dist/properties.d.ts.map +1 -1
- package/dist/properties.js +26 -2
- package/dist/properties.js.map +1 -1
- package/dist/reveal.d.ts +25 -0
- package/dist/reveal.d.ts.map +1 -0
- package/dist/reveal.js +294 -0
- package/dist/reveal.js.map +1 -0
- 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 +208 -14
- package/dist/sequence.js.map +1 -1
- package/dist/shimmer.d.ts +12 -0
- package/dist/shimmer.d.ts.map +1 -0
- package/dist/shimmer.js +161 -0
- package/dist/shimmer.js.map +1 -0
- 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 +23 -0
- package/dist/symbol-effect.d.ts.map +1 -0
- package/dist/symbol-effect.js +187 -0
- package/dist/symbol-effect.js.map +1 -0
- package/dist/text-layout.d.ts +30 -0
- package/dist/text-layout.d.ts.map +1 -0
- package/dist/text-layout.js +185 -0
- package/dist/text-layout.js.map +1 -0
- package/dist/text.d.ts +17 -0
- package/dist/text.d.ts.map +1 -0
- package/dist/text.js +263 -0
- package/dist/text.js.map +1 -0
- package/dist/track.d.ts +3 -1
- package/dist/track.d.ts.map +1 -1
- package/dist/track.js +266 -13
- package/dist/track.js.map +1 -1
- package/dist/transition.d.ts +32 -1
- package/dist/transition.d.ts.map +1 -1
- package/dist/transition.js +58 -6
- package/dist/transition.js.map +1 -1
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vue/index.d.ts +95 -0
- package/dist/vue/index.d.ts.map +1 -0
- package/dist/vue/index.js +163 -0
- package/dist/vue/index.js.map +1 -0
- package/dist/zoom-native.d.ts +30 -0
- package/dist/zoom-native.d.ts.map +1 -0
- package/dist/zoom-native.js +56 -0
- package/dist/zoom-native.js.map +1 -0
- package/dist/zoomable.d.ts +48 -0
- package/dist/zoomable.d.ts.map +1 -0
- package/dist/zoomable.js +249 -0
- package/dist/zoomable.js.map +1 -0
- package/package.json +29 -5
- package/platforms/android/include.gradle +7 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/GradientLayer.java +195 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
- package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
- package/platforms/android/proguard-rules.pro +21 -0
- package/platforms/ios/build.xcconfig +1 -0
- package/platforms/ios/src/JoltImageEffect.h +25 -0
- package/platforms/ios/src/JoltImageEffect.mm +242 -0
- package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
- package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
- package/platforms/ios/src/JoltPhotoZoom.h +23 -0
- package/platforms/ios/src/JoltPhotoZoom.m +229 -0
- package/platforms/ios/src/module.modulemap +6 -0
- package/src/android-plugin.d.ts +184 -0
- package/src/animate.ts +249 -36
- package/src/color.ts +101 -0
- package/src/config.ts +84 -0
- package/src/decay.ts +45 -0
- package/src/decoration.ts +180 -0
- package/src/drag-input.ts +74 -0
- package/src/drag-physics.ts +110 -0
- package/src/drag.ts +395 -0
- package/src/driver/android-color.ts +170 -0
- package/src/driver/android-decoration.ts +182 -0
- package/src/driver/android-gradient.ts +109 -0
- package/src/driver/android-playback.ts +157 -0
- package/src/driver/android.ts +208 -20
- package/src/driver/index.ts +37 -2
- package/src/driver/ios-color.ts +441 -0
- package/src/driver/ios-decoration.ts +332 -0
- package/src/driver/ios-gradient.ts +114 -0
- package/src/driver/ios-layout.ts +63 -0
- package/src/driver/ios-playback.ts +51 -0
- package/src/driver/ios-shadow.ts +50 -0
- package/src/driver/ios.ts +193 -33
- package/src/engine.ts +321 -27
- package/src/gradient.ts +372 -0
- package/src/haptics.ts +148 -0
- package/src/image-effect-native.ts +129 -0
- package/src/image-effect.ts +336 -0
- package/src/image-owner.ts +12 -0
- package/src/index.ts +75 -4
- package/src/interaction-surface.ts +41 -0
- package/src/ios-plugin.d.ts +81 -0
- package/src/items.ts +140 -0
- package/src/keyboard.ts +291 -0
- package/src/label.ts +76 -0
- package/src/layout.ts +408 -0
- package/src/liquid-grid.ts +28 -0
- package/src/liquid.ts +287 -0
- package/src/loader.ts +202 -0
- package/src/motion-scope.ts +279 -0
- package/src/motion-states.ts +269 -0
- package/src/particles.ts +323 -0
- package/src/path.ts +124 -0
- package/src/playback-group.ts +143 -0
- package/src/playback.ts +62 -0
- package/src/press.ts +26 -10
- package/src/properties.ts +32 -6
- package/src/reveal.ts +374 -0
- package/src/roll-diff.ts +57 -0
- package/src/roll.ts +515 -0
- package/src/scroll-linked.ts +73 -0
- package/src/sequence.ts +204 -14
- package/src/shimmer.ts +180 -0
- package/src/svg-path.ts +355 -0
- package/src/symbol-effect.ts +226 -0
- package/src/text-layout.ts +228 -0
- package/src/text.ts +279 -0
- package/src/track.ts +347 -12
- package/src/transition.ts +93 -6
- package/src/types.ts +49 -1
- package/src/vue/index.ts +231 -0
- package/src/zoom-native.ts +124 -0
- package/src/zoomable.ts +330 -0
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
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Image, ImageSource, isIOS } from '@nativescript/core'
|
|
2
|
+
import { toView } from './animate.js'
|
|
3
|
+
import type { Animation } from './engine.js'
|
|
4
|
+
import type { Target } from './types.js'
|
|
5
|
+
import { reducedMotion } from './config.js'
|
|
6
|
+
|
|
7
|
+
export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
|
|
8
|
+
|
|
9
|
+
export type SymbolEffect = SymbolEffectName | { replace: `sys://${string}` }
|
|
10
|
+
|
|
11
|
+
export interface SymbolEffectOptions {
|
|
12
|
+
/** Native playback speed multiplier. Defaults to 1. */
|
|
13
|
+
speed?: number
|
|
14
|
+
/** Repeat bounce, pulse or variableColor until stopped. Defaults to false. */
|
|
15
|
+
repeat?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const running = new WeakMap<Image, SymbolAnimation>()
|
|
19
|
+
const names: readonly string[] = ['bounce', 'pulse', 'scale', 'variableColor']
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Plays an iOS 17+ SF Symbol effect on a NativeScript Image.
|
|
23
|
+
* Scale holds until stopped; replacements also update the Image's src.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* symbolEffect(icon, 'bounce')
|
|
27
|
+
* symbolEffect(icon, { replace: 'sys://heart.fill' })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function symbolEffect(
|
|
31
|
+
target: Target,
|
|
32
|
+
effect: SymbolEffect,
|
|
33
|
+
options: SymbolEffectOptions = {},
|
|
34
|
+
): Animation {
|
|
35
|
+
const view = toView(target)
|
|
36
|
+
if (!(view instanceof Image)) throw new TypeError('symbolEffect expects an Image.')
|
|
37
|
+
if (
|
|
38
|
+
typeof effect === 'string'
|
|
39
|
+
? !names.includes(effect)
|
|
40
|
+
: !effect ||
|
|
41
|
+
typeof effect.replace !== 'string' ||
|
|
42
|
+
!/^sys:\/\/[^\s/]+$/.test(effect.replace)
|
|
43
|
+
) {
|
|
44
|
+
throw new TypeError('Expected a symbol effect name or { replace: "sys://name" }.')
|
|
45
|
+
}
|
|
46
|
+
const speed = options.speed ?? 1
|
|
47
|
+
if (!Number.isFinite(speed) || speed <= 0)
|
|
48
|
+
throw new RangeError('Symbol effect speed must be a positive finite number.')
|
|
49
|
+
if (options.repeat !== undefined && typeof options.repeat !== 'boolean')
|
|
50
|
+
throw new TypeError('Symbol effect repeat must be a boolean.')
|
|
51
|
+
if (options.repeat && (typeof effect !== 'string' || effect === 'scale'))
|
|
52
|
+
throw new TypeError('Only bounce, pulse and variableColor can repeat.')
|
|
53
|
+
|
|
54
|
+
return new SymbolAnimation(view, effect, speed, options.repeat ?? false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class SymbolAnimation implements Animation {
|
|
58
|
+
readonly finished: Promise<boolean>
|
|
59
|
+
private resolve!: (finished: boolean) => void
|
|
60
|
+
private settled = false
|
|
61
|
+
private started = false
|
|
62
|
+
private changingSource = false
|
|
63
|
+
private waitingForLoad: boolean
|
|
64
|
+
private native: UIImageView | undefined
|
|
65
|
+
private nativeEffect: NSSymbolEffect | undefined
|
|
66
|
+
private replacement: UIImage | undefined
|
|
67
|
+
|
|
68
|
+
constructor(
|
|
69
|
+
private readonly view: Image,
|
|
70
|
+
private readonly effect: SymbolEffect,
|
|
71
|
+
private readonly speed: number,
|
|
72
|
+
private readonly repeat: boolean,
|
|
73
|
+
) {
|
|
74
|
+
this.finished = new Promise((resolve) => (this.resolve = resolve))
|
|
75
|
+
this.waitingForLoad = !view.isLoaded
|
|
76
|
+
running.get(view)?.stop()
|
|
77
|
+
running.set(view, this)
|
|
78
|
+
view.on('unloaded', this.stop)
|
|
79
|
+
view.on('srcChange', this.sourceChanged)
|
|
80
|
+
view.on('imageSourceChange', this.imageChanged)
|
|
81
|
+
view.on('loaded', this.loaded)
|
|
82
|
+
view.on('isLoadingChange', this.start)
|
|
83
|
+
this.start()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
then<A = boolean, B = never>(
|
|
87
|
+
onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
|
|
88
|
+
onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
|
|
89
|
+
): Promise<A | B> {
|
|
90
|
+
return this.finished.then(onfulfilled, onrejected)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
stop = () => this.end(false, true)
|
|
94
|
+
|
|
95
|
+
private sourceChanged = () => {
|
|
96
|
+
if (!this.changingSource) this.stop()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private imageChanged = () => {
|
|
100
|
+
if (this.changingSource) return
|
|
101
|
+
if (this.started) this.stop()
|
|
102
|
+
else this.start()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private loaded = () => {
|
|
106
|
+
this.waitingForLoad = false
|
|
107
|
+
this.start()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private start = () => {
|
|
111
|
+
if (this.settled || this.started) return
|
|
112
|
+
if (!isIOS) return this.end(false)
|
|
113
|
+
const { view, effect } = this
|
|
114
|
+
// isLoaded becomes true before NativeScript resumes native property updates.
|
|
115
|
+
// Wait for the loaded event so intermediate image resets don't start playback.
|
|
116
|
+
if (
|
|
117
|
+
this.waitingForLoad ||
|
|
118
|
+
!view.isLoaded ||
|
|
119
|
+
!view.nativeViewProtected ||
|
|
120
|
+
view.isLoading
|
|
121
|
+
)
|
|
122
|
+
return
|
|
123
|
+
this.started = true
|
|
124
|
+
const native = (this.native = view.nativeViewProtected as UIImageView)
|
|
125
|
+
const reduced = reducedMotion()
|
|
126
|
+
|
|
127
|
+
if (typeof effect !== 'string') {
|
|
128
|
+
// Resolve first so a symbol absent on this OS doesn't clear the current image.
|
|
129
|
+
if (!ImageSource.fromSystemImageSync(effect.replace.slice(6))?.ios)
|
|
130
|
+
return this.end(false)
|
|
131
|
+
const previous = native.image
|
|
132
|
+
const loadMode = view.loadMode
|
|
133
|
+
this.changingSource = true
|
|
134
|
+
try {
|
|
135
|
+
// Let NativeScript update its source, tint, configuration and pending loads.
|
|
136
|
+
// Both assignments happen in this turn, before UIKit draws the next frame.
|
|
137
|
+
view.loadMode = 'sync'
|
|
138
|
+
view.src = effect.replace
|
|
139
|
+
} finally {
|
|
140
|
+
view.loadMode = loadMode
|
|
141
|
+
this.changingSource = false
|
|
142
|
+
}
|
|
143
|
+
// A source-change listener may have started another effect or changed the src.
|
|
144
|
+
if (this.settled) return
|
|
145
|
+
if (view.src !== effect.replace) return this.end(false)
|
|
146
|
+
const image = native.image
|
|
147
|
+
if (!image) return this.end(false)
|
|
148
|
+
if (
|
|
149
|
+
reduced ||
|
|
150
|
+
!previous?.symbolImage ||
|
|
151
|
+
!native.respondsToSelector(
|
|
152
|
+
'setSymbolImage:withContentTransition:options:completion:',
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return this.end(true)
|
|
156
|
+
|
|
157
|
+
this.replacement = image
|
|
158
|
+
native.image = previous
|
|
159
|
+
native.setSymbolImageWithContentTransitionOptionsCompletion(
|
|
160
|
+
image,
|
|
161
|
+
NSSymbolReplaceContentTransition.transition(),
|
|
162
|
+
this.options(),
|
|
163
|
+
this.completed,
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (
|
|
169
|
+
reduced ||
|
|
170
|
+
!native.image?.symbolImage ||
|
|
171
|
+
!native.respondsToSelector('addSymbolEffect:options:animated:completion:')
|
|
172
|
+
)
|
|
173
|
+
return this.end(false)
|
|
174
|
+
|
|
175
|
+
const factories = {
|
|
176
|
+
bounce: NSSymbolBounceEffect,
|
|
177
|
+
pulse: NSSymbolPulseEffect,
|
|
178
|
+
scale: NSSymbolScaleEffect,
|
|
179
|
+
variableColor: NSSymbolVariableColorEffect,
|
|
180
|
+
}
|
|
181
|
+
this.nativeEffect = factories[effect].effect()
|
|
182
|
+
native.addSymbolEffectOptionsAnimatedCompletion(
|
|
183
|
+
this.nativeEffect,
|
|
184
|
+
this.options(),
|
|
185
|
+
true,
|
|
186
|
+
this.completed,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private options(): NSSymbolEffectOptions {
|
|
191
|
+
const options = this.repeat
|
|
192
|
+
? NSSymbolEffectOptions.optionsWithRepeating()
|
|
193
|
+
: NSSymbolEffectOptions.optionsWithNonRepeating()
|
|
194
|
+
return options.optionsWithSpeed(this.speed)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private completed = (context: UISymbolEffectCompletionContext) => {
|
|
198
|
+
// Scale's entrance can complete while the effect is still holding its state.
|
|
199
|
+
if (context.finished && (this.effect === 'scale' || this.repeat)) return
|
|
200
|
+
this.end(context.finished)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private end(finished: boolean, remove = false) {
|
|
204
|
+
if (this.settled) return
|
|
205
|
+
this.settled = true
|
|
206
|
+
const { view, native, nativeEffect, replacement } = this
|
|
207
|
+
view.off('loaded', this.loaded)
|
|
208
|
+
view.off('isLoadingChange', this.start)
|
|
209
|
+
view.off('unloaded', this.stop)
|
|
210
|
+
view.off('srcChange', this.sourceChanged)
|
|
211
|
+
view.off('imageSourceChange', this.imageChanged)
|
|
212
|
+
if (running.get(view) === this) running.delete(view)
|
|
213
|
+
if (remove && native) {
|
|
214
|
+
if (nativeEffect) native.removeSymbolEffectOfType(nativeEffect)
|
|
215
|
+
// Content transitions have no removal API. Keep the destination icon.
|
|
216
|
+
if (replacement && native.image === replacement) {
|
|
217
|
+
native.image = null
|
|
218
|
+
native.image = replacement
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
this.native = undefined
|
|
222
|
+
this.nativeEffect = undefined
|
|
223
|
+
this.replacement = undefined
|
|
224
|
+
this.resolve(finished)
|
|
225
|
+
}
|
|
226
|
+
}
|