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