@rafaelaugusto/jolt 0.1.0-beta.0 → 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/README.md +441 -25
  2. package/dist/animate.d.ts +21 -5
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +205 -25
  5. package/dist/animate.js.map +1 -1
  6. package/dist/color.d.ts +11 -0
  7. package/dist/color.d.ts.map +1 -0
  8. package/dist/color.js +73 -0
  9. package/dist/color.js.map +1 -0
  10. package/dist/config.d.ts +31 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +73 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/decay.d.ts +11 -0
  15. package/dist/decay.d.ts.map +1 -0
  16. package/dist/decay.js +22 -0
  17. package/dist/decay.js.map +1 -0
  18. package/dist/decoration.d.ts +18 -0
  19. package/dist/decoration.d.ts.map +1 -0
  20. package/dist/decoration.js +155 -0
  21. package/dist/decoration.js.map +1 -0
  22. package/dist/drag-input.d.ts +15 -0
  23. package/dist/drag-input.d.ts.map +1 -0
  24. package/dist/drag-input.js +66 -0
  25. package/dist/drag-input.js.map +1 -0
  26. package/dist/drag-physics.d.ts +20 -0
  27. package/dist/drag-physics.d.ts.map +1 -0
  28. package/dist/drag-physics.js +75 -0
  29. package/dist/drag-physics.js.map +1 -0
  30. package/dist/drag.d.ts +58 -0
  31. package/dist/drag.d.ts.map +1 -0
  32. package/dist/drag.js +324 -0
  33. package/dist/drag.js.map +1 -0
  34. package/dist/driver/android-color.d.ts +20 -0
  35. package/dist/driver/android-color.d.ts.map +1 -0
  36. package/dist/driver/android-color.js +136 -0
  37. package/dist/driver/android-color.js.map +1 -0
  38. package/dist/driver/android-decoration.d.ts +22 -0
  39. package/dist/driver/android-decoration.d.ts.map +1 -0
  40. package/dist/driver/android-decoration.js +139 -0
  41. package/dist/driver/android-decoration.js.map +1 -0
  42. package/dist/driver/android-playback.d.ts +13 -0
  43. package/dist/driver/android-playback.d.ts.map +1 -0
  44. package/dist/driver/android-playback.js +141 -0
  45. package/dist/driver/android-playback.js.map +1 -0
  46. package/dist/driver/android.d.ts +1 -1
  47. package/dist/driver/android.d.ts.map +1 -1
  48. package/dist/driver/android.js +165 -13
  49. package/dist/driver/android.js.map +1 -1
  50. package/dist/driver/index.d.ts +25 -2
  51. package/dist/driver/index.d.ts.map +1 -1
  52. package/dist/driver/index.js.map +1 -1
  53. package/dist/driver/ios-color.d.ts +16 -0
  54. package/dist/driver/ios-color.d.ts.map +1 -0
  55. package/dist/driver/ios-color.js +392 -0
  56. package/dist/driver/ios-color.js.map +1 -0
  57. package/dist/driver/ios-decoration.d.ts +17 -0
  58. package/dist/driver/ios-decoration.d.ts.map +1 -0
  59. package/dist/driver/ios-decoration.js +271 -0
  60. package/dist/driver/ios-decoration.js.map +1 -0
  61. package/dist/driver/ios-layout.d.ts +9 -0
  62. package/dist/driver/ios-layout.d.ts.map +1 -0
  63. package/dist/driver/ios-layout.js +52 -0
  64. package/dist/driver/ios-layout.js.map +1 -0
  65. package/dist/driver/ios-playback.d.ts +10 -0
  66. package/dist/driver/ios-playback.d.ts.map +1 -0
  67. package/dist/driver/ios-playback.js +47 -0
  68. package/dist/driver/ios-playback.js.map +1 -0
  69. package/dist/driver/ios-shadow.d.ts +6 -0
  70. package/dist/driver/ios-shadow.d.ts.map +1 -0
  71. package/dist/driver/ios-shadow.js +52 -0
  72. package/dist/driver/ios-shadow.js.map +1 -0
  73. package/dist/driver/ios.d.ts.map +1 -1
  74. package/dist/driver/ios.js +164 -20
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +76 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +303 -23
  79. package/dist/engine.js.map +1 -1
  80. package/dist/haptics.d.ts +21 -0
  81. package/dist/haptics.d.ts.map +1 -0
  82. package/dist/haptics.js +120 -0
  83. package/dist/haptics.js.map +1 -0
  84. package/dist/image-effect-native.d.ts +26 -0
  85. package/dist/image-effect-native.d.ts.map +1 -0
  86. package/dist/image-effect-native.js +73 -0
  87. package/dist/image-effect-native.js.map +1 -0
  88. package/dist/image-effect.d.ts +50 -0
  89. package/dist/image-effect.d.ts.map +1 -0
  90. package/dist/image-effect.js +290 -0
  91. package/dist/image-effect.js.map +1 -0
  92. package/dist/image-owner.d.ts +3 -0
  93. package/dist/image-owner.d.ts.map +1 -0
  94. package/dist/image-owner.js +11 -0
  95. package/dist/image-owner.js.map +1 -0
  96. package/dist/index.d.ts +45 -4
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +21 -2
  99. package/dist/index.js.map +1 -1
  100. package/dist/interaction-surface.d.ts +12 -0
  101. package/dist/interaction-surface.d.ts.map +1 -0
  102. package/dist/interaction-surface.js +36 -0
  103. package/dist/interaction-surface.js.map +1 -0
  104. package/dist/items.d.ts +20 -0
  105. package/dist/items.d.ts.map +1 -0
  106. package/dist/items.js +109 -0
  107. package/dist/items.js.map +1 -0
  108. package/dist/keyboard.d.ts +41 -0
  109. package/dist/keyboard.d.ts.map +1 -0
  110. package/dist/keyboard.js +256 -0
  111. package/dist/keyboard.js.map +1 -0
  112. package/dist/label.d.ts +7 -0
  113. package/dist/label.d.ts.map +1 -0
  114. package/dist/label.js +60 -0
  115. package/dist/label.js.map +1 -0
  116. package/dist/layout.d.ts +13 -0
  117. package/dist/layout.d.ts.map +1 -0
  118. package/dist/layout.js +348 -0
  119. package/dist/layout.js.map +1 -0
  120. package/dist/liquid-grid.d.ts +3 -0
  121. package/dist/liquid-grid.d.ts.map +1 -0
  122. package/dist/liquid-grid.js +24 -0
  123. package/dist/liquid-grid.js.map +1 -0
  124. package/dist/liquid.d.ts +18 -0
  125. package/dist/liquid.d.ts.map +1 -0
  126. package/dist/liquid.js +234 -0
  127. package/dist/liquid.js.map +1 -0
  128. package/dist/loader.d.ts +19 -0
  129. package/dist/loader.d.ts.map +1 -0
  130. package/dist/loader.js +173 -0
  131. package/dist/loader.js.map +1 -0
  132. package/dist/motion-scope.d.ts +39 -0
  133. package/dist/motion-scope.d.ts.map +1 -0
  134. package/dist/motion-scope.js +238 -0
  135. package/dist/motion-scope.js.map +1 -0
  136. package/dist/motion-states.d.ts +36 -0
  137. package/dist/motion-states.d.ts.map +1 -0
  138. package/dist/motion-states.js +165 -0
  139. package/dist/motion-states.js.map +1 -0
  140. package/dist/particles.d.ts +39 -0
  141. package/dist/particles.d.ts.map +1 -0
  142. package/dist/particles.js +265 -0
  143. package/dist/particles.js.map +1 -0
  144. package/dist/path.d.ts +12 -0
  145. package/dist/path.d.ts.map +1 -0
  146. package/dist/path.js +90 -0
  147. package/dist/path.js.map +1 -0
  148. package/dist/playback-group.d.ts +6 -0
  149. package/dist/playback-group.d.ts.map +1 -0
  150. package/dist/playback-group.js +141 -0
  151. package/dist/playback-group.js.map +1 -0
  152. package/dist/playback.d.ts +25 -0
  153. package/dist/playback.d.ts.map +1 -0
  154. package/dist/playback.js +35 -0
  155. package/dist/playback.js.map +1 -0
  156. package/dist/presets.d.ts +16 -1
  157. package/dist/presets.d.ts.map +1 -1
  158. package/dist/presets.js +24 -6
  159. package/dist/presets.js.map +1 -1
  160. package/dist/press.d.ts.map +1 -1
  161. package/dist/press.js +25 -7
  162. package/dist/press.js.map +1 -1
  163. package/dist/properties.d.ts +6 -3
  164. package/dist/properties.d.ts.map +1 -1
  165. package/dist/properties.js +26 -2
  166. package/dist/properties.js.map +1 -1
  167. package/dist/reveal.d.ts +25 -0
  168. package/dist/reveal.d.ts.map +1 -0
  169. package/dist/reveal.js +294 -0
  170. package/dist/reveal.js.map +1 -0
  171. package/dist/roll-diff.d.ts +22 -0
  172. package/dist/roll-diff.d.ts.map +1 -0
  173. package/dist/roll-diff.js +36 -0
  174. package/dist/roll-diff.js.map +1 -0
  175. package/dist/roll.d.ts +29 -0
  176. package/dist/roll.d.ts.map +1 -0
  177. package/dist/roll.js +442 -0
  178. package/dist/roll.js.map +1 -0
  179. package/dist/scroll-linked.d.ts +10 -0
  180. package/dist/scroll-linked.d.ts.map +1 -0
  181. package/dist/scroll-linked.js +58 -0
  182. package/dist/scroll-linked.js.map +1 -0
  183. package/dist/sequence.d.ts +13 -7
  184. package/dist/sequence.d.ts.map +1 -1
  185. package/dist/sequence.js +214 -19
  186. package/dist/sequence.js.map +1 -1
  187. package/dist/shimmer.d.ts +12 -0
  188. package/dist/shimmer.d.ts.map +1 -0
  189. package/dist/shimmer.js +161 -0
  190. package/dist/shimmer.js.map +1 -0
  191. package/dist/svg-path.d.ts +14 -0
  192. package/dist/svg-path.d.ts.map +1 -0
  193. package/dist/svg-path.js +281 -0
  194. package/dist/svg-path.js.map +1 -0
  195. package/dist/symbol-effect.d.ts +23 -0
  196. package/dist/symbol-effect.d.ts.map +1 -0
  197. package/dist/symbol-effect.js +187 -0
  198. package/dist/symbol-effect.js.map +1 -0
  199. package/dist/text-layout.d.ts +30 -0
  200. package/dist/text-layout.d.ts.map +1 -0
  201. package/dist/text-layout.js +185 -0
  202. package/dist/text-layout.js.map +1 -0
  203. package/dist/text.d.ts +17 -0
  204. package/dist/text.d.ts.map +1 -0
  205. package/dist/text.js +263 -0
  206. package/dist/text.js.map +1 -0
  207. package/dist/track.d.ts +3 -1
  208. package/dist/track.d.ts.map +1 -1
  209. package/dist/track.js +266 -13
  210. package/dist/track.js.map +1 -1
  211. package/dist/transition.d.ts +17 -1
  212. package/dist/transition.d.ts.map +1 -1
  213. package/dist/transition.js +41 -6
  214. package/dist/transition.js.map +1 -1
  215. package/dist/types.d.ts +50 -1
  216. package/dist/types.d.ts.map +1 -1
  217. package/dist/vue/index.d.ts +95 -0
  218. package/dist/vue/index.d.ts.map +1 -0
  219. package/dist/vue/index.js +163 -0
  220. package/dist/vue/index.js.map +1 -0
  221. package/dist/zoom-native.d.ts +30 -0
  222. package/dist/zoom-native.d.ts.map +1 -0
  223. package/dist/zoom-native.js +56 -0
  224. package/dist/zoom-native.js.map +1 -0
  225. package/dist/zoomable.d.ts +48 -0
  226. package/dist/zoomable.d.ts.map +1 -0
  227. package/dist/zoomable.js +249 -0
  228. package/dist/zoomable.js.map +1 -0
  229. package/package.json +33 -5
  230. package/platforms/android/include.gradle +7 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  235. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  236. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  237. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  238. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  239. package/platforms/android/proguard-rules.pro +19 -0
  240. package/platforms/ios/build.xcconfig +1 -0
  241. package/platforms/ios/src/JoltImageEffect.h +25 -0
  242. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  243. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  244. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  245. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  246. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  247. package/platforms/ios/src/module.modulemap +6 -0
  248. package/src/android-plugin.d.ts +155 -0
  249. package/src/animate.ts +278 -38
  250. package/src/color.ts +101 -0
  251. package/src/config.ts +84 -0
  252. package/src/decay.ts +45 -0
  253. package/src/decoration.ts +180 -0
  254. package/src/drag-input.ts +74 -0
  255. package/src/drag-physics.ts +110 -0
  256. package/src/drag.ts +395 -0
  257. package/src/driver/android-color.ts +170 -0
  258. package/src/driver/android-decoration.ts +182 -0
  259. package/src/driver/android-playback.ts +157 -0
  260. package/src/driver/android.ts +208 -20
  261. package/src/driver/index.ts +37 -2
  262. package/src/driver/ios-color.ts +441 -0
  263. package/src/driver/ios-decoration.ts +332 -0
  264. package/src/driver/ios-layout.ts +63 -0
  265. package/src/driver/ios-playback.ts +51 -0
  266. package/src/driver/ios-shadow.ts +50 -0
  267. package/src/driver/ios.ts +194 -25
  268. package/src/engine.ts +329 -27
  269. package/src/haptics.ts +148 -0
  270. package/src/image-effect-native.ts +129 -0
  271. package/src/image-effect.ts +336 -0
  272. package/src/image-owner.ts +12 -0
  273. package/src/index.ts +61 -4
  274. package/src/interaction-surface.ts +41 -0
  275. package/src/ios-plugin.d.ts +81 -0
  276. package/src/items.ts +140 -0
  277. package/src/keyboard.ts +291 -0
  278. package/src/label.ts +76 -0
  279. package/src/layout.ts +408 -0
  280. package/src/liquid-grid.ts +28 -0
  281. package/src/liquid.ts +287 -0
  282. package/src/loader.ts +202 -0
  283. package/src/motion-scope.ts +279 -0
  284. package/src/motion-states.ts +215 -0
  285. package/src/particles.ts +323 -0
  286. package/src/path.ts +124 -0
  287. package/src/playback-group.ts +143 -0
  288. package/src/playback.ts +62 -0
  289. package/src/presets.ts +31 -7
  290. package/src/press.ts +32 -10
  291. package/src/properties.ts +32 -6
  292. package/src/reveal.ts +374 -0
  293. package/src/roll-diff.ts +57 -0
  294. package/src/roll.ts +515 -0
  295. package/src/scroll-linked.ts +73 -0
  296. package/src/sequence.ts +212 -19
  297. package/src/shimmer.ts +180 -0
  298. package/src/svg-path.ts +355 -0
  299. package/src/symbol-effect.ts +226 -0
  300. package/src/text-layout.ts +228 -0
  301. package/src/text.ts +279 -0
  302. package/src/track.ts +347 -12
  303. package/src/transition.ts +63 -6
  304. package/src/types.ts +51 -1
  305. package/src/vue/index.ts +231 -0
  306. package/src/zoom-native.ts +124 -0
  307. package/src/zoomable.ts +330 -0
@@ -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
+ }