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

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 (91) hide show
  1. package/README.md +115 -8
  2. package/dist/animate.d.ts +3 -1
  3. package/dist/animate.d.ts.map +1 -1
  4. package/dist/animate.js +83 -6
  5. package/dist/animate.js.map +1 -1
  6. package/dist/decay.d.ts +11 -0
  7. package/dist/decay.d.ts.map +1 -0
  8. package/dist/decay.js +22 -0
  9. package/dist/decay.js.map +1 -0
  10. package/dist/driver/android.d.ts +1 -1
  11. package/dist/driver/android.d.ts.map +1 -1
  12. package/dist/driver/android.js +95 -5
  13. package/dist/driver/android.js.map +1 -1
  14. package/dist/driver/index.d.ts +3 -1
  15. package/dist/driver/index.d.ts.map +1 -1
  16. package/dist/driver/index.js.map +1 -1
  17. package/dist/driver/ios.d.ts.map +1 -1
  18. package/dist/driver/ios.js +7 -0
  19. package/dist/driver/ios.js.map +1 -1
  20. package/dist/engine.d.ts +6 -3
  21. package/dist/engine.d.ts.map +1 -1
  22. package/dist/engine.js +21 -5
  23. package/dist/engine.js.map +1 -1
  24. package/dist/haptics.d.ts +18 -0
  25. package/dist/haptics.d.ts.map +1 -0
  26. package/dist/haptics.js +96 -0
  27. package/dist/haptics.js.map +1 -0
  28. package/dist/index.d.ts +14 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -1
  31. package/dist/index.js.map +1 -1
  32. package/dist/loader.d.ts +19 -0
  33. package/dist/loader.d.ts.map +1 -0
  34. package/dist/loader.js +172 -0
  35. package/dist/loader.js.map +1 -0
  36. package/dist/particles.d.ts +39 -0
  37. package/dist/particles.d.ts.map +1 -0
  38. package/dist/particles.js +264 -0
  39. package/dist/particles.js.map +1 -0
  40. package/dist/properties.d.ts +1 -1
  41. package/dist/properties.d.ts.map +1 -1
  42. package/dist/properties.js +1 -1
  43. package/dist/properties.js.map +1 -1
  44. package/dist/reveal.d.ts +25 -0
  45. package/dist/reveal.d.ts.map +1 -0
  46. package/dist/reveal.js +293 -0
  47. package/dist/reveal.js.map +1 -0
  48. package/dist/shimmer.d.ts +12 -0
  49. package/dist/shimmer.d.ts.map +1 -0
  50. package/dist/shimmer.js +160 -0
  51. package/dist/shimmer.js.map +1 -0
  52. package/dist/symbol-effect.d.ts +23 -0
  53. package/dist/symbol-effect.d.ts.map +1 -0
  54. package/dist/symbol-effect.js +186 -0
  55. package/dist/symbol-effect.js.map +1 -0
  56. package/dist/text-layout.d.ts +22 -0
  57. package/dist/text-layout.d.ts.map +1 -0
  58. package/dist/text-layout.js +179 -0
  59. package/dist/text-layout.js.map +1 -0
  60. package/dist/text.d.ts +17 -0
  61. package/dist/text.d.ts.map +1 -0
  62. package/dist/text.js +264 -0
  63. package/dist/text.js.map +1 -0
  64. package/dist/track.d.ts.map +1 -1
  65. package/dist/track.js +25 -3
  66. package/dist/track.js.map +1 -1
  67. package/dist/transition.d.ts +5 -0
  68. package/dist/transition.d.ts.map +1 -1
  69. package/dist/transition.js.map +1 -1
  70. package/dist/types.d.ts +17 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/package.json +2 -1
  73. package/src/animate.ts +96 -4
  74. package/src/decay.ts +45 -0
  75. package/src/driver/android.ts +112 -6
  76. package/src/driver/index.ts +3 -1
  77. package/src/driver/ios.ts +11 -0
  78. package/src/engine.ts +22 -6
  79. package/src/haptics.ts +125 -0
  80. package/src/index.ts +18 -1
  81. package/src/loader.ts +201 -0
  82. package/src/particles.ts +323 -0
  83. package/src/properties.ts +1 -1
  84. package/src/reveal.ts +374 -0
  85. package/src/shimmer.ts +179 -0
  86. package/src/symbol-effect.ts +225 -0
  87. package/src/text-layout.ts +216 -0
  88. package/src/text.ts +296 -0
  89. package/src/track.ts +42 -3
  90. package/src/transition.ts +6 -0
  91. package/src/types.ts +16 -0
