@rafaelaugusto/jolt 0.1.0-beta.6 → 0.1.0-beta.7

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 (47) hide show
  1. package/README.md +12 -2
  2. package/dist/image-effect-native.d.ts +26 -0
  3. package/dist/image-effect-native.d.ts.map +1 -0
  4. package/dist/image-effect-native.js +73 -0
  5. package/dist/image-effect-native.js.map +1 -0
  6. package/dist/image-effect.d.ts +50 -0
  7. package/dist/image-effect.d.ts.map +1 -0
  8. package/dist/image-effect.js +290 -0
  9. package/dist/image-effect.js.map +1 -0
  10. package/dist/image-owner.d.ts +3 -0
  11. package/dist/image-owner.d.ts.map +1 -0
  12. package/dist/image-owner.js +11 -0
  13. package/dist/image-owner.js.map +1 -0
  14. package/dist/index.d.ts +4 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/liquid.d.ts.map +1 -1
  19. package/dist/liquid.js +4 -0
  20. package/dist/liquid.js.map +1 -1
  21. package/dist/zoom-native.d.ts +30 -0
  22. package/dist/zoom-native.d.ts.map +1 -0
  23. package/dist/zoom-native.js +56 -0
  24. package/dist/zoom-native.js.map +1 -0
  25. package/dist/zoomable.d.ts +48 -0
  26. package/dist/zoomable.d.ts.map +1 -0
  27. package/dist/zoomable.js +249 -0
  28. package/dist/zoomable.js.map +1 -0
  29. package/package.json +2 -1
  30. package/platforms/android/java/org/rafaelaugusto/jolt/ImageEffect.java +288 -0
  31. package/platforms/android/java/org/rafaelaugusto/jolt/PhotoZoom.java +250 -0
  32. package/platforms/android/proguard-rules.pro +4 -0
  33. package/platforms/ios/build.xcconfig +1 -0
  34. package/platforms/ios/src/JoltImageEffect.h +25 -0
  35. package/platforms/ios/src/JoltImageEffect.mm +242 -0
  36. package/platforms/ios/src/JoltPhotoZoom.h +23 -0
  37. package/platforms/ios/src/JoltPhotoZoom.m +229 -0
  38. package/platforms/ios/src/module.modulemap +5 -0
  39. package/src/android-plugin.d.ts +78 -0
  40. package/src/image-effect-native.ts +129 -0
  41. package/src/image-effect.ts +336 -0
  42. package/src/image-owner.ts +12 -0
  43. package/src/index.ts +9 -0
  44. package/src/ios-plugin.d.ts +59 -0
  45. package/src/liquid.ts +4 -0
  46. package/src/zoom-native.ts +124 -0
  47. package/src/zoomable.ts +330 -0
