@rafaelaugusto/jolt 0.1.0-beta.5 → 0.1.0-beta.7

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 (184) hide show
  1. package/README.md +122 -5
  2. package/dist/animate.d.ts +4 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +48 -11
  5. package/dist/animate.js.map +1 -1
  6. package/dist/config.d.ts +22 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +34 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/driver/android.d.ts.map +1 -1
  11. package/dist/driver/android.js +7 -0
  12. package/dist/driver/android.js.map +1 -1
  13. package/dist/driver/index.d.ts +2 -0
  14. package/dist/driver/index.d.ts.map +1 -1
  15. package/dist/driver/index.js.map +1 -1
  16. package/dist/driver/ios-color.js +1 -1
  17. package/dist/driver/ios-color.js.map +1 -1
  18. package/dist/driver/ios-decoration.js +1 -1
  19. package/dist/driver/ios-decoration.js.map +1 -1
  20. package/dist/driver/ios-playback.d.ts +1 -1
  21. package/dist/driver/ios-playback.d.ts.map +1 -1
  22. package/dist/driver/ios-playback.js +14 -0
  23. package/dist/driver/ios-playback.js.map +1 -1
  24. package/dist/driver/ios.d.ts.map +1 -1
  25. package/dist/driver/ios.js +1 -0
  26. package/dist/driver/ios.js.map +1 -1
  27. package/dist/engine.d.ts +16 -1
  28. package/dist/engine.d.ts.map +1 -1
  29. package/dist/engine.js +33 -1
  30. package/dist/engine.js.map +1 -1
  31. package/dist/image-effect-native.d.ts +26 -0
  32. package/dist/image-effect-native.d.ts.map +1 -0
  33. package/dist/image-effect-native.js +73 -0
  34. package/dist/image-effect-native.js.map +1 -0
  35. package/dist/image-effect.d.ts +50 -0
  36. package/dist/image-effect.d.ts.map +1 -0
  37. package/dist/image-effect.js +290 -0
  38. package/dist/image-effect.js.map +1 -0
  39. package/dist/image-owner.d.ts +3 -0
  40. package/dist/image-owner.d.ts.map +1 -0
  41. package/dist/image-owner.js +11 -0
  42. package/dist/image-owner.js.map +1 -0
  43. package/dist/index.d.ts +15 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +7 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/items.d.ts +20 -0
  48. package/dist/items.d.ts.map +1 -0
  49. package/dist/items.js +109 -0
  50. package/dist/items.js.map +1 -0
  51. package/dist/keyboard.d.ts +17 -0
  52. package/dist/keyboard.d.ts.map +1 -0
  53. package/dist/keyboard.js +116 -0
  54. package/dist/keyboard.js.map +1 -0
  55. package/dist/label.d.ts +7 -0
  56. package/dist/label.d.ts.map +1 -0
  57. package/dist/label.js +60 -0
  58. package/dist/label.js.map +1 -0
  59. package/dist/liquid.d.ts.map +1 -1
  60. package/dist/liquid.js +6 -1
  61. package/dist/liquid.js.map +1 -1
  62. package/dist/loader.d.ts.map +1 -1
  63. package/dist/loader.js +2 -1
  64. package/dist/loader.js.map +1 -1
  65. package/dist/particles.d.ts.map +1 -1
  66. package/dist/particles.js +4 -3
  67. package/dist/particles.js.map +1 -1
  68. package/dist/path.d.ts +12 -0
  69. package/dist/path.d.ts.map +1 -0
  70. package/dist/path.js +90 -0
  71. package/dist/path.js.map +1 -0
  72. package/dist/playback-group.d.ts.map +1 -1
  73. package/dist/playback-group.js +29 -1
  74. package/dist/playback-group.js.map +1 -1
  75. package/dist/press.d.ts.map +1 -1
  76. package/dist/press.js +3 -3
  77. package/dist/press.js.map +1 -1
  78. package/dist/reveal.d.ts.map +1 -1
  79. package/dist/reveal.js +3 -2
  80. package/dist/reveal.js.map +1 -1
  81. package/dist/roll-diff.d.ts +22 -0
  82. package/dist/roll-diff.d.ts.map +1 -0
  83. package/dist/roll-diff.js +36 -0
  84. package/dist/roll-diff.js.map +1 -0
  85. package/dist/roll.d.ts +29 -0
  86. package/dist/roll.d.ts.map +1 -0
  87. package/dist/roll.js +442 -0
  88. package/dist/roll.js.map +1 -0
  89. package/dist/scroll-linked.d.ts +10 -0
  90. package/dist/scroll-linked.d.ts.map +1 -0
  91. package/dist/scroll-linked.js +58 -0
  92. package/dist/scroll-linked.js.map +1 -0
  93. package/dist/sequence.d.ts +10 -5
  94. package/dist/sequence.d.ts.map +1 -1
  95. package/dist/sequence.js +202 -14
  96. package/dist/sequence.js.map +1 -1
  97. package/dist/shimmer.d.ts.map +1 -1
  98. package/dist/shimmer.js +2 -1
  99. package/dist/shimmer.js.map +1 -1
  100. package/dist/svg-path.d.ts +14 -0
  101. package/dist/svg-path.d.ts.map +1 -0
  102. package/dist/svg-path.js +281 -0
  103. package/dist/svg-path.js.map +1 -0
  104. package/dist/symbol-effect.d.ts.map +1 -1
  105. package/dist/symbol-effect.js +4 -3
  106. package/dist/symbol-effect.js.map +1 -1
  107. package/dist/text-layout.d.ts +8 -0
  108. package/dist/text-layout.d.ts.map +1 -1
  109. package/dist/text-layout.js +8 -2
  110. package/dist/text-layout.js.map +1 -1
  111. package/dist/text.d.ts +1 -1
  112. package/dist/text.d.ts.map +1 -1
  113. package/dist/text.js +60 -64
  114. package/dist/text.js.map +1 -1
  115. package/dist/track.d.ts +2 -0
  116. package/dist/track.d.ts.map +1 -1
  117. package/dist/track.js +62 -15
  118. package/dist/track.js.map +1 -1
  119. package/dist/types.d.ts +7 -1
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/vue/index.d.ts +89 -0
  122. package/dist/vue/index.d.ts.map +1 -0
  123. package/dist/vue/index.js +134 -0
  124. package/dist/vue/index.js.map +1 -0
  125. package/dist/zoom-native.d.ts +30 -0
  126. package/dist/zoom-native.d.ts.map +1 -0
  127. package/dist/zoom-native.js +56 -0
  128. package/dist/zoom-native.js.map +1 -0
  129. package/dist/zoomable.d.ts +48 -0
  130. package/dist/zoomable.d.ts.map +1 -0
  131. package/dist/zoomable.js +249 -0
  132. package/dist/zoomable.js.map +1 -0
  133. package/package.json +16 -3
  134. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  135. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  136. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  137. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  138. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  139. package/platforms/android/proguard-rules.pro +10 -0
  140. package/platforms/ios/build.xcconfig +1 -0
  141. package/platforms/ios/src/JoltImageEffect.h +25 -0
  142. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  143. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  144. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  145. package/platforms/ios/src/module.modulemap +5 -0
  146. package/src/android-plugin.d.ts +142 -0
  147. package/src/animate.ts +80 -30
  148. package/src/config.ts +46 -0
  149. package/src/driver/android.ts +14 -0
  150. package/src/driver/index.ts +2 -0
  151. package/src/driver/ios-color.ts +1 -1
  152. package/src/driver/ios-decoration.ts +1 -1
  153. package/src/driver/ios-playback.ts +15 -1
  154. package/src/driver/ios.ts +2 -0
  155. package/src/engine.ts +47 -2
  156. package/src/image-effect-native.ts +129 -0
  157. package/src/image-effect.ts +336 -0
  158. package/src/image-owner.ts +12 -0
  159. package/src/index.ts +20 -1
  160. package/src/ios-plugin.d.ts +59 -0
  161. package/src/items.ts +140 -0
  162. package/src/keyboard.ts +132 -0
  163. package/src/label.ts +76 -0
  164. package/src/liquid.ts +6 -1
  165. package/src/loader.ts +2 -1
  166. package/src/particles.ts +4 -4
  167. package/src/path.ts +124 -0
  168. package/src/playback-group.ts +34 -1
  169. package/src/press.ts +3 -5
  170. package/src/reveal.ts +3 -3
  171. package/src/roll-diff.ts +57 -0
  172. package/src/roll.ts +515 -0
  173. package/src/scroll-linked.ts +73 -0
  174. package/src/sequence.ts +199 -14
  175. package/src/shimmer.ts +2 -1
  176. package/src/svg-path.ts +355 -0
  177. package/src/symbol-effect.ts +4 -3
  178. package/src/text-layout.ts +14 -2
  179. package/src/text.ts +62 -81
  180. package/src/track.ts +89 -24
  181. package/src/types.ts +8 -1
  182. package/src/vue/index.ts +189 -0
  183. package/src/zoom-native.ts +124 -0
  184. 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
