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

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 (132) hide show
  1. package/README.md +78 -13
  2. package/dist/animate.d.ts +3 -2
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +35 -5
  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/decoration.d.ts +18 -0
  11. package/dist/decoration.d.ts.map +1 -0
  12. package/dist/decoration.js +155 -0
  13. package/dist/decoration.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +20 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +136 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android-decoration.d.ts +22 -0
  19. package/dist/driver/android-decoration.d.ts.map +1 -0
  20. package/dist/driver/android-decoration.js +139 -0
  21. package/dist/driver/android-decoration.js.map +1 -0
  22. package/dist/driver/android-playback.d.ts +13 -0
  23. package/dist/driver/android-playback.d.ts.map +1 -0
  24. package/dist/driver/android-playback.js +141 -0
  25. package/dist/driver/android-playback.js.map +1 -0
  26. package/dist/driver/android.d.ts.map +1 -1
  27. package/dist/driver/android.js +28 -35
  28. package/dist/driver/android.js.map +1 -1
  29. package/dist/driver/index.d.ts +8 -1
  30. package/dist/driver/index.d.ts.map +1 -1
  31. package/dist/driver/index.js.map +1 -1
  32. package/dist/driver/ios-color.d.ts +16 -0
  33. package/dist/driver/ios-color.d.ts.map +1 -0
  34. package/dist/driver/ios-color.js +392 -0
  35. package/dist/driver/ios-color.js.map +1 -0
  36. package/dist/driver/ios-decoration.d.ts +17 -0
  37. package/dist/driver/ios-decoration.d.ts.map +1 -0
  38. package/dist/driver/ios-decoration.js +271 -0
  39. package/dist/driver/ios-decoration.js.map +1 -0
  40. package/dist/driver/ios-playback.d.ts +10 -0
  41. package/dist/driver/ios-playback.d.ts.map +1 -0
  42. package/dist/driver/ios-playback.js +33 -0
  43. package/dist/driver/ios-playback.js.map +1 -0
  44. package/dist/driver/ios-shadow.d.ts +6 -0
  45. package/dist/driver/ios-shadow.d.ts.map +1 -0
  46. package/dist/driver/ios-shadow.js +52 -0
  47. package/dist/driver/ios-shadow.js.map +1 -0
  48. package/dist/driver/ios.d.ts.map +1 -1
  49. package/dist/driver/ios.js +78 -35
  50. package/dist/driver/ios.js.map +1 -1
  51. package/dist/engine.d.ts +48 -2
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +217 -21
  54. package/dist/engine.js.map +1 -1
  55. package/dist/haptics.d.ts +4 -1
  56. package/dist/haptics.d.ts.map +1 -1
  57. package/dist/haptics.js +27 -3
  58. package/dist/haptics.js.map +1 -1
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/liquid-grid.d.ts +3 -0
  64. package/dist/liquid-grid.d.ts.map +1 -0
  65. package/dist/liquid-grid.js +24 -0
  66. package/dist/liquid-grid.js.map +1 -0
  67. package/dist/liquid.d.ts +18 -0
  68. package/dist/liquid.d.ts.map +1 -0
  69. package/dist/liquid.js +229 -0
  70. package/dist/liquid.js.map +1 -0
  71. package/dist/playback-group.d.ts +6 -0
  72. package/dist/playback-group.d.ts.map +1 -0
  73. package/dist/playback-group.js +113 -0
  74. package/dist/playback-group.js.map +1 -0
  75. package/dist/playback.d.ts +25 -0
  76. package/dist/playback.d.ts.map +1 -0
  77. package/dist/playback.js +35 -0
  78. package/dist/playback.js.map +1 -0
  79. package/dist/press.d.ts.map +1 -1
  80. package/dist/press.js +6 -1
  81. package/dist/press.js.map +1 -1
  82. package/dist/properties.d.ts +5 -2
  83. package/dist/properties.d.ts.map +1 -1
  84. package/dist/properties.js +25 -1
  85. package/dist/properties.js.map +1 -1
  86. package/dist/sequence.d.ts.map +1 -1
  87. package/dist/sequence.js +6 -0
  88. package/dist/sequence.js.map +1 -1
  89. package/dist/text.d.ts.map +1 -1
  90. package/dist/text.js +3 -0
  91. package/dist/text.js.map +1 -1
  92. package/dist/track.d.ts +1 -1
  93. package/dist/track.d.ts.map +1 -1
  94. package/dist/track.js +48 -3
  95. package/dist/track.js.map +1 -1
  96. package/dist/transition.d.ts.map +1 -1
  97. package/dist/transition.js +11 -2
  98. package/dist/transition.js.map +1 -1
  99. package/dist/types.d.ts +20 -0
  100. package/dist/types.d.ts.map +1 -1
  101. package/package.json +11 -2
  102. package/platforms/android/include.gradle +7 -0
  103. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  104. package/platforms/android/java/org/rafaelaugusto/jolt/DecorationTarget.java +154 -0
  105. package/platforms/android/proguard-rules.pro +7 -0
  106. package/src/animate.ts +48 -9
  107. package/src/color.ts +101 -0
  108. package/src/decoration.ts +180 -0
  109. package/src/driver/android-color.ts +170 -0
  110. package/src/driver/android-decoration.ts +182 -0
  111. package/src/driver/android-playback.ts +157 -0
  112. package/src/driver/android.ts +50 -46
  113. package/src/driver/index.ts +19 -1
  114. package/src/driver/ios-color.ts +441 -0
  115. package/src/driver/ios-decoration.ts +332 -0
  116. package/src/driver/ios-playback.ts +37 -0
  117. package/src/driver/ios-shadow.ts +50 -0
  118. package/src/driver/ios.ts +91 -42
  119. package/src/engine.ts +227 -23
  120. package/src/haptics.ts +25 -2
  121. package/src/index.ts +4 -0
  122. package/src/liquid-grid.ts +28 -0
  123. package/src/liquid.ts +282 -0
  124. package/src/playback-group.ts +110 -0
  125. package/src/playback.ts +62 -0
  126. package/src/press.ts +8 -2
  127. package/src/properties.ts +31 -5
  128. package/src/sequence.ts +5 -0
  129. package/src/text.ts +2 -0
  130. package/src/track.ts +72 -3
  131. package/src/transition.ts +13 -2
  132. package/src/types.ts +20 -0