@@ -0,0 +1,129 @@
1
+ import { Utils, isAndroid, isIOS, type Image, type ImageSource } from '@nativescript/core'
2
+
3
+ export interface ImageEffectSettings {
4
+ mode: number
5
+ x: number
6
+ y: number
7
+ amplitude: number
8
+ radius: number
9
+ duration: number
10
+ seed: number
11
+ to?: ImageSource
12
+ }
13
+
14
+ export interface ImageRenderer {
15
+ readonly backend: 'metal' | 'agsl'
16
+ readonly progress: number
17
+ readonly frames: number
18
+ readonly textureBytes: number
19
+ readonly longestFrameMs: number
20
+ play(endpoint: number): void
21
+ pause(): void
22
+ resume(): void
23
+ seek(progress: number): void
24
+ finish(endpoint: number): void
25
+ stop(preserveImage?: boolean): void
26
+ }
27
+
28
+ export function createImageRenderer(
29
+ image: Image,
30
+ settings: ImageEffectSettings,
31
+ ended: (completed: boolean, progress: number) => void,
32
+ ): ImageRenderer | undefined {
33
+ const { mode, x, y, amplitude, radius, duration, seed, to } = settings
34
+ if (isIOS && typeof JoltImageEffect !== 'undefined') {
35
+ const native =
36
+ JoltImageEffect.createWithViewDestinationModeOriginXOriginYAmplitudeRadiusDurationSeedListener(
37
+ image.nativeViewProtected,
38
+ to?.ios ?? null,
39
+ mode,
40
+ x,
41
+ y,
42
+ amplitude,
43
+ radius,
44
+ duration,
45
+ seed,
46
+ ended,
47
+ )
48
+ if (!native) return undefined
49
+ return {
50
+ backend: 'metal',
51
+ get progress() {
52
+ return native.progress
53
+ },
54
+ get frames() {
55
+ return native.frames
56
+ },
57
+ get textureBytes() {
58
+ return native.textureBytes
59
+ },
60
+ get longestFrameMs() {
61
+ return native.longestFrame * 1000
62
+ },
63
+ play: (p) => native.play(p),
64
+ pause: () => native.pause(),
65
+ resume: () => native.resume(),
66
+ seek: (p) => native.seek(p),
67
+ finish: (p) => native.finish(p),
68
+ stop: (preserveImage) =>
69
+ preserveImage ? native.stopPreservingImage() : native.stop(),
70
+ }
71
+ }
72
+ if (isAndroid && android.os.Build.VERSION.SDK_INT >= 33) {
73
+ const density = Utils.layout.getDisplayDensity()
74
+ const background = image.nativeViewProtected.getBackground()
75
+ // NativeScript draws bitmap pixels itself: ImageView.getImageMatrix() does
76
+ // not describe its stretch, EXIF rotation or inner border clipping.
77
+ const geometry = Array.create('float', 6)
78
+ geometry[0] = image.imageSource?.rotationAngle ?? 0
79
+ geometry[1] = to?.rotationAngle ?? 0
80
+ const corners = ['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft']
81
+ corners.forEach((corner, i) => {
82
+ geometry[i + 2] = background?.[`getBorder${corner}Radius`]?.() ?? 0
83
+ })
84
+ const native = new org.rafaelaugusto.jolt.ImageEffect(
85
+ image.nativeViewProtected,
86
+ image.imageSource?.android ?? null,
87
+ to?.android ?? null,
88
+ geometry,
89
+ mode,
90
+ x * density,
91
+ y * density,
92
+ amplitude * density,
93
+ radius * density,
94
+ duration,
95
+ seed,
96
+ (image as Image & { touchListener?: android.view.View.OnTouchListener })
97
+ .touchListener ?? null,
98
+ new org.rafaelaugusto.jolt.ImageEffect.Listener({ onEnd: ended }),
99
+ )
100
+ if (!native.isSupported()) {
101
+ const reason = native.getFailureReason()
102
+ native.stop()
103
+ throw new Error(reason)
104
+ }
105
+ return {
106
+ backend: 'agsl',
107
+ get progress() {
108
+ return native.getProgress()
109
+ },
110
+ get frames() {
111
+ return native.getFrames()
112
+ },
113
+ get textureBytes() {
114
+ return native.getTextureBytes()
115
+ },
116
+ get longestFrameMs() {
117
+ return native.getLongestFrameNanos() / 1e6
118
+ },
119
+ play: (p) => native.play(p),
120
+ pause: () => native.pause(),
121
+ resume: () => native.resume(),
122
+ seek: (p) => native.seek(p),
123
+ finish: (p) => native.finish(p),
124
+ stop: (preserveImage) =>
125
+ preserveImage ? native.stopPreservingImage() : native.stop(),
126
+ }
127
+ }
128
+ return undefined
129
+ }
@@ -0,0 +1,336 @@
1
+ import {
2
+ Application,
3
+ Image,
4
+ ImageSource,
5
+ isIOS,
6
+ type EventData,
7
+ } from '@nativescript/core'
8
+ import { toView } from './animate.js'
9
+ import { reducedMotion, timeScale } from './config.js'
10
+ import type { Animation } from './engine.js'
11
+ import { ownImage } from './image-owner.js'
12
+ import { createImageRenderer, type ImageRenderer } from './image-effect-native.js'
13
+ import type { Target } from './types.js'
14
+
15
+ export type ImageEffectName = 'ripple' | 'distortion' | 'dissolve'
16
+ export interface ImageEffectOptions {
17
+ /** Origin in target-local DIPs; defaults to the center. */
18
+ origin?: { x: number; y: number }
19
+ /** Maximum displacement in DIPs, 0..64. Defaults to 8. */
20
+ amplitude?: number
21
+ /** Influence radius in DIPs, greater than zero. Defaults to 96. */
22
+ radius?: number
23
+ /** Milliseconds for a finite effect (or drag release). Defaults to 650. */
24
+ duration?: number
25
+ /** Deterministic dissolve noise seed, 0..65535. Defaults to 1. */
26
+ seed?: number
27
+ /** Loaded destination raster for dissolve. */
28
+ to?: ImageSource
29
+ /** Prepare at progress zero without starting. */
30
+ paused?: boolean
31
+ }
32
+ export interface ImageEffectDiagnostics {
33
+ backend: 'waiting' | 'metal' | 'agsl' | 'fallback'
34
+ reason?: 'reduced-motion' | 'unsupported' | 'not-visible'
35
+ /** Native setup error, when available. Intended for developer diagnostics. */
36
+ detail?: string
37
+ frames: number
38
+ textureBytes: number
39
+ peakTextureBytes: number
40
+ preparationMs: number
41
+ longestFrameMs: number
42
+ }
43
+ export interface ImageEffect extends Animation {
44
+ readonly effect: ImageEffectName
45
+ readonly paused: boolean
46
+ /** Finite progress; distortion rejects seeking because it follows native touch input. */
47
+ progress: number
48
+ readonly diagnostics: Readonly<ImageEffectDiagnostics>
49
+ pause(): void
50
+ play(): void
51
+ /** Reverse a finite effect from its current pixels. Terminal handles remain terminal. */
52
+ reverse(): void
53
+ /** Complete immediately at the current direction's endpoint. */
54
+ finish(): void
55
+ }
56
+
57
+ const names: readonly ImageEffectName[] = ['ripple', 'distortion', 'dissolve']
58
+ const surfaceChanges = [
59
+ 'tintColorChange',
60
+ ...['TopLeft', 'TopRight', 'BottomRight', 'BottomLeft'].map(
61
+ (corner) => `border${corner}RadiusChange`,
62
+ ),
63
+ ...['Top', 'Right', 'Bottom', 'Left'].flatMap((edge) => [
64
+ `padding${edge}Change`,
65
+ `border${edge}WidthChange`,
66
+ ]),
67
+ ]
68
+
69
+ /** Optional native image effects. Stop restores the source; dissolve completion commits its destination. */
70
+ export function imageEffect(
71
+ target: Target,
72
+ effect: ImageEffectName,
73
+ options: ImageEffectOptions = {},
74
+ ): ImageEffect {
75
+ const image = toView(target)
76
+ if (!(image instanceof Image))
77
+ throw new TypeError('imageEffect() expects a NativeScript Image.')
78
+ if (!names.includes(effect))
79
+ throw new TypeError(`Unknown image effect: ${String(effect)}.`)
80
+ if (!options || typeof options !== 'object')
81
+ throw new TypeError('Image effect options must be an object.')
82
+ const amplitude = options.amplitude ?? 8,
83
+ radius = options.radius ?? 96
84
+ const duration = options.duration ?? 650,
85
+ seed = options.seed ?? 1
86
+ if (!Number.isFinite(amplitude) || amplitude < 0 || amplitude > 64)
87
+ throw new RangeError('Image displacement must be between 0 and 64 DIPs.')
88
+ if (
89
+ !Number.isFinite(radius) ||
90
+ radius <= 0 ||
91
+ !Number.isFinite(duration) ||
92
+ duration <= 0
93
+ )
94
+ throw new RangeError('Image radius and duration must be positive finite numbers.')
95
+ if (!Number.isInteger(seed) || seed < 0 || seed > 65535)
96
+ throw new RangeError('Image seed must be an integer between 0 and 65535.')
97
+ if (
98
+ options.origin &&
99
+ (!Number.isFinite(options.origin.x) || !Number.isFinite(options.origin.y))
100
+ )
101
+ throw new RangeError('Image origin must contain finite local coordinates.')
102
+ if (options.paused !== undefined && typeof options.paused !== 'boolean')
103
+ throw new TypeError('paused must be a boolean.')
104
+ if (
105
+ effect === 'dissolve' &&
106
+ (!(options.to instanceof ImageSource) ||
107
+ !Number.isFinite(options.to.width) ||
108
+ !Number.isFinite(options.to.height) ||
109
+ options.to.width <= 0 ||
110
+ options.to.height <= 0)
111
+ )
112
+ throw new TypeError('Dissolve requires a loaded destination ImageSource.')
113
+ return new Effect(image, effect, {
114
+ ...options,
115
+ amplitude,
116
+ radius,
117
+ duration: duration / timeScale(),
118
+ seed,
119
+ })
120
+ }
121
+
122
+ class Effect implements ImageEffect {
123
+ readonly finished: Promise<boolean>
124
+ private resolve!: (completed: boolean) => void
125
+ private renderer: ImageRenderer | undefined
126
+ private releaseOwnership: () => void = () => {}
127
+ private terminal = false
128
+ private queued = false
129
+ private committing = false
130
+ private userPaused: boolean
131
+ private suspended = false
132
+ private position = 0
133
+ private direction: 0 | 1 = 1
134
+ private dimensions = ''
135
+ private stats: ImageEffectDiagnostics = {
136
+ backend: 'waiting',
137
+ frames: 0,
138
+ textureBytes: 0,
139
+ peakTextureBytes: 0,
140
+ preparationMs: 0,
141
+ longestFrameMs: 0,
142
+ }
143
+ private reduceObserver:
144
+ ReturnType<typeof Application.ios.addNotificationObserver> | undefined
145
+
146
+ constructor(
147
+ private readonly image: Image,
148
+ readonly effect: ImageEffectName,
149
+ private readonly options: ImageEffectOptions &
150
+ Required<Pick<ImageEffectOptions, 'amplitude' | 'radius' | 'duration' | 'seed'>>,
151
+ ) {
152
+ this.userPaused = options.paused ?? false
153
+ this.finished = new Promise((resolve) => {
154
+ this.resolve = resolve
155
+ })
156
+ this.releaseOwnership = ownImage(image, this.stop)
157
+ for (const event of ['loaded', 'layoutChanged']) image.on(event, this.schedule)
158
+ for (const event of ['imageSourceChange', 'stretchChange'])
159
+ image.on(event, this.changed)
160
+ image.on('unloaded', this.stop)
161
+ image.style.on('visibilityChange', this.visible)
162
+ for (const event of surfaceChanges) image.style.on(event, this.changed)
163
+ Application.on(Application.suspendEvent, this.suspend)
164
+ Application.on(Application.resumeEvent, this.resume)
165
+ if (isIOS)
166
+ this.reduceObserver = Application.ios.addNotificationObserver(
167
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
168
+ this.preferences,
169
+ )
170
+ this.schedule()
171
+ }
172
+ then<TResult1 = boolean, TResult2 = never>(
173
+ fulfilled?: ((value: boolean) => TResult1 | PromiseLike<TResult1>) | null,
174
+ rejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
175
+ ): PromiseLike<TResult1 | TResult2> {
176
+ return this.finished.then(fulfilled, rejected)
177
+ }
178
+ get paused() {
179
+ return this.userPaused
180
+ }
181
+ get progress() {
182
+ return this.renderer?.progress ?? this.position
183
+ }
184
+ set progress(value: number) {
185
+ if (this.effect === 'distortion')
186
+ throw new TypeError('Distortion follows touch input and cannot be scrubbed.')
187
+ if (!Number.isFinite(value) || value < 0 || value > 1)
188
+ throw new RangeError('Image progress must be between 0 and 1.')
189
+ if (this.terminal) return
190
+ this.position = value
191
+ this.renderer?.seek(value)
192
+ }
193
+ get diagnostics(): Readonly<ImageEffectDiagnostics> {
194
+ return {
195
+ ...this.stats,
196
+ ...(this.renderer
197
+ ? {
198
+ frames: this.renderer.frames,
199
+ textureBytes: this.renderer.textureBytes,
200
+ longestFrameMs: this.renderer.longestFrameMs,
201
+ }
202
+ : {}),
203
+ }
204
+ }
205
+ pause = () => {
206
+ if (this.terminal) return
207
+ this.userPaused = true
208
+ this.renderer?.pause()
209
+ }
210
+ play = () => {
211
+ if (this.terminal) return
212
+ this.userPaused = false
213
+ if (this.suspended) return
214
+ if (this.renderer) {
215
+ if (this.effect === 'distortion') this.renderer.resume()
216
+ else this.renderer.play(this.direction)
217
+ } else this.schedule()
218
+ }
219
+ reverse = () => {
220
+ if (this.effect === 'distortion')
221
+ throw new TypeError('Distortion has no finite playback direction.')
222
+ if (this.terminal) return
223
+ this.direction = this.direction === 1 ? 0 : 1
224
+ this.play()
225
+ }
226
+ finish = () => {
227
+ if (this.terminal) return
228
+ if (this.effect === 'distortion') return this.stop()
229
+ this.complete(true, this.direction)
230
+ }
231
+ stop = () => this.complete(false, this.progress)
232
+ private complete = (completed: boolean, position: number, preserveImage = false) => {
233
+ if (this.terminal) return
234
+ this.terminal = true
235
+ this.position = position
236
+ this.stats = { ...this.diagnostics, textureBytes: 0 }
237
+ this.renderer?.stop(preserveImage)
238
+ this.renderer = undefined
239
+ this.releaseOwnership()
240
+ for (const event of ['loaded', 'layoutChanged']) this.image.off(event, this.schedule)
241
+ for (const event of ['imageSourceChange', 'stretchChange'])
242
+ this.image.off(event, this.changed)
243
+ this.image.off('unloaded', this.stop)
244
+ this.image.style.off('visibilityChange', this.visible)
245
+ for (const event of surfaceChanges) this.image.style.off(event, this.changed)
246
+ Application.off(Application.suspendEvent, this.suspend)
247
+ Application.off(Application.resumeEvent, this.resume)
248
+ if (this.reduceObserver)
249
+ Application.ios.removeNotificationObserver(
250
+ this.reduceObserver,
251
+ UIAccessibilityReduceMotionStatusDidChangeNotification,
252
+ )
253
+ this.reduceObserver = undefined
254
+ if (completed && this.effect === 'dissolve' && position === 1) {
255
+ this.committing = true
256
+ // The view may clear its ImageSource on disposal. Keep the caller's
257
+ // destination reusable by giving the view its own wrapper.
258
+ const destination = new ImageSource(this.options.to!.getNativeSource())
259
+ destination.rotationAngle = this.options.to!.rotationAngle
260
+ this.image.imageSource = destination
261
+ this.committing = false
262
+ }
263
+ this.resolve(completed)
264
+ }
265
+ private changed = (event?: EventData) => {
266
+ if (this.committing) return
267
+ if (this.renderer)
268
+ this.complete(false, this.progress, event?.eventName === 'imageSourceChange')
269
+ else this.schedule()
270
+ }
271
+ private suspend = () => {
272
+ this.suspended = true
273
+ this.renderer?.pause()
274
+ }
275
+ private resume = () => {
276
+ this.suspended = false
277
+ this.schedule()
278
+ if (!this.userPaused) this.play()
279
+ }
280
+ private visible = () => {
281
+ if (this.image.visibility !== 'visible') this.stop()
282
+ }
283
+ private preferences = () => {
284
+ if (reducedMotion()) this.fallback('reduced-motion')
285
+ }
286
+ private fallback(reason: ImageEffectDiagnostics['reason']) {
287
+ this.stats = { ...this.stats, backend: 'fallback', reason }
288
+ // Reduced/unsupported rendering still performs a requested content change.
289
+ if (this.effect === 'dissolve' && this.direction === 1) {
290
+ this.complete(true, 1)
291
+ } else this.complete(false, 0)
292
+ }
293
+ private schedule = () => {
294
+ if (this.queued || this.terminal) return
295
+ this.queued = true
296
+ void Promise.resolve().then(() => {
297
+ this.queued = false
298
+ if (this.terminal || this.suspended || !this.image.isLoaded) return
299
+ if (reducedMotion()) return this.fallback('reduced-motion')
300
+ if (this.image.visibility !== 'visible') return this.fallback('not-visible')
301
+ const { width, height } = this.image.getActualSize()
302
+ if (width <= 0 || height <= 0) return
303
+ const dimensions = `${width}:${height}`
304
+ if (this.renderer) {
305
+ if (this.dimensions !== dimensions) this.stop()
306
+ return
307
+ }
308
+ if (!this.image.imageSource) return
309
+ this.dimensions = dimensions
310
+ const started = Date.now()
311
+ try {
312
+ this.renderer = createImageRenderer(
313
+ this.image,
314
+ {
315
+ ...this.options,
316
+ mode: names.indexOf(this.effect),
317
+ x: this.options.origin?.x ?? width / 2,
318
+ y: this.options.origin?.y ?? height / 2,
319
+ },
320
+ this.complete,
321
+ )
322
+ } catch (error) {
323
+ this.stats.preparationMs = Date.now() - started
324
+ this.stats.detail = String(error)
325
+ return this.fallback('unsupported')
326
+ }
327
+ if (!this.renderer) return this.fallback('unsupported')
328
+ this.stats.backend = this.renderer.backend
329
+ this.stats.preparationMs = Date.now() - started
330
+ this.stats.peakTextureBytes = this.renderer.textureBytes
331
+ this.renderer.pause()
332
+ if (this.position) this.renderer.seek(this.position)
333
+ if (!this.userPaused && !this.suspended) this.play()
334
+ })
335
+ }
336
+ }
@@ -0,0 +1,12 @@
1
+ import type { Image } from '@nativescript/core'
2
+
3
+ /** Rendering effects share one image surface; ordinary view transforms stay independent. */
4
+ const owners = new WeakMap<Image, () => void>()
5
+
6
+ export function ownImage(image: Image, stop: () => void): () => void {
7
+ owners.get(image)?.()
8
+ owners.set(image, stop)
9
+ return () => {
10
+ if (owners.get(image) === stop) owners.delete(image)
11
+ }
12
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,15 @@ export { shimmer } from './shimmer.js'
15
15
  export type { ShimmerOptions } from './shimmer.js'
16
16
  export { liquid } from './liquid.js'
17
17
  export type { LiquidOptions, LiquidEffect } from './liquid.js'
18
+ export { imageEffect } from './image-effect.js'
19
+ export type {
20
+ ImageEffect,
21
+ ImageEffectName,
22
+ ImageEffectOptions,
23
+ ImageEffectDiagnostics,
24
+ } from './image-effect.js'
25
+ export { zoomable } from './zoomable.js'
26
+ export type { Zoomable, ZoomableOptions, ZoomState, PhotoDismissal } from './zoomable.js'
18
27
  export { loader } from './loader.js'
19
28
  export type { LoaderOptions, LoaderStyle } from './loader.js'
20
29
  export { reveal, conceal } from './reveal.js'
@@ -0,0 +1,59 @@
1
+ declare class JoltImageEffect extends NSObject {
2
+ stopPreservingImage(): void
3
+ static createWithViewDestinationModeOriginXOriginYAmplitudeRadiusDurationSeedListener(
4
+ image: UIImageView,
5
+ destination: UIImage | null,
6
+ mode: number,
7
+ x: number,
8
+ y: number,
9
+ amplitude: number,
10
+ radius: number,
11
+ duration: number,
12
+ seed: number,
13
+ listener: (completed: boolean, progress: number) => void,
14
+ ): JoltImageEffect | null
15
+ readonly progress: number
16
+ readonly paused: boolean
17
+ readonly frames: number
18
+ readonly longestFrame: number
19
+ readonly textureBytes: number
20
+ play(endpoint: number): void
21
+ pause(): void
22
+ resume(): void
23
+ seek(progress: number): void
24
+ finish(endpoint: number): void
25
+ stop(): void
26
+ }
27
+ declare class JoltPhotoZoom extends NSObject {
28
+ static attachImageViewportMinimumMaximumDoubleTapDismissReducedBackdropChromeOnDismissOnSettled(
29
+ image: UIImageView,
30
+ viewport: UIView,
31
+ minimum: number,
32
+ maximum: number,
33
+ doubleTap: number,
34
+ dismiss: boolean,
35
+ reduced: boolean,
36
+ backdrop: UIView | null,
37
+ chrome: UIView | null,
38
+ onDismiss: (
39
+ x: number,
40
+ y: number,
41
+ scale: number,
42
+ velocity: number,
43
+ progress: number,
44
+ ) => void,
45
+ onSettled: (x: number, y: number, scale: number) => void,
46
+ ): JoltPhotoZoom
47
+ readonly x: number
48
+ readonly y: number
49
+ readonly scale: number
50
+ readonly dismissProgress: number
51
+ readonly dismissPending: boolean
52
+ zoomFocusXFocusYAnimated(scale: number, x: number, y: number, animated: boolean): void
53
+ reset(animated: boolean): void
54
+ requestDismiss(): void
55
+ setReduced(value: boolean): void
56
+ setActive(value: boolean): void
57
+ layout(): void
58
+ stop(): void
59
+ }
package/src/liquid.ts CHANGED
@@ -4,6 +4,7 @@ import { toView } from './animate.js'
4
4
  import { liquidGrid } from './liquid-grid.js'
5
5
  import type { Target } from './types.js'
6
6
  import { reducedMotion } from './config.js'
7
+ import { ownImage } from './image-owner.js'
7
8
 
8
9
  export interface LiquidOptions {
9
10
  /** Amount of internal deformation, from 0 to 1. Defaults to 0.7. */
@@ -50,6 +51,7 @@ export function liquid(target: Target, options: LiquidOptions = {}): LiquidEffec
50
51
  }
51
52
 
52
53
  class Liquid implements LiquidEffect {
54
+ private releaseOwnership: (() => void) | undefined
53
55
  private stopped = false
54
56
  private queued = false
55
57
  private pausedByUser = false
@@ -69,6 +71,7 @@ class Liquid implements LiquidEffect {
69
71
  private readonly image: Image,
70
72
  private readonly settings: Required<LiquidOptions>,
71
73
  ) {
74
+ this.releaseOwnership = ownImage(image, this.stop)
72
75
  running.set(image, this)
73
76
  for (const event of ['loaded', 'layoutChanged', 'imageSourceChange', 'stretchChange'])
74
77
  image.on(event, this.schedule)
@@ -93,6 +96,7 @@ class Liquid implements LiquidEffect {
93
96
  stop = () => {
94
97
  if (this.stopped) return
95
98
  this.stopped = true
99
+ this.releaseOwnership?.()
96
100
  for (const event of ['loaded', 'layoutChanged', 'imageSourceChange', 'stretchChange'])
97
101
  this.image.off(event, this.schedule)
98
102
  this.image.off('unloaded', this.unloaded)