@rafaelaugusto/jolt 0.1.0-beta.2 → 0.1.0-beta.4

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 (105) hide show
  1. package/README.md +112 -16
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +89 -3
  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/decay.d.ts +11 -0
  11. package/dist/decay.d.ts.map +1 -0
  12. package/dist/decay.js +22 -0
  13. package/dist/decay.js.map +1 -0
  14. package/dist/driver/android-color.d.ts +17 -0
  15. package/dist/driver/android-color.d.ts.map +1 -0
  16. package/dist/driver/android-color.js +134 -0
  17. package/dist/driver/android-color.js.map +1 -0
  18. package/dist/driver/android.d.ts +1 -1
  19. package/dist/driver/android.d.ts.map +1 -1
  20. package/dist/driver/android.js +101 -5
  21. package/dist/driver/android.js.map +1 -1
  22. package/dist/driver/index.d.ts +4 -2
  23. package/dist/driver/index.d.ts.map +1 -1
  24. package/dist/driver/index.js.map +1 -1
  25. package/dist/driver/ios-color.d.ts +14 -0
  26. package/dist/driver/ios-color.d.ts.map +1 -0
  27. package/dist/driver/ios-color.js +381 -0
  28. package/dist/driver/ios-color.js.map +1 -0
  29. package/dist/driver/ios.d.ts.map +1 -1
  30. package/dist/driver/ios.js +9 -1
  31. package/dist/driver/ios.js.map +1 -1
  32. package/dist/engine.d.ts +1 -1
  33. package/dist/engine.d.ts.map +1 -1
  34. package/dist/engine.js +15 -8
  35. package/dist/engine.js.map +1 -1
  36. package/dist/index.d.ts +10 -1
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/liquid-grid.d.ts +3 -0
  41. package/dist/liquid-grid.d.ts.map +1 -0
  42. package/dist/liquid-grid.js +24 -0
  43. package/dist/liquid-grid.js.map +1 -0
  44. package/dist/liquid.d.ts +18 -0
  45. package/dist/liquid.d.ts.map +1 -0
  46. package/dist/liquid.js +229 -0
  47. package/dist/liquid.js.map +1 -0
  48. package/dist/loader.d.ts +19 -0
  49. package/dist/loader.d.ts.map +1 -0
  50. package/dist/loader.js +172 -0
  51. package/dist/loader.js.map +1 -0
  52. package/dist/press.d.ts.map +1 -1
  53. package/dist/press.js +6 -1
  54. package/dist/press.js.map +1 -1
  55. package/dist/properties.d.ts +5 -3
  56. package/dist/properties.d.ts.map +1 -1
  57. package/dist/properties.js +13 -2
  58. package/dist/properties.js.map +1 -1
  59. package/dist/reveal.d.ts +25 -0
  60. package/dist/reveal.d.ts.map +1 -0
  61. package/dist/reveal.js +293 -0
  62. package/dist/reveal.js.map +1 -0
  63. package/dist/text-layout.d.ts +22 -0
  64. package/dist/text-layout.d.ts.map +1 -0
  65. package/dist/text-layout.js +179 -0
  66. package/dist/text-layout.js.map +1 -0
  67. package/dist/text.d.ts +17 -0
  68. package/dist/text.d.ts.map +1 -0
  69. package/dist/text.js +267 -0
  70. package/dist/text.js.map +1 -0
  71. package/dist/track.d.ts +1 -1
  72. package/dist/track.d.ts.map +1 -1
  73. package/dist/track.js +63 -5
  74. package/dist/track.js.map +1 -1
  75. package/dist/transition.d.ts +5 -0
  76. package/dist/transition.d.ts.map +1 -1
  77. package/dist/transition.js +8 -2
  78. package/dist/transition.js.map +1 -1
  79. package/dist/types.d.ts +19 -0
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +11 -2
  82. package/platforms/android/include.gradle +7 -0
  83. package/platforms/android/java/org/rafaelaugusto/jolt/ColorTarget.java +102 -0
  84. package/platforms/android/proguard-rules.pro +6 -0
  85. package/src/animate.ts +105 -5
  86. package/src/color.ts +101 -0
  87. package/src/decay.ts +45 -0
  88. package/src/driver/android-color.ts +168 -0
  89. package/src/driver/android.ts +120 -10
  90. package/src/driver/index.ts +9 -2
  91. package/src/driver/ios-color.ts +432 -0
  92. package/src/driver/ios.ts +13 -3
  93. package/src/engine.ts +17 -9
  94. package/src/index.ts +10 -1
  95. package/src/liquid-grid.ts +28 -0
  96. package/src/liquid.ts +282 -0
  97. package/src/loader.ts +201 -0
  98. package/src/press.ts +8 -2
  99. package/src/properties.ts +16 -6
  100. package/src/reveal.ts +374 -0
  101. package/src/text-layout.ts +216 -0
  102. package/src/text.ts +298 -0
  103. package/src/track.ts +105 -5
  104. package/src/transition.ts +16 -2
  105. package/src/types.ts +18 -0
