@rafaelaugusto/jolt 0.1.0-beta.1 → 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 (302) hide show
  1. package/README.md +367 -18
  2. package/dist/animate.d.ts +19 -3
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +186 -14
  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 +163 -28
  75. package/dist/driver/ios.js.map +1 -1
  76. package/dist/engine.d.ts +75 -5
  77. package/dist/engine.d.ts.map +1 -1
  78. package/dist/engine.js +293 -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 +43 -2
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +20 -1
  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/press.d.ts.map +1 -1
  157. package/dist/press.js +23 -7
  158. package/dist/press.js.map +1 -1
  159. package/dist/properties.d.ts +6 -3
  160. package/dist/properties.d.ts.map +1 -1
  161. package/dist/properties.js +26 -2
  162. package/dist/properties.js.map +1 -1
  163. package/dist/reveal.d.ts +25 -0
  164. package/dist/reveal.d.ts.map +1 -0
  165. package/dist/reveal.js +294 -0
  166. package/dist/reveal.js.map +1 -0
  167. package/dist/roll-diff.d.ts +22 -0
  168. package/dist/roll-diff.d.ts.map +1 -0
  169. package/dist/roll-diff.js +36 -0
  170. package/dist/roll-diff.js.map +1 -0
  171. package/dist/roll.d.ts +29 -0
  172. package/dist/roll.d.ts.map +1 -0
  173. package/dist/roll.js +442 -0
  174. package/dist/roll.js.map +1 -0
  175. package/dist/scroll-linked.d.ts +10 -0
  176. package/dist/scroll-linked.d.ts.map +1 -0
  177. package/dist/scroll-linked.js +58 -0
  178. package/dist/scroll-linked.js.map +1 -0
  179. package/dist/sequence.d.ts +10 -5
  180. package/dist/sequence.d.ts.map +1 -1
  181. package/dist/sequence.js +208 -14
  182. package/dist/sequence.js.map +1 -1
  183. package/dist/shimmer.d.ts +12 -0
  184. package/dist/shimmer.d.ts.map +1 -0
  185. package/dist/shimmer.js +161 -0
  186. package/dist/shimmer.js.map +1 -0
  187. package/dist/svg-path.d.ts +14 -0
  188. package/dist/svg-path.d.ts.map +1 -0
  189. package/dist/svg-path.js +281 -0
  190. package/dist/svg-path.js.map +1 -0
  191. package/dist/symbol-effect.d.ts +23 -0
  192. package/dist/symbol-effect.d.ts.map +1 -0
  193. package/dist/symbol-effect.js +187 -0
  194. package/dist/symbol-effect.js.map +1 -0
  195. package/dist/text-layout.d.ts +30 -0
  196. package/dist/text-layout.d.ts.map +1 -0
  197. package/dist/text-layout.js +185 -0
  198. package/dist/text-layout.js.map +1 -0
  199. package/dist/text.d.ts +17 -0
  200. package/dist/text.d.ts.map +1 -0
  201. package/dist/text.js +263 -0
  202. package/dist/text.js.map +1 -0
  203. package/dist/track.d.ts +3 -1
  204. package/dist/track.d.ts.map +1 -1
  205. package/dist/track.js +266 -13
  206. package/dist/track.js.map +1 -1
  207. package/dist/transition.d.ts +17 -1
  208. package/dist/transition.d.ts.map +1 -1
  209. package/dist/transition.js +41 -6
  210. package/dist/transition.js.map +1 -1
  211. package/dist/types.d.ts +48 -1
  212. package/dist/types.d.ts.map +1 -1
  213. package/dist/vue/index.d.ts +95 -0
  214. package/dist/vue/index.d.ts.map +1 -0
  215. package/dist/vue/index.js +163 -0
  216. package/dist/vue/index.js.map +1 -0
  217. package/dist/zoom-native.d.ts +30 -0
  218. package/dist/zoom-native.d.ts.map +1 -0
  219. package/dist/zoom-native.js +56 -0
  220. package/dist/zoom-native.js.map +1 -0
  221. package/dist/zoomable.d.ts +48 -0
  222. package/dist/zoomable.d.ts.map +1 -0
  223. package/dist/zoomable.js +249 -0
  224. package/dist/zoomable.js.map +1 -0
  225. package/package.json +29 -5
  226. package/platforms/android/include.gradle +7 -0
  227. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  228. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  229. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  230. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardFollower.java +91 -0
  231. package/platforms/android/java/org/rafaelaugusto/jolt/KeyboardObserver.java +97 -0
  232. package/platforms/android/java/org/rafaelaugusto/jolt/NumberRoll.java +190 -0
  233. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  234. package/platforms/android/java/org/rafaelaugusto/jolt/TextReveal.java +180 -0
  235. package/platforms/android/proguard-rules.pro +19 -0
  236. package/platforms/ios/build.xcconfig +1 -0
  237. package/platforms/ios/src/JoltImageEffect.h +25 -0
  238. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  239. package/platforms/ios/src/JoltInteractiveAnimation.h +18 -0
  240. package/platforms/ios/src/JoltInteractiveAnimation.m +123 -0
  241. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  242. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  243. package/platforms/ios/src/module.modulemap +6 -0
  244. package/src/android-plugin.d.ts +155 -0
  245. package/src/animate.ts +249 -36
  246. package/src/color.ts +101 -0
  247. package/src/config.ts +84 -0
  248. package/src/decay.ts +45 -0
  249. package/src/decoration.ts +180 -0
  250. package/src/drag-input.ts +74 -0
  251. package/src/drag-physics.ts +110 -0
  252. package/src/drag.ts +395 -0
  253. package/src/driver/android-color.ts +170 -0
  254. package/src/driver/android-decoration.ts +182 -0
  255. package/src/driver/android-playback.ts +157 -0
  256. package/src/driver/android.ts +208 -20
  257. package/src/driver/index.ts +37 -2
  258. package/src/driver/ios-color.ts +441 -0
  259. package/src/driver/ios-decoration.ts +332 -0
  260. package/src/driver/ios-layout.ts +63 -0
  261. package/src/driver/ios-playback.ts +51 -0
  262. package/src/driver/ios-shadow.ts +50 -0
  263. package/src/driver/ios.ts +193 -33
  264. package/src/engine.ts +321 -27
  265. package/src/haptics.ts +148 -0
  266. package/src/image-effect-native.ts +129 -0
  267. package/src/image-effect.ts +336 -0
  268. package/src/image-owner.ts +12 -0
  269. package/src/index.ts +59 -2
  270. package/src/interaction-surface.ts +41 -0
  271. package/src/ios-plugin.d.ts +81 -0
  272. package/src/items.ts +140 -0
  273. package/src/keyboard.ts +291 -0
  274. package/src/label.ts +76 -0
  275. package/src/layout.ts +408 -0
  276. package/src/liquid-grid.ts +28 -0
  277. package/src/liquid.ts +287 -0
  278. package/src/loader.ts +202 -0
  279. package/src/motion-scope.ts +279 -0
  280. package/src/motion-states.ts +215 -0
  281. package/src/particles.ts +323 -0
  282. package/src/path.ts +124 -0
  283. package/src/playback-group.ts +143 -0
  284. package/src/playback.ts +62 -0
  285. package/src/press.ts +26 -10
  286. package/src/properties.ts +32 -6
  287. package/src/reveal.ts +374 -0
  288. package/src/roll-diff.ts +57 -0
  289. package/src/roll.ts +515 -0
  290. package/src/scroll-linked.ts +73 -0
  291. package/src/sequence.ts +204 -14
  292. package/src/shimmer.ts +180 -0
  293. package/src/svg-path.ts +355 -0
  294. package/src/symbol-effect.ts +226 -0
  295. package/src/text-layout.ts +228 -0
  296. package/src/text.ts +279 -0
  297. package/src/track.ts +347 -12
  298. package/src/transition.ts +63 -6
  299. package/src/types.ts +49 -1
  300. package/src/vue/index.ts +231 -0
  301. package/src/zoom-native.ts +124 -0
  302. package/src/zoomable.ts +330 -0