@@ -0,0 +1,441 @@
1
+ import { Color, type View } from '@nativescript/core'
2
+ import { colorProperty } from '@nativescript/core/ui/styling/style-properties'
3
+ import { formattedTextProperty, textProperty } from '@nativescript/core/ui/text-base'
4
+ import { readColor, type ColorProperty } from '../color.js'
5
+ import { iosPlayback } from './ios-playback.js'
6
+ import type { Track } from '../track.js'
7
+
8
+ type TextView = View & {
9
+ text?: string
10
+ formattedText?: {
11
+ toString(): string
12
+ style: object
13
+ spans: { length: number; getItem(index: number): { text: string; style: object } }
14
+ }
15
+ }
16
+ type PaintLayer = {
17
+ layer: CALayer
18
+ path: 'backgroundColor' | 'borderColor' | 'fillColor'
19
+ }
20
+ type NativeView = UIView & { borderLayer?: CAShapeLayer }
21
+ interface ColorHandle {
22
+ elapsed(): number
23
+ control(elapsed: number, paused: boolean): void
24
+ stop(): void
25
+ presented(): number | undefined
26
+ }
27
+ const playing = new WeakMap<View, Map<string, ColorHandle>>()
28
+ const labelOf = (view: View) => {
29
+ const native = view.nativeViewProtected
30
+ return native instanceof UILabel
31
+ ? native
32
+ : native instanceof UIButton
33
+ ? native.titleLabel
34
+ : undefined
35
+ }
36
+
37
+ export function presentedIOSColor(
38
+ view: View,
39
+ property: ColorProperty,
40
+ ): number | undefined {
41
+ const active = playing.get(view)?.get(property)
42
+ if (active) return active.presented()
43
+ if (property === 'color' && !view.color) {
44
+ const label = labelOf(view)
45
+ if (label?.textColor) return Color.fromIosColor(label.textColor).argb >>> 0
46
+ }
47
+ return readColor(view, property)
48
+ }
49
+
50
+ /** Glyph entrances and color masks cannot own the same UILabel paint at once. */
51
+ export function stopIOSColorText(view: View) {
52
+ playing.get(view)?.get('color')?.stop()
53
+ }
54
+
55
+ /** Core Animation paints a solid layer through an exact UILabel alpha mask. */
56
+ export function playIOSColor(
57
+ view: View,
58
+ track: Track,
59
+ startedAt: number,
60
+ onEnd: (finished?: boolean) => void,
61
+ ): ColorHandle {
62
+ const timing = iosPlayback(track, startedAt)
63
+ const property = track.property as ColorProperty
64
+ let target: PaintLayer | undefined
65
+ let restore: (() => void) | undefined
66
+ let stopped = false
67
+ let queued = false
68
+ let attached = false
69
+ let generation = 0
70
+ let textGeometry: string | undefined
71
+ const native = view.nativeViewProtected as NativeView
72
+ const label = property === 'color' ? labelOf(view) : undefined
73
+ const key = `jolt:${property}`
74
+ const listeners: [
75
+ object & {
76
+ on(name: string, callback: () => void): void
77
+ off(name: string, callback: () => void): void
78
+ },
79
+ string,
80
+ () => void,
81
+ ][] = []
82
+ const listen = (
83
+ object: typeof view | typeof view.style,
84
+ name: string,
85
+ callback: () => void,
86
+ ) => {
87
+ object.on(name, callback)
88
+ listeners.push([object, name, callback])
89
+ }
90
+ const cleanup = () => {
91
+ if (stopped) return
92
+ stopped = true
93
+ generation++
94
+ for (const [object, name, callback] of listeners) object.off(name, callback)
95
+ target?.layer.removeAnimationForKey(key)
96
+ restore?.()
97
+ if (playing.get(view)?.get(property) === handle) playing.get(view)?.delete(property)
98
+ }
99
+ const cancel = () => {
100
+ if (stopped) return
101
+ cleanup()
102
+ onEnd(false)
103
+ }
104
+ const scheduleCancel = () => {
105
+ if (stopped || queued) return
106
+ queued = true
107
+ void Promise.resolve().then(() => {
108
+ queued = false
109
+ if (!stopped) cancel()
110
+ })
111
+ }
112
+ const handle: ColorHandle = {
113
+ stop: cancel,
114
+ elapsed: timing.elapsed,
115
+ control(time, paused) {
116
+ if (!stopped) {
117
+ timing.control(time, paused)
118
+ attach()
119
+ }
120
+ },
121
+ presented() {
122
+ // A new CA copy commits at the end of the turn. After controls, the engine
123
+ // clock is authoritative even before presentationLayer catches up.
124
+ if (timing.controlled) return undefined
125
+ if (!target) return track.values[0]
126
+ const layer = target.layer.presentationLayer()
127
+ // Before CA commits its first frame, the model already contains the target.
128
+ if (!layer) return track.valueAt(timing.elapsed())
129
+ const color = (layer as unknown as Record<string, interop.Pointer | null>)[
130
+ target.path
131
+ ]
132
+ return color
133
+ ? Color.fromIosColor(UIColor.colorWithCGColor(color)).argb >>> 0
134
+ : undefined
135
+ },
136
+ }
137
+ let entries = playing.get(view)
138
+ if (!entries) playing.set(view, (entries = new Map()))
139
+ entries.set(property, handle)
140
+ listen(view, 'unloaded', cancel)
141
+ for (const name of property === 'borderColor'
142
+ ? [
143
+ 'borderTopColorChange',
144
+ 'borderRightColorChange',
145
+ 'borderBottomColorChange',
146
+ 'borderLeftColorChange',
147
+ ]
148
+ : [`${property}Change`])
149
+ listen(view.style, name, scheduleCancel)
150
+ if (property === 'backgroundColor')
151
+ listen(view.style, 'backgroundImageChange', scheduleCancel)
152
+
153
+ const start = () => {
154
+ if (stopped || attached) return
155
+ if (property === 'color') {
156
+ if (
157
+ !label ||
158
+ !label.window ||
159
+ label.bounds.size.width <= 0 ||
160
+ label.bounds.size.height <= 0
161
+ )
162
+ return
163
+ let paint: ReturnType<typeof textPaint>
164
+ try {
165
+ paint = textPaint(view as TextView, label)
166
+ } catch {
167
+ return cancel()
168
+ }
169
+ if (!paint) return cancel()
170
+ target = { layer: paint.layer, path: 'backgroundColor' }
171
+ restore = paint.restore
172
+ } else {
173
+ // Core rasterizes an image together with its background into a pattern.
174
+ // Replacing that CGColor would erase the image.
175
+ if (
176
+ property === 'backgroundColor' &&
177
+ typeof view.backgroundImage === 'string' &&
178
+ view.backgroundImage
179
+ )
180
+ return cancel()
181
+ target =
182
+ property === 'borderColor' && native.borderLayer
183
+ ? { layer: native.borderLayer, path: 'fillColor' }
184
+ : { layer: native.layer, path: property }
185
+ }
186
+ attached = true
187
+ if (label) textGeometry = geometry(label)
188
+ attach()
189
+ }
190
+ const attach = () => {
191
+ if (!target || stopped) return
192
+ const instance = ++generation
193
+ const { layer, path } = target
194
+ CATransaction.begin()
195
+ CATransaction.setDisableActions(true)
196
+ ;(layer as unknown as Record<string, interop.Pointer>)[path] = new Color(
197
+ track.to,
198
+ ).ios.CGColor
199
+ CATransaction.commit()
200
+ const animation = CAKeyframeAnimation.animationWithKeyPath(path)
201
+ animation.values = NSArray.arrayWithArray(
202
+ track.values.map((color) => new Color(color).ios.CGColor),
203
+ )
204
+ animation.keyTimes = NSArray.arrayWithArray(track.times as number[])
205
+ animation.duration = Math.max(track.duration, 1) / 1000
206
+ timing.configure(animation, layer)
207
+ if (animation.respondsToSelector('setPreferredFrameRateRange:')) {
208
+ const maximum = Math.min(
209
+ 120,
210
+ native.window?.screen.maximumFramesPerSecond ??
211
+ UIScreen.mainScreen.maximumFramesPerSecond,
212
+ )
213
+ animation.preferredFrameRateRange = CAFrameRateRangeMake(
214
+ Math.min(80, maximum),
215
+ maximum,
216
+ maximum,
217
+ )
218
+ }
219
+ if (track.repeat > 0) {
220
+ animation.autoreverses = track.reverse
221
+ animation.repeatCount = track.reverse ? (track.repeat + 1) / 2 : track.repeat + 1
222
+ }
223
+ CATransaction.begin()
224
+ if (!timing.paused && track.repeat !== Infinity)
225
+ CATransaction.setCompletionBlock(() => {
226
+ if (!stopped && instance === generation) {
227
+ cleanup()
228
+ onEnd()
229
+ }
230
+ })
231
+ layer.addAnimationForKey(animation, key)
232
+ CATransaction.commit()
233
+ }
234
+
235
+ if (property === 'color') {
236
+ for (const name of ['textChange', 'formattedTextChange', 'isEnabledChange'])
237
+ listen(view, name, scheduleCancel)
238
+ for (const name of [
239
+ 'fontInternalChange',
240
+ 'textTransformChange',
241
+ 'whiteSpaceChange',
242
+ 'textOverflowChange',
243
+ 'maxLinesChange',
244
+ 'textStrokeChange',
245
+ 'directionChange',
246
+ 'visibilityChange',
247
+ 'paddingLeftChange',
248
+ 'paddingRightChange',
249
+ 'paddingTopChange',
250
+ 'paddingBottomChange',
251
+ 'textAlignmentChange',
252
+ 'letterSpacingChange',
253
+ 'textDecorationChange',
254
+ 'textShadowChange',
255
+ 'lineHeightChange',
256
+ ])
257
+ listen(view.style, name, scheduleCancel)
258
+ listen(view, 'layoutChanged', () => {
259
+ if (attached) {
260
+ // Core can relayout after rebuilding an identical attributed string.
261
+ // Only changed glyph geometry invalidates the captured mask.
262
+ if (label && geometry(label) !== textGeometry) scheduleCancel()
263
+ } else start()
264
+ })
265
+ const formatted = (view as TextView).formattedText
266
+ if (formatted) {
267
+ const object = formatted as unknown as typeof view
268
+ listen(object, 'propertyChange', scheduleCancel)
269
+ }
270
+ } else if (property === 'borderColor') {
271
+ // Core may rebuild its border layer after a background or geometry change.
272
+ // Reattach on the same native timeline, without cancelling sibling colors.
273
+ let pending = false
274
+ const followBorder = () => {
275
+ if (stopped || pending) return
276
+ pending = true
277
+ void Promise.resolve().then(() => {
278
+ pending = false
279
+ if (stopped || !attached || !target) return
280
+ const next: PaintLayer = native.borderLayer
281
+ ? { layer: native.borderLayer, path: 'fillColor' }
282
+ : { layer: native.layer, path: 'borderColor' }
283
+ if (next.layer === target.layer && next.path === target.path) return
284
+ const previous = target
285
+ target = next
286
+ generation++
287
+ previous.layer.removeAnimationForKey(key)
288
+ attach()
289
+ })
290
+ }
291
+ listen(view, 'layoutChanged', followBorder)
292
+ listen(view.style, 'backgroundInternalChange', followBorder)
293
+ }
294
+ start()
295
+ return handle
296
+ }
297
+
298
+ function textPaint(
299
+ view: TextView,
300
+ native: UILabel,
301
+ ): { layer: CALayer; restore(): void } | undefined {
302
+ const owner = view.nativeViewProtected
303
+ const original = native.attributedText
304
+ const text = original?.string ?? native.text ?? ''
305
+ const bounds = native.bounds
306
+ const scale = native.window?.screen.scale ?? UIScreen.mainScreen.scale
307
+ if (
308
+ !text ||
309
+ Array.from(text).some((char) => {
310
+ const cp = char.codePointAt(0)!
311
+ return (
312
+ (cp >= 0x1f000 && cp <= 0x1faff) ||
313
+ (cp >= 0x2600 && cp <= 0x27bf) ||
314
+ cp === 0x200d ||
315
+ cp === 0x20e3 ||
316
+ cp === 0xfe0f
317
+ )
318
+ }) ||
319
+ (native.shadowColor && CGColorGetAlpha(native.shadowColor.CGColor) > 0) ||
320
+ bounds.size.width * bounds.size.height * scale * scale > 4_000_000
321
+ )
322
+ return undefined
323
+ const source = original
324
+ ? NSMutableAttributedString.alloc().initWithAttributedString(original)
325
+ : NSMutableAttributedString.alloc().initWithString(text)
326
+ const full = { location: 0, length: source.length }
327
+ let unsupported = false
328
+ for (const attribute of [NSAttachmentAttributeName, NSShadowAttributeName])
329
+ source.enumerateAttributeInRangeOptionsUsingBlock(
330
+ attribute,
331
+ full,
332
+ 0 as NSAttributedStringEnumerationOptions,
333
+ (value) => {
334
+ if (attribute === NSShadowAttributeName) {
335
+ const color = (value as NSShadow | null)?.shadowColor as UIColor | null
336
+ if (color && CGColorGetAlpha(color.CGColor) > 0) unsupported = true
337
+ } else if (value != null) unsupported = true
338
+ },
339
+ )
340
+ if (unsupported) return undefined
341
+ const ranges: NSRange[] = []
342
+ const ownColor = (style: object) =>
343
+ ((style as Record<symbol, number>)[colorProperty.sourceKey] ?? 0) >= 2
344
+ const formatted = view.formattedText
345
+ if (formatted) {
346
+ let offset = 0
347
+ for (let i = 0; i < formatted.spans.length; i++) {
348
+ const span = formatted.spans.getItem(i)
349
+ if (!ownColor(formatted.style) && !ownColor(span.style))
350
+ ranges.push({ location: offset, length: span.text.length })
351
+ offset += span.text.length
352
+ }
353
+ } else ranges.push(full)
354
+ const maskText = NSMutableAttributedString.alloc().initWithAttributedString(source)
355
+ const hidden = NSMutableAttributedString.alloc().initWithAttributedString(source)
356
+ maskText.addAttributeValueRange(NSFontAttributeName, native.font, full)
357
+ // Keep per-span fonts, weights and paragraph metrics exactly as UILabel uses them.
358
+ if (original)
359
+ original.enumerateAttributeInRangeOptionsUsingBlock(
360
+ NSFontAttributeName,
361
+ full,
362
+ 0 as NSAttributedStringEnumerationOptions,
363
+ (font, range) => {
364
+ if (font) maskText.addAttributeValueRange(NSFontAttributeName, font, range)
365
+ },
366
+ )
367
+ for (const name of [
368
+ NSForegroundColorAttributeName,
369
+ NSBackgroundColorAttributeName,
370
+ NSUnderlineColorAttributeName,
371
+ NSStrikethroughColorAttributeName,
372
+ NSStrokeColorAttributeName,
373
+ ])
374
+ maskText.addAttributeValueRange(name, UIColor.clearColor, full)
375
+ for (const range of ranges)
376
+ for (const name of [
377
+ NSForegroundColorAttributeName,
378
+ NSUnderlineColorAttributeName,
379
+ NSStrikethroughColorAttributeName,
380
+ NSStrokeColorAttributeName,
381
+ ]) {
382
+ maskText.addAttributeValueRange(name, UIColor.whiteColor, range)
383
+ hidden.addAttributeValueRange(name, UIColor.clearColor, range)
384
+ }
385
+ let image: UIImage | null
386
+ try {
387
+ native.attributedText = maskText
388
+ UIGraphicsBeginImageContextWithOptions(bounds.size, false, scale)
389
+ try {
390
+ CGContextTranslateCTM(
391
+ UIGraphicsGetCurrentContext()!,
392
+ -bounds.origin.x,
393
+ -bounds.origin.y,
394
+ )
395
+ native.drawTextInRect(bounds)
396
+ image = UIGraphicsGetImageFromCurrentImageContext()
397
+ } finally {
398
+ UIGraphicsEndImageContext()
399
+ }
400
+ } finally {
401
+ if (original) native.attributedText = original
402
+ else native.text = text
403
+ }
404
+ if (!image?.CGImage) return undefined
405
+ const mask = CALayer.new()
406
+ mask.frame = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
407
+ mask.contents = image.CGImage
408
+ mask.contentsScale = scale
409
+ const layer = CALayer.new()
410
+ layer.frame = bounds
411
+ layer.name = 'jolt:color'
412
+ layer.mask = mask
413
+ native.attributedText = hidden
414
+ native.layer.addSublayer(layer)
415
+ return {
416
+ layer,
417
+ restore() {
418
+ layer.removeFromSuperlayer()
419
+ if (view.nativeViewProtected !== owner) return
420
+ const property = view.formattedText ? formattedTextProperty : textProperty
421
+ ;(view as unknown as Record<symbol, (value: unknown) => void>)[property.setNative]!(
422
+ view.formattedText ?? view.text,
423
+ )
424
+ },
425
+ }
426
+ }
427
+
428
+ function geometry(label: UILabel): string {
429
+ const bounds = label.bounds
430
+ const text = label.textRectForBoundsLimitedToNumberOfLines(bounds, label.numberOfLines)
431
+ return [
432
+ bounds.origin.x,
433
+ bounds.origin.y,
434
+ bounds.size.width,
435
+ bounds.size.height,
436
+ text.origin.x,
437
+ text.origin.y,
438
+ text.size.width,
439
+ text.size.height,
440
+ ].join(',')
441
+ }