+ }
@@ -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 reducedMotion = UIAccessibilityIsReduceMotionEnabled()
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
- reducedMotion ||
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
- reducedMotion ||
169
+ reduced ||
169
170
  !native.image?.symbolImage ||
170
171
  !native.respondsToSelector('addSymbolEffect:options:animated:completion:')
171
172
  )
@@ -1,6 +1,12 @@
1
1
  /** TextKit lays out the complete string before any glyphs are separated. */
2
2
  type Insets = { top: number; left: number; bottom: number; right: number }
3
3
  type NativeLabel = UILabel & { padding?: Insets; borderThickness?: Insets }
4
+ /** The characters one piece draws, as UTF-16 offsets into the label text. */
5
+ export interface TextPiece {
6
+ text: string
7
+ location: number
8
+ length: number
9
+ }
4
10
  const MAX_PIECES = 256
5
11
  const MAX_PIXELS = 4_000_000
6
12
 
@@ -30,7 +36,7 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
30
36
  if (native.adjustsFontSizeToFitWidth) return undefined
31
37
  const original = native.attributedText
32
38
  const text = original?.string ?? native.text ?? ''
33
- if (!text.trim()) return { text, layer: undefined }
39
+ if (!text.trim()) return { text, layer: undefined, pieces: [] as TextPiece[] }
34
40
  const string = NSString.stringWithString(text)