@@ -0,0 +1,228 @@
1
+ /** TextKit lays out the complete string before any glyphs are separated. */
2
+ type Insets = { top: number; left: number; bottom: number; right: number }
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
+ }
10
+ const MAX_PIECES = 256
11
+ const MAX_PIXELS = 4_000_000
12
+
13
+ /** Ligatures and composed characters may share glyphs. Never draw a shared glyph twice. */
14
+ export function mergeGlyphRanges(ranges: readonly NSRange[]): NSRange[] {
15
+ const merged: NSRange[] = []
16
+ for (const range of ranges) {
17
+ if (!range.length) continue
18
+ const previous = merged.at(-1)
19
+ if (
20
+ previous &&
21
+ range.location < previous.location + previous.length &&
22
+ range.location + range.length > previous.location
23
+ ) {
24
+ const end = Math.max(
25
+ previous.location + previous.length,
26
+ range.location + range.length,
27
+ )
28
+ previous.location = Math.min(previous.location, range.location)
29
+ previous.length = end - previous.location
30
+ } else merged.push({ location: range.location, length: range.length })
31
+ }
32
+ return merged
33
+ }
34
+
35
+ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
36
+ if (native.adjustsFontSizeToFitWidth) return undefined
37
+ const original = native.attributedText
38
+ const text = original?.string ?? native.text ?? ''
39
+ if (!text.trim()) return { text, layer: undefined, pieces: [] as TextPiece[] }
40
+ const string = NSString.stringWithString(text)
41
+ const full = { location: 0, length: string.length }
42
+ const attributed = original
43
+ ? NSMutableAttributedString.alloc().initWithAttributedString(original)
44
+ : NSMutableAttributedString.alloc().initWithString(text)
45
+ const paragraph = NSMutableParagraphStyle.new()
46
+ paragraph.alignment = native.textAlignment
47
+ paragraph.lineBreakMode = native.lineBreakMode
48
+ const defaults: [string, unknown][] = [
49
+ [NSFontAttributeName, native.font],
50
+ [NSForegroundColorAttributeName, native.textColor],
51
+ [NSParagraphStyleAttributeName, paragraph],
52
+ ]
53
+ if (native.shadowColor) {
54
+ const shadow = NSShadow.new()
55
+ shadow.shadowColor = native.shadowColor
56
+ shadow.shadowOffset = native.shadowOffset
57
+ defaults.push([NSShadowAttributeName, shadow])
58
+ }
59
+ for (const [key, value] of defaults) {
60
+ attributed.enumerateAttributeInRangeOptionsUsingBlock(
61
+ key,
62
+ full,
63
+ 0 as NSAttributedStringEnumerationOptions,
64
+ (attribute, range) => {
65
+ if (attribute == null && value != null)
66
+ attributed.addAttributeValueRange(key, value, range)
67
+ },
68
+ )
69
+ }
70
+ const inset = (side: keyof Insets) =>
71
+ (native.padding?.[side] ?? 0) + (native.borderThickness?.[side] ?? 0)
72
+ const bounds = native.bounds
73
+ const width = bounds.size.width - inset('left') - inset('right')
74
+ const height = bounds.size.height - inset('top') - inset('bottom')
75
+ if (width <= 0 || height <= 0) return undefined
76
+ const storage = NSTextStorage.alloc().initWithAttributedString(attributed)
77
+ const manager = NSLayoutManager.new()
78
+ manager.usesFontLeading = false
79
+ const container = NSTextContainer.alloc().initWithSize(CGSizeMake(width, height))
80
+ container.lineFragmentPadding = 0
81
+ container.maximumNumberOfLines = native.numberOfLines
82
+ container.lineBreakMode = native.lineBreakMode
83
+ manager.addTextContainer(container)
84
+ storage.addLayoutManager(manager)
85
+ manager.ensureLayoutForTextContainer(container)
86
+ const visible = manager.glyphRangeForTextContainer(container)
87
+ if (visible.length !== manager.numberOfGlyphs) return undefined
88
+ // A truncation token is not an independently addressable character. Keep the real label.
89
+ for (let glyph = 0; glyph < manager.numberOfGlyphs;) {
90
+ if (manager.truncatedGlyphRangeInLineFragmentForGlyphAtIndex(glyph).length)
91
+ return undefined
92
+ const range = new interop.Reference<NSRange>()
93
+ manager.lineFragmentRectForGlyphAtIndexEffectiveRange(glyph, range)
94
+ const next = range.value.location + range.value.length
95
+ if (next <= glyph) return undefined
96
+ glyph = next
97
+ }
98
+ const characters: NSRange[] = []
99
+ string.enumerateSubstringsInRangeOptionsUsingBlock(
100
+ full,
101
+ by === 'letter' ? 2 : 3,
102
+ (substring, range) => {
103
+ if (substring?.trim())
104
+ characters.push({ location: range.location, length: range.length })
105
+ },
106
+ )
107
+ if (by === 'word') {
108
+ // Attach spaces and punctuation to the surrounding word, preserving every visible mark.
109
+ characters.forEach((range, index) => {
110
+ const start = index === 0 ? 0 : range.location
111
+ const end = characters[index + 1]?.location ?? full.length
112
+ range.location = start
113
+ range.length = end - start
114
+ })
115
+ if (!characters.length) characters.push(full)
116
+ }
117
+ const ranges = mergeGlyphRanges(
118
+ characters.map((range) =>
119
+ manager.glyphRangeForCharacterRangeActualCharacterRange(range, null),
120
+ ),
121
+ )
122
+ if (ranges.length > MAX_PIECES) return undefined
123
+ const used = manager.usedRectForTextContainer(container)
124
+ const offsetX = bounds.origin.x + inset('left')
125
+ const offsetY =
126
+ bounds.origin.y + inset('top') + (height - used.size.height) / 2 - used.origin.y
127
+ const scale = native.window?.screen.scale ?? UIScreen.mainScreen.scale
128
+ const padding = Math.max(4, native.font.pointSize * 0.1)
129
+ const pieces = ranges.map((range) => {
130
+ const glyph = manager.boundingRectForGlyphRangeInTextContainer(range, container)
131
+ return {
132
+ range,
133
+ rect: CGRectIntegral(
134
+ CGRectMake(
135
+ glyph.origin.x + offsetX - padding,
136
+ glyph.origin.y + offsetY - padding,
137
+ glyph.size.width + padding * 2,
138
+ glyph.size.height + padding * 2,
139
+ ),
140
+ ),
141
+ }
142
+ })
143
+ if (
144
+ pieces.reduce(
145
+ (pixels, { rect }) => pixels + rect.size.width * rect.size.height * scale * scale,
146
+ 0,
147
+ ) > MAX_PIXELS
148
+ )
149
+ return undefined
150
+ const layer = CALayer.new()
151
+ layer.name = 'jolt:text'
152
+ layer.frame = bounds
153
+ const transparent = UIColor.clearColor
154
+ const noShadow = NSShadow.new()
155
+ noShadow.shadowColor = transparent
156
+ const hidden = NSMutableAttributedString.alloc().initWithAttributedString(attributed)
157
+ for (const key of [
158
+ NSForegroundColorAttributeName,
159
+ NSBackgroundColorAttributeName,
160
+ NSUnderlineColorAttributeName,
161
+ NSStrikethroughColorAttributeName,
162
+ NSStrokeColorAttributeName,
163
+ ]) {
164
+ hidden.addAttributeValueRange(key, transparent, full)
165
+ }
166
+ hidden.addAttributeValueRange(NSShadowAttributeName, noShadow, full)
167
+ let attachments = false
168
+ attributed.enumerateAttributeInRangeOptionsUsingBlock(
169
+ NSAttachmentAttributeName,
170
+ full,
171
+ 0 as NSAttributedStringEnumerationOptions,
172
+ (value) => {
173
+ if (value != null) attachments = true
174
+ },
175
+ )
176
+ if (attachments) return undefined
177
+ const shadowColor = native.shadowColor
178
+ const drawn: TextPiece[] = []
179
+ try {
180
+ native.shadowColor = null
181
+ for (const { range, rect } of pieces) {
182
+ const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
183
+ const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
184
+ drawn.push({
185
+ text: string.substringWithRange(characters),
186
+ location: characters.location,
187
+ length: characters.length,
188
+ })
189
+ attributed.enumerateAttributesInRangeOptionsUsingBlock(
190
+ characters,
191
+ 0 as NSAttributedStringEnumerationOptions,
192
+ (attributes, part) => fragment.setAttributesRange(attributes, part),
193
+ )
194
+ native.attributedText = fragment
195
+ // UILabel draws its own glyphs, preserving its baseline rounding and fallback-font metrics.
196
+ // Other glyphs keep their shaping/advances but have transparent paint.
197
+ UIGraphicsBeginImageContextWithOptions(rect.size, false, scale)
198
+ let image: UIImage | null
199
+ try {
200
+ CGContextTranslateCTM(
201
+ UIGraphicsGetCurrentContext()!,
202
+ -rect.origin.x,
203
+ -rect.origin.y,
204
+ )
205
+ native.drawTextInRect(bounds)
206
+ image = UIGraphicsGetImageFromCurrentImageContext()
207
+ } finally {
208
+ UIGraphicsEndImageContext()
209
+ }
210
+ if (!image?.CGImage) return undefined
211
+ const piece = CALayer.new()
212
+ piece.frame = CGRectMake(
213
+ rect.origin.x - bounds.origin.x,
214
+ rect.origin.y - bounds.origin.y,
215
+ rect.size.width,
216
+ rect.size.height,
217
+ )
218
+ piece.contents = image.CGImage
219
+ piece.contentsScale = scale
220
+ layer.addSublayer(piece)
221
+ }
222
+ } finally {
223
+ if (original) native.attributedText = original
224
+ else native.text = text
225
+ native.shadowColor = shadowColor
226
+ }
227
+ return { text, layer, pieces: drawn }
228
+ }
package/src/text.ts ADDED
@@ -0,0 +1,279 @@
1
+ import { stopIOSColorText } from './driver/ios-color.js'
2
+ import { Application, Label, Utils, isAndroid, isIOS } from '@nativescript/core'
3
+ import { textProperty, formattedTextProperty } from '@nativescript/core/ui/text-base'
4
+ import { toView } from './animate.js'
5
+ import { reducedMotion, timeScale } from './config.js'
6
+ import type { Animation } from './engine.js'
7
+ import { completion, forgetCompletion, watchLabel } from './label.js'
8
+ import { textLayer } from './text-layout.js'
9
+ import type { Target } from './types.js'
10
+
11
+ export interface TextOptions {
12
+ /** Composed letters (including emoji), or words. Defaults to 'letter'. */
13
+ by?: 'letter' | 'word'
14
+ /** Milliseconds per piece. Defaults to 400. */
15
+ duration?: number
16
+ /** Milliseconds between pieces. Defaults to 35. */
17
+ stagger?: number
18
+ /** Milliseconds before the first piece. Defaults to zero. */
19
+ delay?: number
20
+ /** Initial vertical offset in DIPs. Defaults to 12. */
21
+ y?: number
22
+ }
23
+ type Options = Required<TextOptions>
24
+ const running = new WeakMap<Label, TextAnimation>()
25
+
26
+ /** Reveals the text of a Label without splitting its layout or accessibility element. */
27
+ export function animateText(target: Target, options: TextOptions = {}): Animation {
28
+ const config: Options = {
29
+ by: options.by ?? 'letter',
30
+ duration: options.duration ?? 400,
31
+ stagger: options.stagger ?? 35,
32
+ delay: options.delay ?? 0,
33
+ y: options.y ?? 12,
34
+ }
35
+ if (config.by !== 'letter' && config.by !== 'word')
36
+ throw new TypeError('Text grouping must be letter or word.')
37
+ for (const key of ['duration', 'stagger', 'delay'] as const) {
38
+ if (!Number.isFinite(config[key]) || config[key] < 0)
39
+ throw new RangeError(`Text ${key} must be a nonnegative finite number.`)
40
+ }
41
+ if (!Number.isFinite(config.y)) throw new RangeError('Text y must be finite.')
42
+ const view = toView(target)
43
+ if (!(view instanceof Label)) throw new TypeError('Expected a NativeScript Label.')
44
+ running.get(view)?.stop()
45
+ return new TextAnimation(view, config)
46
+ }
47
+
48
+ class TextAnimation implements Animation {
49
+ readonly finished: Promise<boolean>
50
+ private resolve!: (finished: boolean) => void
51
+ private settled = false
52
+ private queued = false
53
+ private native: UILabel | android.widget.TextView | undefined
54
+ private reveal: org.rafaelaugusto.jolt.TextReveal | undefined
55
+ private unwatch: (() => void) | undefined
56
+ private bounds: CGRect | undefined
57
+ private layer: CALayer | undefined
58
+ private originalText: string | null = null
59
+ private originalAttributed: NSAttributedString | null = null
60
+ private accessibility: string | null = null
61
+ private temporaryAccessibility: string | undefined
62
+ private delegate: NSObject | undefined
63
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
64
+
65
+ constructor(
66
+ private readonly view: Label,
67
+ private readonly options: Options,
68
+ ) {
69
+ this.finished = new Promise((resolve) => {
70
+ this.resolve = resolve
71
+ })
72
+ if (isIOS) stopIOSColorText(view)
73
+ running.set(view, this)
74
+ if (!isIOS && !isAndroid) {
75
+ this.end(false)
76
+ return
77
+ }
78
+ if (options.duration === 0 || reducedMotion()) {
79
+ this.end(true)
80
+ return
81
+ }
82
+ view.on('loaded', this.schedule)
83
+ view.on('layoutChanged', this.layoutChanged)
84
+ view.on('unloaded', this.stop)
85
+ this.unwatch = watchLabel(view, this.changed)
86
+ this.schedule()
87
+ }
88
+
89
+ then<A = boolean, B = never>(
90
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
91
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
92
+ ): Promise<A | B> {
93
+ return this.finished.then(onfulfilled, onrejected)
94
+ }
95
+ /** Cancels the entrance and restores the full text. */
96
+ stop = () => this.end(false)
97
+ private changed = () => this.end(false, true)
98
+ private motionChanged = () => {
99
+ if (reducedMotion()) this.end(true)
100
+ }
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
+ }
107
+ const native = this.view.nativeViewProtected as UILabel | undefined
108
+ const a = this.bounds
109
+ const b = native?.bounds
110
+ if (
111
+ this.layer &&
112
+ (native !== this.native ||
113
+ !native?.window ||
114
+ !b ||
115
+ !a ||
116
+ a.origin.x !== b.origin.x ||
117
+ a.origin.y !== b.origin.y ||
118
+ a.size.width !== b.size.width ||
119
+ a.size.height !== b.size.height)
120
+ )
121
+ return this.stop()
122
+ this.schedule()
123
+ }
124
+ private schedule = () => {
125
+ if (this.settled || this.queued) return
126
+ this.queued = true
127
+ void Promise.resolve().then(() => {
128
+ this.queued = false
129
+ if (this.settled || this.layer || this.reveal || !this.view.isLoaded) return
130
+ try {
131
+ this.start()
132
+ } catch {
133
+ this.end(false)
134
+ }
135
+ })
136
+ }
137
+ private start() {
138
+ if (this.options.duration === 0 || reducedMotion()) return this.end(true)
139
+ if (this.view.visibility !== 'visible') return this.end(false)
140
+ if (isAndroid) return this.startAndroid()
141
+ const native = this.view.nativeViewProtected as UILabel | undefined
142
+ if (!(native instanceof UILabel) || !native.window) return
143
+ if (native.bounds.size.width <= 0 || native.bounds.size.height <= 0) return
144
+ const prepared = textLayer(native, this.options.by)
145
+ if (!prepared) return this.end(false)
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
151
+ if (
152
+ !Number.isFinite(delay + duration + (prepared.layer.sublayers.count - 1) * stagger)
153
+ )
154
+ return this.end(false)
155
+ this.native = native
156
+ this.bounds = native.bounds
157
+ this.layer = prepared.layer
158
+ this.originalText = native.text
159
+ this.originalAttributed = native.attributedText
160
+ this.accessibility = native.accessibilityLabel
161
+ this.temporaryAccessibility = this.accessibility ?? prepared.text
162
+ const { y } = this.options
163
+ const start = CACurrentMediaTime()
164
+ CATransaction.begin()
165
+ CATransaction.setDisableActions(true)
166
+ try {
167
+ native.text = ''
168
+ native.accessibilityLabel = this.temporaryAccessibility
169
+ native.layer.addSublayer(this.layer)
170
+ const pieces = this.layer.sublayers!
171
+ for (let i = 0; i < pieces.count; i++) {
172
+ const piece = pieces.objectAtIndex(i)
173
+ const fade = CABasicAnimation.animationWithKeyPath('opacity')
174
+ fade.fromValue = 0
175
+ fade.toValue = 1
176
+ const move = CABasicAnimation.animationWithKeyPath('transform.translation.y')
177
+ move.fromValue = y
178
+ move.toValue = 0
179
+ fade.duration = move.duration = duration / 1000
180
+ const group = CAAnimationGroup.new()
181
+ group.animations = NSArray.arrayWithArray([fade, move])
182
+ group.duration = duration / 1000
183
+ group.beginTime = start + (delay + i * stagger) / 1000
184
+ group.fillMode = kCAFillModeBackwards
185
+ group.timingFunction = CAMediaTimingFunction.functionWithName(
186
+ kCAMediaTimingFunctionEaseOut,
187
+ )
188
+ if (i === pieces.count - 1)
189
+ this.delegate = group.delegate = completion((finished) => this.end(finished))
190
+ piece.addAnimationForKey(group, 'jolt:text')
191
+ }
192
+ this.observer = Application.ios.addNotificationObserver(
193
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
194
+ this.motionChanged,
195
+ )
196
+ } finally {
197
+ CATransaction.commit()
198
+ }
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
+ }
230
+ private end(finished: boolean, refresh = false) {
231
+ if (this.settled) return
232
+ this.settled = true
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()
238
+ view.off('loaded', this.schedule)
239
+ view.off('layoutChanged', this.layoutChanged)
240
+ view.off('unloaded', this.stop)
241
+ this.unwatch?.()
242
+ if (running.get(view) === this) running.delete(view)
243
+ if (this.observer)
244
+ Application.ios.removeNotificationObserver(
245
+ this.observer,
246
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
247
+ )
248
+ if (this.delegate) forgetCompletion(this.delegate)
249
+ if (native && layer) {
250
+ CATransaction.begin()
251
+ CATransaction.setDisableActions(true)
252
+ try {
253
+ // Restore only the native text we suppressed; app changes win.
254
+ if (!native.text && !native.attributedText?.length) {
255
+ if (refresh && view.nativeViewProtected === native) {
256
+ const property = view.formattedText ? formattedTextProperty : textProperty
257
+ ;(view as unknown as Record<symbol, (value: unknown) => void>)[
258
+ property.setNative
259
+ ]!(view.formattedText ?? view.text)
260
+ } else if (this.originalAttributed)
261
+ native.attributedText = this.originalAttributed
262
+ else native.text = this.originalText
263
+ }
264
+ if (native.accessibilityLabel === this.temporaryAccessibility)
265
+ native.accessibilityLabel = this.accessibility
266
+ layer.removeFromSuperlayer()
267
+ const pieces = layer.sublayers
268
+ if (pieces)
269
+ for (let i = 0; i < pieces.count; i++)
270
+ pieces.objectAtIndex(i).removeAnimationForKey('jolt:text')
271
+ } finally {
272
+ CATransaction.commit()
273
+ }
274
+ }
275
+ this.native = this.layer = this.delegate = this.observer = undefined
276
+ this.originalAttributed = null
277
+ this.resolve(finished)
278
+ }
279
+ }