@@ -0,0 +1,225 @@
1
+ import { Image, ImageSource, isIOS } from '@nativescript/core'
2
+ import { toView } from './animate.js'
3
+ import type { Animation } from './engine.js'
4
+ import type { Target } from './types.js'
5
+
6
+ export type SymbolEffectName = 'bounce' | 'pulse' | 'scale' | 'variableColor'
7
+
8
+ export type SymbolEffect = SymbolEffectName | { replace: `sys://${string}` }
9
+
10
+ export interface SymbolEffectOptions {
11
+ /** Native playback speed multiplier. Defaults to 1. */
12
+ speed?: number
13
+ /** Repeat bounce, pulse or variableColor until stopped. Defaults to false. */
14
+ repeat?: boolean
15
+ }
16
+
17
+ const running = new WeakMap<Image, SymbolAnimation>()
18
+ const names: readonly string[] = ['bounce', 'pulse', 'scale', 'variableColor']
19
+
20
+ /**
21
+ * Plays an iOS 17+ SF Symbol effect on a NativeScript Image.
22
+ * Scale holds until stopped; replacements also update the Image's src.
23
+ *
24
+ * ```ts
25
+ * symbolEffect(icon, 'bounce')
26
+ * symbolEffect(icon, { replace: 'sys://heart.fill' })
27
+ * ```
28
+ */
29
+ export function symbolEffect(
30
+ target: Target,
31
+ effect: SymbolEffect,
32
+ options: SymbolEffectOptions = {},
33
+ ): Animation {
34
+ const view = toView(target)
35
+ if (!(view instanceof Image)) throw new TypeError('symbolEffect expects an Image.')
36
+ if (
37
+ typeof effect === 'string'
38
+ ? !names.includes(effect)
39
+ : !effect ||
40
+ typeof effect.replace !== 'string' ||
41
+ !/^sys:\/\/[^\s/]+$/.test(effect.replace)
42
+ ) {
43
+ throw new TypeError('Expected a symbol effect name or { replace: "sys://name" }.')
44
+ }
45
+ const speed = options.speed ?? 1
46
+ if (!Number.isFinite(speed) || speed <= 0)
47
+ throw new RangeError('Symbol effect speed must be a positive finite number.')
48
+ if (options.repeat !== undefined && typeof options.repeat !== 'boolean')
49
+ throw new TypeError('Symbol effect repeat must be a boolean.')
50
+ if (options.repeat && (typeof effect !== 'string' || effect === 'scale'))
51
+ throw new TypeError('Only bounce, pulse and variableColor can repeat.')
52
+
53
+ return new SymbolAnimation(view, effect, speed, options.repeat ?? false)
54
+ }
55
+
56
+ class SymbolAnimation implements Animation {
57
+ readonly finished: Promise<boolean>
58
+ private resolve!: (finished: boolean) => void
59
+ private settled = false
60
+ private started = false
61
+ private changingSource = false
62
+ private waitingForLoad: boolean
63
+ private native: UIImageView | undefined
64
+ private nativeEffect: NSSymbolEffect | undefined
65
+ private replacement: UIImage | undefined
66
+
67
+ constructor(
68
+ private readonly view: Image,
69
+ private readonly effect: SymbolEffect,
70
+ private readonly speed: number,
71
+ private readonly repeat: boolean,
72
+ ) {
73
+ this.finished = new Promise((resolve) => (this.resolve = resolve))
74
+ this.waitingForLoad = !view.isLoaded
75
+ running.get(view)?.stop()
76
+ running.set(view, this)
77
+ view.on('unloaded', this.stop)
78
+ view.on('srcChange', this.sourceChanged)
79
+ view.on('imageSourceChange', this.imageChanged)
80
+ view.on('loaded', this.loaded)
81
+ view.on('isLoadingChange', this.start)
82
+ this.start()
83
+ }
84
+
85
+ then<A = boolean, B = never>(
86
+ onfulfilled?: ((finished: boolean) => A | PromiseLike<A>) | null,
87
+ onrejected?: ((reason: unknown) => B | PromiseLike<B>) | null,
88
+ ): Promise<A | B> {
89
+ return this.finished.then(onfulfilled, onrejected)
90
+ }
91
+
92
+ stop = () => this.end(false, true)
93
+
94
+ private sourceChanged = () => {
95
+ if (!this.changingSource) this.stop()
96
+ }
97
+
98
+ private imageChanged = () => {
99
+ if (this.changingSource) return
100
+ if (this.started) this.stop()
101
+ else this.start()
102
+ }
103
+
104
+ private loaded = () => {
105
+ this.waitingForLoad = false
106
+ this.start()
107
+ }
108
+
109
+ private start = () => {
110
+ if (this.settled || this.started) return
111
+ if (!isIOS) return this.end(false)
112
+ const { view, effect } = this
113
+ // isLoaded becomes true before NativeScript resumes native property updates.
114
+ // Wait for the loaded event so intermediate image resets don't start playback.
115
+ if (
116
+ this.waitingForLoad ||
117
+ !view.isLoaded ||
118
+ !view.nativeViewProtected ||
119
+ view.isLoading
120
+ )
121
+ return
122
+ this.started = true
123
+ const native = (this.native = view.nativeViewProtected as UIImageView)
124
+ const reducedMotion = UIAccessibilityIsReduceMotionEnabled()
125
+
126
+ if (typeof effect !== 'string') {
127
+ // Resolve first so a symbol absent on this OS doesn't clear the current image.
128
+ if (!ImageSource.fromSystemImageSync(effect.replace.slice(6))?.ios)
129
+ return this.end(false)
130
+ const previous = native.image
131
+ const loadMode = view.loadMode
132
+ this.changingSource = true
133
+ try {
134
+ // Let NativeScript update its source, tint, configuration and pending loads.
135
+ // Both assignments happen in this turn, before UIKit draws the next frame.
136
+ view.loadMode = 'sync'
137
+ view.src = effect.replace
138
+ } finally {
139
+ view.loadMode = loadMode
140
+ this.changingSource = false
141
+ }
142
+ // A source-change listener may have started another effect or changed the src.
143
+ if (this.settled) return
144
+ if (view.src !== effect.replace) return this.end(false)
145
+ const image = native.image
146
+ if (!image) return this.end(false)
147
+ if (
148
+ reducedMotion ||
149
+ !previous?.symbolImage ||
150
+ !native.respondsToSelector(
151
+ 'setSymbolImage:withContentTransition:options:completion:',
152
+ )
153
+ )
154
+ return this.end(true)
155
+
156
+ this.replacement = image
157
+ native.image = previous
158
+ native.setSymbolImageWithContentTransitionOptionsCompletion(
159
+ image,
160
+ NSSymbolReplaceContentTransition.transition(),
161
+ this.options(),
162
+ this.completed,
163
+ )
164
+ return
165
+ }
166
+
167
+ if (
168
+ reducedMotion ||
169
+ !native.image?.symbolImage ||
170
+ !native.respondsToSelector('addSymbolEffect:options:animated:completion:')
171
+ )
172
+ return this.end(false)
173
+
174
+ const factories = {
175
+ bounce: NSSymbolBounceEffect,
176
+ pulse: NSSymbolPulseEffect,
177
+ scale: NSSymbolScaleEffect,
178
+ variableColor: NSSymbolVariableColorEffect,
179
+ }
180
+ this.nativeEffect = factories[effect].effect()
181
+ native.addSymbolEffectOptionsAnimatedCompletion(
182
+ this.nativeEffect,
183
+ this.options(),
184
+ true,
185
+ this.completed,
186
+ )
187
+ }
188
+
189
+ private options(): NSSymbolEffectOptions {
190
+ const options = this.repeat
191
+ ? NSSymbolEffectOptions.optionsWithRepeating()
192
+ : NSSymbolEffectOptions.optionsWithNonRepeating()
193
+ return options.optionsWithSpeed(this.speed)
194
+ }
195
+
196
+ private completed = (context: UISymbolEffectCompletionContext) => {
197
+ // Scale's entrance can complete while the effect is still holding its state.
198
+ if (context.finished && (this.effect === 'scale' || this.repeat)) return
199
+ this.end(context.finished)
200
+ }
201
+
202
+ private end(finished: boolean, remove = false) {
203
+ if (this.settled) return
204
+ this.settled = true
205
+ const { view, native, nativeEffect, replacement } = this
206
+ view.off('loaded', this.loaded)
207
+ view.off('isLoadingChange', this.start)
208
+ view.off('unloaded', this.stop)
209
+ view.off('srcChange', this.sourceChanged)
210
+ view.off('imageSourceChange', this.imageChanged)
211
+ if (running.get(view) === this) running.delete(view)
212
+ if (remove && native) {
213
+ if (nativeEffect) native.removeSymbolEffectOfType(nativeEffect)
214
+ // Content transitions have no removal API. Keep the destination icon.
215
+ if (replacement && native.image === replacement) {
216
+ native.image = null
217
+ native.image = replacement
218
+ }
219
+ }
220
+ this.native = undefined
221
+ this.nativeEffect = undefined
222
+ this.replacement = undefined
223
+ this.resolve(finished)
224
+ }
225
+ }
@@ -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
+ }