package/src/properties.ts CHANGED
@@ -1,7 +1,9 @@
1
- import type { View } from '@nativescript/core'
1
+ import { Color, type View } from '@nativescript/core'
2
+ import { isColorProperty, readColor, type ColorProperty } from './color.js'
2
3
 
3
- export type Property =
4
+ export type NumericProperty =
4
5
  'opacity' | 'x' | 'y' | 'scaleX' | 'scaleY' | 'rotate' | 'rotateX' | 'rotateY'
6
+ export type Property = NumericProperty | ColorProperty
5
7
 
6
8
  const viewKeys = {
7
9
  opacity: 'opacity',
@@ -12,9 +14,9 @@ const viewKeys = {
12
14
  rotate: 'rotate',
13
15
  rotateX: 'rotateX',
14
16
  rotateY: 'rotateY',
15
- } as const satisfies Record<Property, keyof View>
17
+ } as const satisfies Record<NumericProperty, keyof View>
16
18
 
17
- const defaults: Record<Property, number> = {
19
+ const defaults: Record<NumericProperty, number> = {
18
20
  opacity: 1,
19
21
  x: 0,
20
22
  y: 0,
@@ -25,8 +27,11 @@ const defaults: Record<Property, number> = {
25
27
  rotateY: 0,
26
28
  }
27
29
 
28
- /** How close to its target a spring has to be to count as settled. */
30
+ /** How close to its target a spring or decay has to be to count as settled. */
29
31
  export const precision: Record<Property, number> = {
32
+ backgroundColor: 0.002,
33
+ borderColor: 0.002,
34
+ color: 0.002,
30
35
  opacity: 0.002,
31
36
  x: 0.25,
32
37
  y: 0.25,
@@ -38,14 +43,19 @@ export const precision: Record<Property, number> = {
38
43
  }
39
44
 
40
45
  export function isProperty(key: string): key is Property {
41
- return key in viewKeys
46
+ return key in viewKeys || isColorProperty(key)
42
47
  }
43
48
 
44
49
  export function readValue(view: View, property: Property): number {
50
+ if (isColorProperty(property)) return readColor(view, property)
45
51
  const value = view[viewKeys[property]]
46
52
  return typeof value === 'number' && !Number.isNaN(value) ? value : defaults[property]
47
53
  }
48
54
 
49
55
  export function writeValue(view: View, property: Property, value: number) {
56
+ if (isColorProperty(property)) {
57
+ view[property] = new Color(value)
58
+ return
59
+ }
50
60
  view[viewKeys[property]] = value
51
61
  }
package/src/reveal.ts ADDED
@@ -0,0 +1,374 @@
1
+ import { Application, isIOS, type View } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import { resolveEasing } from './easing.js'
4
+ import type { Animation } from './engine.js'
5
+ import type { Target } from './types.js'
6
+
7
+ export interface RevealOptions {
8
+ /** Circle origin in DIPs from the target's top-left corner. Defaults to its center. */
9
+ from?: { x: number; y: number }
10
+ /** Milliseconds. Defaults to 400; zero changes visibility immediately. */
11
+ duration?: number
12
+ }
13
+
14
+ export interface ConcealOptions {
15
+ /** Circle destination in DIPs from the target's top-left corner. Defaults to its center. */
16
+ to?: { x: number; y: number }
17
+ /** Milliseconds. Defaults to 400; zero changes visibility immediately. */
18
+ duration?: number
19
+ }
20
+
21
+ type Circle = { x: number; y: number; radius: number }
22
+ type MaskedView = UIView & { originalMask?: CALayer | null; maskType?: unknown }
23
+ type Snapshot = { circle: Circle; native: UIView; bounds: CGRect }
24
+ const running = new WeakMap<View, CircularAnimation>()
25
+ const callbacks = new WeakMap<NSObject, (finished: boolean) => void>()
26
+ const ease = resolveEasing('easeInOut')
27
+ const key = 'jolt:reveal'
28
+ let Delegate: typeof NSObject | undefined
29
+
30
+ /** Reveals an iOS view through an expanding circle, then removes the temporary mask. */
31
+ export function reveal(target: Target, options: RevealOptions = {}): Animation {
32
+ return start(target, true, options.from, options.duration ?? 400)
33
+ }
34
+
35
+ /** Contracts an iOS view to a point, then sets visibility to 'hidden' without collapsing layout. */
36
+ export function conceal(target: Target, options: ConcealOptions = {}): Animation {
37
+ return start(target, false, options.to, options.duration ?? 400)
38
+ }
39
+
40
+ function start(
41
+ target: Target,
42
+ opening: boolean,
43
+ point: { x: number; y: number } | undefined,
44
+ duration: number,
45
+ ): Animation {
46
+ if (!Number.isFinite(duration) || duration < 0)
47
+ throw new RangeError('Reveal duration must be a nonnegative finite number.')
48
+ if (
49
+ point !== undefined &&
50
+ (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y))
51
+ )
52
+ throw new TypeError('Reveal origin must have finite x and y coordinates.')
53
+ const view = toView(target)
54
+ const previous = running.get(view)
55
+ const snapshot = previous?.snapshot()
56
+ const original = previous?.originalVisibility ?? view.visibility
57
+ previous?.stop()
58
+ return new CircularAnimation(
59
+ view,
60
+ opening,
61
+ point && { ...point },
62
+ duration,
63
+ original,
64
+ snapshot,
65
+ )
66
+ }
67
+
68
+ function sameBounds(a: CGRect, b: CGRect) {
69
+ return (
70
+ a.origin.x === b.origin.x &&
71
+ a.origin.y === b.origin.y &&
72
+ a.size.width === b.size.width &&
73
+ a.size.height === b.size.height
74
+ )
75
+ }
76
+
77
+ function path(circle: Circle): CAShapeLayer['path'] {
78
+ // Keep the same oval topology even at the closed endpoint.
79
+ const radius = Math.max(0.01, circle.radius)
80
+ return UIBezierPath.bezierPathWithOvalInRect(
81
+ CGRectMake(circle.x - radius, circle.y - radius, radius * 2, radius * 2),
82
+ ).CGPath
83
+ }
84
+
85
+ function completion(
86
+ callback: (finished: boolean) => void,
87
+ ): NSObject & CAAnimationDelegate {
88
+ if (!Delegate) {
89
+ // Use the runtime subclass API so published JS needs no NativeClass transform.
90
+ const base = NSObject as typeof NSObject & {
91
+ extend(
92
+ methods: object,
93
+ options: { protocols: unknown[]; name: string },
94
+ ): typeof NSObject
95
+ }
96
+ Delegate = base.extend(
97
+ {
98
+ animationDidStopFinished(
99
+ this: NSObject,
100
+ _animation: CAAnimation,
101
+ finished: boolean,
102
+ ) {
103
+ callbacks.get(this)?.(finished)
104
+ },
105
+ },
106
+ { protocols: [CAAnimationDelegate], name: 'JoltCircularRevealDelegate' },
107
+ )
108
+ }
109
+ const delegate = Delegate.new()
110
+ callbacks.set(delegate, callback)
111
+ return delegate
112
+ }
113
+
114
+ class CircularAnimation implements Animation {
115
+ readonly finished: Promise<boolean>
116
+ private resolve!: (finished: boolean) => void
117
+ private settled = false
118
+ private queued = false
119
+ private changingVisibility = false
120
+ private waitingForLoad: boolean
121
+ private native: MaskedView | undefined
122
+ private bounds: CGRect | undefined
123
+ private wrapper: CALayer | undefined
124
+ private shape: CAShapeLayer | undefined
125
+ private originalMask: CALayer | null = null
126
+ private savedMask: CALayer | null = null
127
+ private delegate: NSObject | undefined
128
+ private observer: ReturnType<typeof Application.ios.addNotificationObserver> | undefined
129
+ private from: Circle | undefined
130
+ private to: Circle | undefined
131
+ private startedAt = 0
132
+
133
+ constructor(
134
+ private readonly view: View,
135
+ private readonly opening: boolean,
136
+ private readonly point: { x: number; y: number } | undefined,
137
+ private readonly duration: number,
138
+ readonly originalVisibility: View['visibility'],
139
+ private readonly inherited?: Snapshot,
140
+ ) {
141
+ this.finished = new Promise((resolve) => {
142
+ this.resolve = resolve
143
+ })
144
+ this.waitingForLoad = !view.isLoaded
145
+ running.set(view, this)
146
+ if (!isIOS) {
147
+ this.end(false)
148
+ return
149
+ }
150
+ view.on('loaded', this.loaded)
151
+ view.on('layoutChanged', this.layoutChanged)
152
+ view.on('unloaded', this.stop)
153
+ view.style.on('backgroundInternalChange', this.stop)
154
+ view.style.on('visibilityChange', this.visibilityChanged)
155
+ if (opening && view.visibility === 'collapse') this.visibility('hidden')
156
+ this.schedule()
157
+ }
158
+
159
+ then<A = boolean, B = never>(
160
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
161
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
162
+ ): Promise<A | B> {
163
+ return this.finished.then(onfulfilled, onrejected)
164
+ }
165
+
166
+ /** Cancels and restores visibility from before the interrupted reveal/conceal chain. */
167
+ stop = () => this.end(false)
168
+
169
+ snapshot(): Snapshot | undefined {
170
+ if (!this.shape || !this.native || !this.bounds || !this.from || !this.to) return
171
+ const presented = this.shape.presentationLayer() as CAShapeLayer | null
172
+ let circle: Circle
173
+ if (presented?.path) {
174
+ const box = CGPathGetBoundingBox(presented.path)
175
+ circle = {
176
+ x: box.origin.x + box.size.width / 2,
177
+ y: box.origin.y + box.size.height / 2,
178
+ radius: box.size.width / 2,
179
+ }
180
+ } else {
181
+ const progress = ease(
182
+ Math.max(
183
+ 0,
184
+ Math.min(1, ((CACurrentMediaTime() - this.startedAt) * 1000) / this.duration),
185
+ ),
186
+ )
187
+ const lerp = (a: number, b: number) => a + (b - a) * progress
188
+ circle = {
189
+ x: lerp(this.from.x, this.to.x),
190
+ y: lerp(this.from.y, this.to.y),
191
+ radius: lerp(this.from.radius, this.to.radius),
192
+ }
193
+ }
194
+ return { circle, native: this.native, bounds: this.bounds }
195
+ }
196
+
197
+ private visibility(value: View['visibility']) {
198
+ this.changingVisibility = true
199
+ try {
200
+ this.view.visibility = value
201
+ } finally {
202
+ this.changingVisibility = false
203
+ }
204
+ }
205
+
206
+ private visibilityChanged = () => {
207
+ if (!this.changingVisibility) this.end(false, true)
208
+ }
209
+
210
+ private loaded = () => {
211
+ this.waitingForLoad = false
212
+ this.schedule()
213
+ }
214
+
215
+ private layoutChanged = () => {
216
+ if (this.shape) {
217
+ const native = this.view.nativeViewProtected as UIView | undefined
218
+ if (
219
+ native !== this.native ||
220
+ !native?.window ||
221
+ !sameBounds(native.layer.bounds, this.bounds!)
222
+ ) {
223
+ this.stop()
224
+ return
225
+ }
226
+ }
227
+ this.schedule()
228
+ }
229
+
230
+ private schedule = () => {
231
+ if (this.settled || this.queued) return
232
+ this.queued = true
233
+ void Promise.resolve().then(() => {
234
+ this.queued = false
235
+ if (this.settled) return
236
+ try {
237
+ this.update()
238
+ } catch (error) {
239
+ this.stop()
240
+ throw error
241
+ }
242
+ })
243
+ }
244
+
245
+ private motionChanged = () => {
246
+ if (UIAccessibilityIsReduceMotionEnabled()) this.end(true)
247
+ }
248
+
249
+ private update() {
250
+ if (!this.opening && !this.inherited && this.view.visibility !== 'visible')
251
+ return this.end(true, true)
252
+ if (this.duration === 0 || UIAccessibilityIsReduceMotionEnabled())
253
+ return this.end(true)
254
+ if (this.waitingForLoad || !this.view.isLoaded) return
255
+ const native = this.view.nativeViewProtected as MaskedView | undefined
256
+ if (this.shape) {
257
+ if (native !== this.native || native?.layer.mask !== this.wrapper) this.stop()
258
+ return
259
+ }
260
+ if (!native?.window) return
261
+ const bounds = native.layer.bounds
262
+ if (bounds.size.width <= 0 || bounds.size.height <= 0) return
263
+ const inherited =
264
+ this.inherited?.native === native && sameBounds(this.inherited.bounds, bounds)
265
+ ? this.inherited.circle
266
+ : undefined
267
+ const x = this.point
268
+ ? bounds.origin.x + this.point.x
269
+ : (inherited?.x ?? bounds.origin.x + bounds.size.width / 2)
270
+ const y = this.point
271
+ ? bounds.origin.y + this.point.y
272
+ : (inherited?.y ?? bounds.origin.y + bounds.size.height / 2)
273
+ const radius =
274
+ Math.hypot(
275
+ Math.max(
276
+ Math.abs(x - bounds.origin.x),
277
+ Math.abs(x - bounds.origin.x - bounds.size.width),
278
+ ),
279
+ Math.max(
280
+ Math.abs(y - bounds.origin.y),
281
+ Math.abs(y - bounds.origin.y - bounds.size.height),
282
+ ),
283
+ ) + 1
284
+ this.from = inherited ?? { x, y, radius: this.opening ? 0 : radius }
285
+ this.to = { x, y, radius: this.opening ? radius : 0 }
286
+ this.native = native
287
+ this.bounds = bounds
288
+ this.originalMask = native.layer.mask
289
+ this.savedMask =
290
+ native.maskType != null ? (native.originalMask ?? null) : this.originalMask
291
+
292
+ CATransaction.begin()
293
+ CATransaction.setDisableActions(true)
294
+ try {
295
+ // A plain container prevents Core from mistaking our circle for its own clip path.
296
+ const wrapper = (this.wrapper = CALayer.new())
297
+ const shape = (this.shape = CAShapeLayer.new())
298
+ wrapper.name = key
299
+ for (const layer of [wrapper, shape]) {
300
+ layer.bounds = bounds
301
+ layer.position = CGPointMake(
302
+ bounds.origin.x + bounds.size.width / 2,
303
+ bounds.origin.y + bounds.size.height / 2,
304
+ )
305
+ }
306
+ shape.fillColor = UIColor.blackColor.CGColor
307
+ shape.path = path(this.to)
308
+ wrapper.addSublayer(shape)
309
+ // Same coordinate space as the original host, including bounds origins.
310
+ native.layer.mask = null
311
+ wrapper.mask = this.originalMask
312
+ native.layer.mask = wrapper
313
+ const animation = CABasicAnimation.animationWithKeyPath('path')
314
+ animation.fromValue = path(this.from)
315
+ animation.toValue = shape.path
316
+ animation.duration = this.duration / 1000
317
+ animation.timingFunction = CAMediaTimingFunction.functionWithName(
318
+ kCAMediaTimingFunctionEaseInEaseOut,
319
+ )
320
+ this.delegate = animation.delegate = completion((finished) => this.end(finished))
321
+ this.startedAt = CACurrentMediaTime()
322
+ shape.addAnimationForKey(animation, key)
323
+ this.observer = Application.ios.addNotificationObserver(
324
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
325
+ this.motionChanged,
326
+ )
327
+ this.visibility('visible')
328
+ } finally {
329
+ CATransaction.commit()
330
+ }
331
+ }
332
+
333
+ private end(finished: boolean, keepVisibility = false) {
334
+ if (this.settled) return
335
+ this.settled = true
336
+ const { view, native, wrapper, shape } = this
337
+ view.off('loaded', this.loaded)
338
+ view.off('layoutChanged', this.layoutChanged)
339
+ view.off('unloaded', this.stop)
340
+ view.style.off('backgroundInternalChange', this.stop)
341
+ view.style.off('visibilityChange', this.visibilityChanged)
342
+ if (running.get(view) === this) running.delete(view)
343
+ if (this.observer)
344
+ Application.ios.removeNotificationObserver(
345
+ this.observer,
346
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
347
+ )
348
+ if (this.delegate) callbacks.delete(this.delegate)
349
+ if (native && wrapper) {
350
+ CATransaction.begin()
351
+ CATransaction.setDisableActions(true)
352
+ try {
353
+ // Hide before removing a closed mask so conceal never flashes its content.
354
+ if (!keepVisibility)
355
+ this.visibility(
356
+ finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
357
+ )
358
+ shape?.removeAnimationForKey(key)
359
+ wrapper.mask = null
360
+ if (native.layer.mask === wrapper) native.layer.mask = this.originalMask
361
+ // Core can save the temporary mask while installing a new clip/border mask.
362
+ if (native.originalMask === wrapper) native.originalMask = this.savedMask
363
+ } finally {
364
+ CATransaction.commit()
365
+ }
366
+ } else if (isIOS && !keepVisibility) {
367
+ this.visibility(
368
+ finished ? (this.opening ? 'visible' : 'hidden') : this.originalVisibility,
369
+ )
370
+ }
371
+ this.native = this.wrapper = this.shape = this.delegate = this.observer = undefined
372
+ this.resolve(finished)
373
+ }
374
+ }
@@ -0,0 +1,216 @@
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
+ const MAX_PIECES = 256
5
+ const MAX_PIXELS = 4_000_000
6
+
7
+ /** Ligatures and composed characters may share glyphs. Never draw a shared glyph twice. */
8
+ export function mergeGlyphRanges(ranges: readonly NSRange[]): NSRange[] {
9
+ const merged: NSRange[] = []
10
+ for (const range of ranges) {
11
+ if (!range.length) continue
12
+ const previous = merged.at(-1)
13
+ if (
14
+ previous &&
15
+ range.location < previous.location + previous.length &&
16
+ range.location + range.length > previous.location
17
+ ) {
18
+ const end = Math.max(
19
+ previous.location + previous.length,
20
+ range.location + range.length,
21
+ )
22
+ previous.location = Math.min(previous.location, range.location)
23
+ previous.length = end - previous.location
24
+ } else merged.push({ location: range.location, length: range.length })
25
+ }
26
+ return merged
27
+ }
28
+
29
+ export function textLayer(native: NativeLabel, by: 'letter' | 'word') {
30
+ if (native.adjustsFontSizeToFitWidth) return undefined
31
+ const original = native.attributedText
32
+ const text = original?.string ?? native.text ?? ''
33
+ if (!text.trim()) return { text, layer: undefined }
34
+ const string = NSString.stringWithString(text)
35
+ const full = { location: 0, length: string.length }
36
+ const attributed = original
37
+ ? NSMutableAttributedString.alloc().initWithAttributedString(original)
38
+ : NSMutableAttributedString.alloc().initWithString(text)
39
+ const paragraph = NSMutableParagraphStyle.new()
40
+ paragraph.alignment = native.textAlignment
41
+ paragraph.lineBreakMode = native.lineBreakMode
42
+ const defaults: [string, unknown][] = [
43
+ [NSFontAttributeName, native.font],
44
+ [NSForegroundColorAttributeName, native.textColor],
45
+ [NSParagraphStyleAttributeName, paragraph],
46
+ ]
47
+ if (native.shadowColor) {
48
+ const shadow = NSShadow.new()
49
+ shadow.shadowColor = native.shadowColor
50
+ shadow.shadowOffset = native.shadowOffset
51
+ defaults.push([NSShadowAttributeName, shadow])
52
+ }
53
+ for (const [key, value] of defaults) {
54
+ attributed.enumerateAttributeInRangeOptionsUsingBlock(
55
+ key,
56
+ full,
57
+ 0 as NSAttributedStringEnumerationOptions,
58
+ (attribute, range) => {
59
+ if (attribute == null && value != null)
60
+ attributed.addAttributeValueRange(key, value, range)
61
+ },
62
+ )
63
+ }
64
+ const inset = (side: keyof Insets) =>
65
+ (native.padding?.[side] ?? 0) + (native.borderThickness?.[side] ?? 0)
66
+ const bounds = native.bounds
67
+ const width = bounds.size.width - inset('left') - inset('right')
68
+ const height = bounds.size.height - inset('top') - inset('bottom')
69
+ if (width <= 0 || height <= 0) return undefined
70
+ const storage = NSTextStorage.alloc().initWithAttributedString(attributed)
71
+ const manager = NSLayoutManager.new()
72
+ manager.usesFontLeading = false
73
+ const container = NSTextContainer.alloc().initWithSize(CGSizeMake(width, height))
74
+ container.lineFragmentPadding = 0
75
+ container.maximumNumberOfLines = native.numberOfLines
76
+ container.lineBreakMode = native.lineBreakMode
77
+ manager.addTextContainer(container)
78
+ storage.addLayoutManager(manager)
79
+ manager.ensureLayoutForTextContainer(container)
80
+ const visible = manager.glyphRangeForTextContainer(container)
81
+ if (visible.length !== manager.numberOfGlyphs) return undefined
82
+ // A truncation token is not an independently addressable character. Keep the real label.
83
+ for (let glyph = 0; glyph < manager.numberOfGlyphs;) {
84
+ if (manager.truncatedGlyphRangeInLineFragmentForGlyphAtIndex(glyph).length)
85
+ return undefined
86
+ const range = new interop.Reference<NSRange>()
87
+ manager.lineFragmentRectForGlyphAtIndexEffectiveRange(glyph, range)
88
+ const next = range.value.location + range.value.length
89
+ if (next <= glyph) return undefined
90
+ glyph = next
91
+ }
92
+ const characters: NSRange[] = []
93
+ string.enumerateSubstringsInRangeOptionsUsingBlock(
94
+ full,
95
+ by === 'letter' ? 2 : 3,
96
+ (substring, range) => {
97
+ if (substring?.trim())
98
+ characters.push({ location: range.location, length: range.length })
99
+ },
100
+ )
101
+ if (by === 'word') {
102
+ // Attach spaces and punctuation to the surrounding word, preserving every visible mark.
103
+ characters.forEach((range, index) => {
104
+ const start = index === 0 ? 0 : range.location
105
+ const end = characters[index + 1]?.location ?? full.length
106
+ range.location = start
107
+ range.length = end - start
108
+ })
109
+ if (!characters.length) characters.push(full)
110
+ }
111
+ const ranges = mergeGlyphRanges(
112
+ characters.map((range) =>
113
+ manager.glyphRangeForCharacterRangeActualCharacterRange(range, null),
114
+ ),
115
+ )
116
+ if (ranges.length > MAX_PIECES) return undefined
117
+ const used = manager.usedRectForTextContainer(container)
118
+ const offsetX = bounds.origin.x + inset('left')
119
+ const offsetY =
120
+ bounds.origin.y + inset('top') + (height - used.size.height) / 2 - used.origin.y
121
+ const scale = native.window?.screen.scale ?? UIScreen.mainScreen.scale
122
+ const padding = Math.max(4, native.font.pointSize * 0.1)
123
+ const pieces = ranges.map((range) => {
124
+ const glyph = manager.boundingRectForGlyphRangeInTextContainer(range, container)
125
+ return {
126
+ range,
127
+ rect: CGRectIntegral(
128
+ CGRectMake(
129
+ glyph.origin.x + offsetX - padding,
130
+ glyph.origin.y + offsetY - padding,
131
+ glyph.size.width + padding * 2,
132
+ glyph.size.height + padding * 2,
133
+ ),
134
+ ),
135
+ }
136
+ })
137
+ if (
138
+ pieces.reduce(
139
+ (pixels, { rect }) => pixels + rect.size.width * rect.size.height * scale * scale,
140
+ 0,
141
+ ) > MAX_PIXELS
142
+ )
143
+ return undefined
144
+ const layer = CALayer.new()
145
+ layer.name = 'jolt:text'
146
+ layer.frame = bounds
147
+ const transparent = UIColor.clearColor
148
+ const noShadow = NSShadow.new()
149
+ noShadow.shadowColor = transparent
150
+ const hidden = NSMutableAttributedString.alloc().initWithAttributedString(attributed)
151
+ for (const key of [
152
+ NSForegroundColorAttributeName,
153
+ NSBackgroundColorAttributeName,
154
+ NSUnderlineColorAttributeName,
155
+ NSStrikethroughColorAttributeName,
156
+ NSStrokeColorAttributeName,
157
+ ]) {
158
+ hidden.addAttributeValueRange(key, transparent, full)
159
+ }
160
+ hidden.addAttributeValueRange(NSShadowAttributeName, noShadow, full)
161
+ let attachments = false
162
+ attributed.enumerateAttributeInRangeOptionsUsingBlock(
163
+ NSAttachmentAttributeName,
164
+ full,
165
+ 0 as NSAttributedStringEnumerationOptions,
166
+ (value) => {
167
+ if (value != null) attachments = true
168
+ },
169
+ )
170
+ if (attachments) return undefined
171
+ const shadowColor = native.shadowColor
172
+ try {
173
+ native.shadowColor = null
174
+ for (const { range, rect } of pieces) {
175
+ const fragment = NSMutableAttributedString.alloc().initWithAttributedString(hidden)
176
+ const characters = manager.characterRangeForGlyphRangeActualGlyphRange(range, null)
177
+ attributed.enumerateAttributesInRangeOptionsUsingBlock(
178
+ characters,
179
+ 0 as NSAttributedStringEnumerationOptions,
180
+ (attributes, part) => fragment.setAttributesRange(attributes, part),
181
+ )
182
+ native.attributedText = fragment
183
+ // UILabel draws its own glyphs, preserving its baseline rounding and fallback-font metrics.
184
+ // Other glyphs keep their shaping/advances but have transparent paint.
185
+ UIGraphicsBeginImageContextWithOptions(rect.size, false, scale)
186
+ let image: UIImage | null
187
+ try {
188
+ CGContextTranslateCTM(
189
+ UIGraphicsGetCurrentContext()!,
190
+ -rect.origin.x,
191
+ -rect.origin.y,
192
+ )
193
+ native.drawTextInRect(bounds)
194
+ image = UIGraphicsGetImageFromCurrentImageContext()
195
+ } finally {
196
+ UIGraphicsEndImageContext()
197
+ }
198
+ if (!image?.CGImage) return undefined
199
+ const piece = CALayer.new()
200
+ piece.frame = CGRectMake(
201
+ rect.origin.x - bounds.origin.x,
202
+ rect.origin.y - bounds.origin.y,
203
+ rect.size.width,
204
+ rect.size.height,
205
+ )
206
+ piece.contents = image.CGImage
207
+ piece.contentsScale = scale
208
+ layer.addSublayer(piece)
209
+ }
210
+ } finally {
211
+ if (original) native.attributedText = original
212
+ else native.text = text
213
+ native.shadowColor = shadowColor
214
+ }
215
+ return { text, layer }
216
+ }