35
41
  const full = { location: 0, length: string.length }
36
42
  const attributed = original
@@ -169,11 +175,17 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
169
175
  )
170
176
  if (attachments) return undefined
171
177
  const shadowColor = native.shadowColor
178
+ const drawn: TextPiece[] = []
172
179
  try {
173
180
  native.shadowColor = null
174
181
  for (const { range, rect } of pieces) {
175
182
  const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
176
183
  const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
184
+ drawn.push({
185
+ text: string.substringWithRange(characters),
186
+ location: characters.location,
187
+ length: characters.length,
188
+ })
177
189
  attributed.enumerateAttributesInRangeOptionsUsingBlock(
178
190
  characters,
179
191
  0 as NSAttributedStringEnumerationOptions,
@@ -212,5 +224,5 @@ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
212
224
  else native.text = text
213
225
  native.shadowColor = shadowColor
214
226
  }
215
- return { text, layer }
227
+ return { text, layer, pieces: drawn }
216
228
  }
package/src/text.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { stopIOSColorText } from './driver/ios-color.js'
2
- import { Application, Label, isIOS } from '@nativescript/core'
2
+ import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
3
3
  import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
4
4
  import { toView } from './animate.js'
5
+ import { reducedMotion, timeScale } from './config.js'
5
6
  import type { Animation } from './engine.js'
7
+ import { completion, forgetCompletion, watchLabel } from './label.js'
6
8
  import { textLayer } from './text-layout.js'
7
9
  import type { Target } from './types.js'
8
10
 
@@ -20,34 +22,8 @@ export interface TextOptions {
20
22
  }
21
23
  type Options = Required<TextOptions>
22
24
  const running = new WeakMap<Label, TextAnimation>()
23
- const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
24
- let Delegate: typeof NSObject | undefined
25
- const styleEvents = [
26
- 'fontInternalChange',
27
- 'colorChange',
28
- 'textAlignmentChange',
29
- 'textDecorationChange',
30
- 'textTransformChange',
31
- 'letterSpacingChange',
32
- 'lineHeightChange',
33
- 'textShadowChange',
34
- 'textStrokeChange',
35
- 'whiteSpaceChange',
36
- 'textOverflowChange',
37
- 'maxLinesChange',
38
- 'paddingLeftChange',
39
- 'paddingRightChange',
40
- 'paddingTopChange',
41
- 'paddingBottomChange',
42
- 'visibilityChange',
43
- 'directionChange',
44
- 'borderLeftWidthChange',
45
- 'borderRightWidthChange',
46
- 'borderTopWidthChange',
47
- 'borderBottomWidthChange',
48
- ]
49
25
 
50
- /** Reveals the text of an iOS Label without splitting its layout or accessibility element. */
26
+ /** Reveals the text of a Label without splitting its layout or accessibility element. */
51
27
  export function animateText(target: Target, options: TextOptions = {}): Animation {
52
28
  const config: Options = {
53
29
  by: options.by ?? 'letter',
@@ -69,41 +45,14 @@ export function animateText(target: Target, options: TextOptions = {}): Animatio
69
45
  return new TextAnimation(view, config)
70
46
  }
71
47
 
72
- function completion(
73
- callback: (finished: boolean) => void,
74
- ): NSObject & CAAnimationDelegate {
75
- if (!Delegate) {
76
- const base = NSObject as typeof NSObject & {
77
- extend(
78
- methods: object,
79
- options: { protocols: unknown[]; name: string },
80
- ): typeof NSObject
81
- }
82
- Delegate = base.extend(
83
- {
84
- animationDidStopFinished(
85
- this: NSObject,
86
- _animation: CAAnimation,
87
- finished: boolean,
88
- ) {
89
- callbacks.get(this)?.(finished)
90
- },
91
- },
92
- { protocols: [CAAnimationDelegate], name: 'JoltTextAnimationDelegate' },
93
- )
94
- }
95
- const delegate = Delegate.new()
96
- callbacks.set(delegate, callback)
97
- return delegate
98
- }
99
-
100
48
  class TextAnimation implements Animation {
101
49
  readonly finished: Promise<boolean>
102
50
  private resolve!: (finished: boolean) => void
103
- private readonly formatted: Label['formattedText']
104
51
  private settled = false
105
52
  private queued = false
106
- private native: UILabel | undefined
53
+ private native: UILabel | android.widget.TextView | undefined
54
+ private reveal: org.rafaelaugusto.jolt.TextReveal | undefined
55
+ private unwatch: (() => void) | undefined
107
56
  private bounds: CGRect | undefined
108
57
  private layer: CALayer | undefined
109
58
  private originalText: string | null = null
@@ -121,23 +70,19 @@ class TextAnimation implements Animation {
121
70
  this.resolve = resolve
122
71
  })
123
72
  if (isIOS) stopIOSColorText(view)
124
- this.formatted = view.formattedText
125
73
  running.set(view, this)
126
- if (!isIOS) {
74
+ if (!isIOS && !isAndroid) {
127
75
  this.end(false)
128
76
  return
129
77
  }
130
- if (options.duration === 0 || UIAccessibilityIsReduceMotionEnabled()) {
78
+ if (options.duration === 0 || reducedMotion()) {
131
79
  this.end(true)
132
80
  return
133
81
  }
134
82
  view.on('loaded', this.schedule)
135
83
  view.on('layoutChanged', this.layoutChanged)
136
84
  view.on('unloaded', this.stop)
137
- view.on('textChange', this.changed)
138
- view.on('formattedTextChange', this.changed)
139
- this.formatted?.on('propertyChange', this.changed)
140
- for (const event of styleEvents) view.style.on(event, this.changed)
85
+ this.unwatch = watchLabel(view, this.changed)
141
86
  this.schedule()
142
87
  }
143
88
 
@@ -151,9 +96,14 @@ class TextAnimation implements Animation {
151
96
  stop = () => this.end(false)
152
97
  private changed = () => this.end(false, true)
153
98
  private motionChanged = () => {
154
- if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
99
+ if (reducedMotion()) this.end(true)
155
100
  }
156
101
  private layoutChanged = () => {
102
+ if (isAndroid) {
103
+ if (!this.reveal) this.schedule()
104
+ else if (this.view.nativeViewProtected !== this.native) this.stop()
105
+ return
106
+ }
157
107
  const native = this.view.nativeViewProtected as UILabel | undefined
158
108
  const a = this.bounds
159
109
  const b = native?.bounds
@@ -176,7 +126,7 @@ class TextAnimation implements Animation {
176
126
  this.queued = true
177
127
  void Promise.resolve().then(() => {
178
128
  this.queued = false
179
- if (this.settled || this.layer || !this.view.isLoaded) return
129
+ if (this.settled || this.layer || this.reveal || !this.view.isLoaded) return
180
130
  try {
181
131
  this.start()
182
132
  } catch {
@@ -185,21 +135,21 @@ class TextAnimation implements Animation {
185
135
  })
186
136
  }
187
137
  private start() {
188
- if (this.options.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
189
- return this.end(true)
138
+ if (this.options.duration === 0 || reducedMotion()) return this.end(true)
190
139
  if (this.view.visibility !== 'visible') return this.end(false)
140
+ if (isAndroid) return this.startAndroid()
191
141
  const native = this.view.nativeViewProtected as UILabel | undefined
192
142
  if (!(native instanceof UILabel) || !native.window) return
193
143
  if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
194
144
  const prepared = textLayer(native, this.options.by)
195
145
  if (!prepared) return this.end(false)
196
146
  if (!prepared.layer?.sublayers?.count) return this.end(true)
147
+ const slowdown = 1 / timeScale()
148
+ const duration = this.options.duration * slowdown
149
+ const stagger = this.options.stagger * slowdown
150
+ const delay = this.options.delay * slowdown
197
151
  if (
198
- !Number.isFinite(
199
- this.options.delay +
200
- this.options.duration +
201
- (prepared.layer.sublayers.count - 1) * this.options.stagger,
202
- )
152
+ !Number.isFinite(delay + duration + (prepared.layer.sublayers.count - 1) * stagger)
203
153
  )
204
154
  return this.end(false)
205
155
  this.native = native
@@ -209,7 +159,7 @@ class TextAnimation implements Animation {
209
159
  this.originalAttributed = native.attributedText
210
160
  this.accessibility = native.accessibilityLabel
211
161
  this.temporaryAccessibility = this.accessibility ?? prepared.text
212
- const { duration, delay, stagger, y } = this.options
162
+ const { y } = this.options
213
163
  const start = CACurrentMediaTime()
214
164
  CATransaction.begin()
215
165
  CATransaction.setDisableActions(true)
@@ -247,24 +197,55 @@ class TextAnimation implements Animation {
247
197
  CATransaction.commit()
248
198
  }
249
199
  }
200
+ private startAndroid() {
201
+ const native = this.view.nativeViewProtected as android.widget.TextView | undefined
202
+ if (!(native instanceof android.widget.TextView)) return this.end(false)
203
+ const { by, duration, stagger, delay, y } = this.options
204
+ let reveal: org.rafaelaugusto.jolt.TextReveal
205
+ try {
206
+ reveal = new org.rafaelaugusto.jolt.TextReveal(
207
+ native,
208
+ by === 'word',
209
+ Math.round(duration),
210
+ Math.round(stagger),
211
+ Math.round(delay),
212
+ y * Utils.layout.getDisplayDensity(),
213
+ new org.rafaelaugusto.jolt.TextReveal.Callback({
214
+ onEnd: (finished: boolean) => {
215
+ if (this.reveal !== reveal) return
216
+ this.reveal = undefined
217
+ this.end(finished)
218
+ },
219
+ }),
220
+ )
221
+ } catch {
222
+ // A JS-only update cannot install the native plugin. Keep the label as it is.
223
+ return this.end(false)
224
+ }
225
+ if (!reveal.isSupported()) return this.end(false)
226
+ this.native = native
227
+ this.reveal = reveal
228
+ reveal.start()
229
+ }
250
230
  private end(finished: boolean, refresh = false) {
251
231
  if (this.settled) return
252
232
  this.settled = true
253
- const { view, native, layer } = this
233
+ const { view, layer } = this
234
+ const native = this.native as UILabel | undefined
235
+ const reveal = this.reveal
236
+ this.reveal = undefined
237
+ reveal?.cancel()
254
238
  view.off('loaded', this.schedule)
255
239
  view.off('layoutChanged', this.layoutChanged)
256
240
  view.off('unloaded', this.stop)
257
- view.off('textChange', this.changed)
258
- view.off('formattedTextChange', this.changed)
259
- this.formatted?.off('propertyChange', this.changed)
260
- for (const event of styleEvents) view.style.off(event, this.changed)
241
+ this.unwatch?.()
261
242
  if (running.get(view) === this) running.delete(view)
262
243
  if (this.observer)
263
244
  Application.ios.removeNotificationObserver(
264
245
  this.observer,
265
246
  UIAccessibilityReduceMotionStatusDidChangeNotification,
266
247
  )
267
- if (this.delegate) callbacks.delete(this.delegate)
248
+ if (this.delegate) forgetCompletion(this.delegate)
268
249
  if (native && layer) {
269
250
  CATransaction.begin()
270
251
  CATransaction.setDisableActions(true)