react-native-nitro-image-pipeline 1.6.0 → 1.7.0

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 (78) hide show
  1. package/README.md +88 -13
  2. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/HybridNitroImagePipeline.kt +51 -20
  3. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/PipelineImageLoader.kt +47 -8
  4. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/FitGeometry.kt +104 -0
  5. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/PipelineRoundedCornersTransformation.kt +103 -0
  6. package/android/src/main/java/com/margelo/nitro/nitroimagepipeline/transform/ResizeTransformation.kt +69 -16
  7. package/ios/FitResizeProcessor.swift +127 -0
  8. package/ios/HybridNitroImagePipeline.swift +41 -15
  9. package/ios/PipelineImageLoader.swift +107 -17
  10. package/lib/commonjs/NativePipelineImage.js +18 -3
  11. package/lib/commonjs/NativePipelineImage.js.map +1 -1
  12. package/lib/commonjs/PipelineImage.js +13 -1
  13. package/lib/commonjs/PipelineImage.js.map +1 -1
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/resizeForStyle.js +3 -1
  16. package/lib/commonjs/resizeForStyle.js.map +1 -1
  17. package/lib/commonjs/useImage.js +6 -2
  18. package/lib/commonjs/useImage.js.map +1 -1
  19. package/lib/commonjs/usePipelineImageLoader.js +107 -9
  20. package/lib/commonjs/usePipelineImageLoader.js.map +1 -1
  21. package/lib/module/NativePipelineImage.js +18 -3
  22. package/lib/module/NativePipelineImage.js.map +1 -1
  23. package/lib/module/PipelineImage.js +13 -1
  24. package/lib/module/PipelineImage.js.map +1 -1
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/resizeForStyle.js +3 -1
  27. package/lib/module/resizeForStyle.js.map +1 -1
  28. package/lib/module/useImage.js +6 -2
  29. package/lib/module/useImage.js.map +1 -1
  30. package/lib/module/usePipelineImageLoader.js +108 -10
  31. package/lib/module/usePipelineImageLoader.js.map +1 -1
  32. package/lib/typescript/src/NativePipelineImage.d.ts +44 -3
  33. package/lib/typescript/src/NativePipelineImage.d.ts.map +1 -1
  34. package/lib/typescript/src/PipelineImage.d.ts +28 -1
  35. package/lib/typescript/src/PipelineImage.d.ts.map +1 -1
  36. package/lib/typescript/src/index.d.ts +1 -1
  37. package/lib/typescript/src/index.d.ts.map +1 -1
  38. package/lib/typescript/src/resizeForStyle.d.ts +3 -1
  39. package/lib/typescript/src/resizeForStyle.d.ts.map +1 -1
  40. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts +83 -8
  41. package/lib/typescript/src/specs/nitro-image-toolkit.nitro.d.ts.map +1 -1
  42. package/lib/typescript/src/useImage.d.ts +3 -2
  43. package/lib/typescript/src/useImage.d.ts.map +1 -1
  44. package/lib/typescript/src/usePipelineImageLoader.d.ts +3 -1
  45. package/lib/typescript/src/usePipelineImageLoader.d.ts.map +1 -1
  46. package/nitrogen/generated/android/NitroImagePipelineOnLoad.cpp +4 -0
  47. package/nitrogen/generated/android/c++/JFunc_void_double_double.hpp +75 -0
  48. package/nitrogen/generated/android/c++/JFunc_void_std__string.hpp +76 -0
  49. package/nitrogen/generated/android/c++/JHybridNitroImagePipelineSpec.cpp +8 -0
  50. package/nitrogen/generated/android/c++/JOptions.hpp +2 -0
  51. package/nitrogen/generated/android/c++/JResizeFit.hpp +64 -0
  52. package/nitrogen/generated/android/c++/JResizeOptions.hpp +14 -4
  53. package/nitrogen/generated/android/c++/JViewOptions.hpp +42 -3
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_double_double.kt +78 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/Func_void_std__string.kt +78 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeFit.kt +25 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ResizeOptions.kt +14 -4
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitroimagepipeline/ViewOptions.kt +29 -5
  59. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.cpp +16 -0
  60. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Bridge.hpp +107 -0
  61. package/nitrogen/generated/ios/NitroImagePipeline-Swift-Cxx-Umbrella.hpp +4 -0
  62. package/nitrogen/generated/ios/c++/HybridNitroImagePipelineSpecSwift.hpp +4 -0
  63. package/nitrogen/generated/ios/swift/Func_void_double_double.swift +46 -0
  64. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
  65. package/nitrogen/generated/ios/swift/ResizeFit.swift +48 -0
  66. package/nitrogen/generated/ios/swift/ResizeOptions.swift +31 -2
  67. package/nitrogen/generated/ios/swift/ViewOptions.swift +82 -1
  68. package/nitrogen/generated/shared/c++/ResizeFit.hpp +84 -0
  69. package/nitrogen/generated/shared/c++/ResizeOptions.hpp +14 -4
  70. package/nitrogen/generated/shared/c++/ViewOptions.hpp +24 -3
  71. package/package.json +1 -1
  72. package/src/NativePipelineImage.tsx +67 -3
  73. package/src/PipelineImage.tsx +39 -1
  74. package/src/index.ts +1 -0
  75. package/src/resizeForStyle.ts +3 -1
  76. package/src/specs/nitro-image-toolkit.nitro.ts +84 -8
  77. package/src/useImage.ts +13 -3
  78. package/src/usePipelineImageLoader.ts +115 -8
@@ -34,12 +34,17 @@ namespace margelo::nitro::nitroimagepipeline { enum class CacheOption; }
34
34
  namespace margelo::nitro::nitroimagepipeline { struct CornerRadii; }
35
35
  // Forward declaration of `ResizeOptions` to properly resolve imports.
36
36
  namespace margelo::nitro::nitroimagepipeline { struct ResizeOptions; }
37
+ // Forward declaration of `ResizeFit` to properly resolve imports.
38
+ namespace margelo::nitro::nitroimagepipeline { enum class ResizeFit; }
37
39
 
38
40
  #include <optional>
39
41
  #include "CacheOption.hpp"
40
42
  #include "CornerRadii.hpp"
41
43
  #include <variant>
42
44
  #include "ResizeOptions.hpp"
45
+ #include "ResizeFit.hpp"
46
+ #include <functional>
47
+ #include <string>
43
48
 
44
49
  namespace margelo::nitro::nitroimagepipeline {
45
50
 
@@ -52,13 +57,17 @@ namespace margelo::nitro::nitroimagepipeline {
52
57
  std::optional<CacheOption> cache SWIFT_PRIVATE;
53
58
  std::optional<std::variant<double, CornerRadii>> cornerRadius SWIFT_PRIVATE;
54
59
  std::optional<ResizeOptions> resize SWIFT_PRIVATE;
60
+ std::optional<ResizeFit> fit SWIFT_PRIVATE;
61
+ std::optional<bool> allowUpscale SWIFT_PRIVATE;
62
+ std::optional<std::function<void(double /* width */, double /* height */)>> onLoad SWIFT_PRIVATE;
63
+ std::optional<std::function<void(const std::string& /* message */)>> onError SWIFT_PRIVATE;
55
64
 
56
65
  public:
57
66
  ViewOptions() = default;
58
- explicit ViewOptions(std::optional<double> blur, std::optional<CacheOption> cache, std::optional<std::variant<double, CornerRadii>> cornerRadius, std::optional<ResizeOptions> resize): blur(blur), cache(cache), cornerRadius(cornerRadius), resize(resize) {}
67
+ explicit ViewOptions(std::optional<double> blur, std::optional<CacheOption> cache, std::optional<std::variant<double, CornerRadii>> cornerRadius, std::optional<ResizeOptions> resize, std::optional<ResizeFit> fit, std::optional<bool> allowUpscale, std::optional<std::function<void(double /* width */, double /* height */)>> onLoad, std::optional<std::function<void(const std::string& /* message */)>> onError): blur(blur), cache(cache), cornerRadius(cornerRadius), resize(resize), fit(fit), allowUpscale(allowUpscale), onLoad(onLoad), onError(onError) {}
59
68
 
60
69
  public:
61
- friend bool operator==(const ViewOptions& lhs, const ViewOptions& rhs) = default;
70
+ // ViewOptions is not equatable because these properties are not equatable: onLoad, onError
62
71
  };
63
72
 
64
73
  } // namespace margelo::nitro::nitroimagepipeline
@@ -74,7 +83,11 @@ namespace margelo::nitro {
74
83
  JSIConverter<std::optional<double>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "blur"))),
75
84
  JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::CacheOption>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cache"))),
76
85
  JSIConverter<std::optional<std::variant<double, margelo::nitro::nitroimagepipeline::CornerRadii>>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cornerRadius"))),
77
- JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeOptions>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "resize")))
86
+ JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeOptions>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "resize"))),
87
+ JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeFit>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fit"))),
88
+ JSIConverter<std::optional<bool>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowUpscale"))),
89
+ JSIConverter<std::optional<std::function<void(double, double)>>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "onLoad"))),
90
+ JSIConverter<std::optional<std::function<void(const std::string&)>>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "onError")))
78
91
  );
79
92
  }
80
93
  static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::nitroimagepipeline::ViewOptions& arg) {
@@ -83,6 +96,10 @@ namespace margelo::nitro {
83
96
  obj.setProperty(runtime, PropNameIDCache::get(runtime, "cache"), JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::CacheOption>>::toJSI(runtime, arg.cache));
84
97
  obj.setProperty(runtime, PropNameIDCache::get(runtime, "cornerRadius"), JSIConverter<std::optional<std::variant<double, margelo::nitro::nitroimagepipeline::CornerRadii>>>::toJSI(runtime, arg.cornerRadius));
85
98
  obj.setProperty(runtime, PropNameIDCache::get(runtime, "resize"), JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeOptions>>::toJSI(runtime, arg.resize));
99
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "fit"), JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeFit>>::toJSI(runtime, arg.fit));
100
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "allowUpscale"), JSIConverter<std::optional<bool>>::toJSI(runtime, arg.allowUpscale));
101
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "onLoad"), JSIConverter<std::optional<std::function<void(double, double)>>>::toJSI(runtime, arg.onLoad));
102
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "onError"), JSIConverter<std::optional<std::function<void(const std::string&)>>>::toJSI(runtime, arg.onError));
86
103
  return obj;
87
104
  }
88
105
  static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
@@ -97,6 +114,10 @@ namespace margelo::nitro {
97
114
  if (!JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::CacheOption>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cache")))) return false;
98
115
  if (!JSIConverter<std::optional<std::variant<double, margelo::nitro::nitroimagepipeline::CornerRadii>>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cornerRadius")))) return false;
99
116
  if (!JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeOptions>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "resize")))) return false;
117
+ if (!JSIConverter<std::optional<margelo::nitro::nitroimagepipeline::ResizeFit>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fit")))) return false;
118
+ if (!JSIConverter<std::optional<bool>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "allowUpscale")))) return false;
119
+ if (!JSIConverter<std::optional<std::function<void(double, double)>>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "onLoad")))) return false;
120
+ if (!JSIConverter<std::optional<std::function<void(const std::string&)>>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "onError")))) return false;
100
121
  return true;
101
122
  }
102
123
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-image-pipeline",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "High-performance image loading, caching, and processing for React Native, built with Nitro Modules",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -7,6 +7,7 @@ import { type ImageSource, recyclingKeyFor } from './resolveImageSource';
7
7
  import type {
8
8
  CacheOption,
9
9
  CornerRadii,
10
+ ResizeFit,
10
11
  ResizeOptions,
11
12
  } from './specs/nitro-image-toolkit.nitro';
12
13
  import { usePipelineImageLoader } from './usePipelineImageLoader';
@@ -51,6 +52,46 @@ export interface NativePipelineImageProps extends Omit<
51
52
  * @default undefined (measure the view natively)
52
53
  */
53
54
  resize?: ResizeOptions;
55
+ /**
56
+ * How the source is fitted into the measured view size when the bitmap is
57
+ * produced — see {@linkcode ResizeFit}. When omitted it follows
58
+ * `resizeMode`, like `PipelineImage`. Set this to decouple the two.
59
+ * @default undefined (derived from `resizeMode`, or `'cover'` if unset)
60
+ */
61
+ fit?: ResizeFit;
62
+ /**
63
+ * `false` never enlarges a source smaller than the view when the
64
+ * **bitmap** is produced — see {@linkcode ResizeOptions.allowUpscale}: it
65
+ * stays at its own pixel size, so nothing is decoded or blurred larger
66
+ * than the source. The **view** still displays that bitmap per
67
+ * `resizeMode`, so `cover`/`contain`/`stretch` scale it up on screen (and
68
+ * a baked `blur`/`cornerRadius` with it); `resizeMode="center"` shows it
69
+ * pixel for pixel instead, on both platforms.
70
+ * @default true
71
+ */
72
+ allowUpscale?: boolean;
73
+ /**
74
+ * Called when the view has displayed the image, with the size of the bitmap
75
+ * it displays in **pixels**.
76
+ *
77
+ * Unlike `PipelineImage`'s `onLoad` this doesn't hand you the `Image` — the
78
+ * bitmap stays native, which is the point of this component — and it means
79
+ * "the view is now showing this image" rather than firing exactly once: a
80
+ * recycled cell re-attaching calls it again, and a single view may call it
81
+ * more than once for the same image. Make it idempotent.
82
+ *
83
+ * Setting it means there is per-image JS work again; leave it unset and the
84
+ * component stays free of JS round trips.
85
+ */
86
+ onLoad?: (width: number, height: number) => void;
87
+ /**
88
+ * Called when loading fails, with the underlying error's message. A load
89
+ * cancelled by the view detaching is not a failure and doesn't call this.
90
+ *
91
+ * Without it, failures on this component are silent — there is no `Image`
92
+ * and no promise to surface them.
93
+ */
94
+ onError?: (message: string) => void;
54
95
  }
55
96
 
56
97
  /**
@@ -62,8 +103,9 @@ export interface NativePipelineImageProps extends Omit<
62
103
  * the same pipeline and caches as `useImage`/`preLoadImage`.
63
104
  *
64
105
  * Compared to `PipelineImage`:
65
- * - No `onLoad`/`onError` callbacks the loaded `Image` never crosses into
66
- * JS. Use `PipelineImage` (or `useImage`) when you need them.
106
+ * - `onLoad` reports the bitmap's pixel size rather than the `Image` itself,
107
+ * which never crosses into JS, and can fire more than once for a view. Use
108
+ * `PipelineImage` (or `useImage`) when you need the `Image`.
67
109
  * - The bitmap is loaded once at the size the view first has; if the view is
68
110
  * resized later, the bitmap scales with it instead of reloading.
69
111
  * @example
@@ -79,16 +121,37 @@ export const NativePipelineImage = forwardRef<
79
121
  NativePipelineImageRef,
80
122
  NativePipelineImageProps
81
123
  >(function NativePipelineImage(
82
- { url, blur, cornerRadius, cache, resize, style, ...viewProps },
124
+ {
125
+ url,
126
+ blur,
127
+ cornerRadius,
128
+ cache,
129
+ resize,
130
+ fit,
131
+ allowUpscale,
132
+ onLoad,
133
+ onError,
134
+ style,
135
+ resizeMode,
136
+ ...viewProps
137
+ },
83
138
  ref,
84
139
  ) {
85
140
  // Same precedence as PipelineImage: an explicit prop wins over style.
86
141
  const effectiveCornerRadius = cornerRadius ?? cornerRadiusForStyle(style);
142
+ // Inline arrows are fine here — the hook keeps the callbacks in a ref, so a
143
+ // new identity doesn't recreate the loader (which would reload the image).
87
144
  const loader = usePipelineImageLoader(url, {
88
145
  blur,
89
146
  cornerRadius: effectiveCornerRadius,
90
147
  cache,
91
148
  resize,
149
+ // Same four values as the view's resizeMode; an explicit `fit` decouples
150
+ // them.
151
+ fit: fit ?? resizeMode,
152
+ allowUpscale,
153
+ onLoad,
154
+ onError,
92
155
  });
93
156
 
94
157
  return (
@@ -98,6 +161,7 @@ export const NativePipelineImage = forwardRef<
98
161
  {...viewProps}
99
162
  ref={ref}
100
163
  style={style}
164
+ resizeMode={resizeMode}
101
165
  image={loader}
102
166
  />
103
167
  );
@@ -21,6 +21,7 @@ import type { ImageSource } from './resolveImageSource';
21
21
  import type {
22
22
  CacheOption,
23
23
  CornerRadii,
24
+ ResizeFit,
24
25
  ResizeOptions,
25
26
  } from './specs/nitro-image-toolkit.nitro';
26
27
  import { useImage } from './useImage';
@@ -62,6 +63,33 @@ export interface PipelineImageProps extends Omit<NativeImageProps, 'image'> {
62
63
  */
63
64
  cornerRadius?: number | CornerRadii;
64
65
  cache?: CacheOption;
66
+ /**
67
+ * How the source is fitted into the display size when the bitmap is
68
+ * produced — see {@linkcode ResizeFit}. When omitted it follows
69
+ * `resizeMode`, so `resizeMode="contain"` decodes an aspect-fitted bitmap
70
+ * (no crop, no padding) instead of a center-cropped one the view then
71
+ * letterboxes. Set this to decouple the two.
72
+ *
73
+ * One iOS caveat, inherited from nitro-image's `Image`: an `Image` handed
74
+ * to the view is a scale-1 image, which `resizeMode="center"` — the one
75
+ * mode that draws an image at its own size — shows at 1 pt per pixel,
76
+ * i.e. `PixelRatio.get()`× larger than Android draws it. The bitmap is
77
+ * right; the display is not. `NativePipelineImage` draws `center` pixel
78
+ * for pixel on both platforms; use it when you need that mode.
79
+ * @default undefined (derived from `resizeMode`, or `'cover'` if unset)
80
+ */
81
+ fit?: ResizeFit;
82
+ /**
83
+ * `false` never enlarges a source smaller than the display size when the
84
+ * **bitmap** is produced — see {@linkcode ResizeOptions.allowUpscale}: it
85
+ * stays at its own pixel size, so nothing is decoded or blurred larger
86
+ * than the source. The **view** still displays that bitmap per
87
+ * `resizeMode`, so `cover`/`contain`/`stretch` scale it up on screen (and
88
+ * a baked `blur`/`cornerRadius` with it); `resizeMode="center"` shows it
89
+ * at its natural size instead.
90
+ * @default true
91
+ */
92
+ allowUpscale?: boolean;
65
93
  /** Called with the processed `Image` each time a new variant resolves. */
66
94
  onLoad?: (image: Image) => void;
67
95
  /** Called when loading fails. */
@@ -119,10 +147,13 @@ export const PipelineImage = forwardRef<PipelineImageRef, PipelineImageProps>(
119
147
  blur = 0,
120
148
  cornerRadius,
121
149
  cache,
150
+ fit,
151
+ allowUpscale,
122
152
  onLoad,
123
153
  onError,
124
154
  style,
125
155
  onLayout,
156
+ resizeMode,
126
157
  ...viewProps
127
158
  },
128
159
  ref,
@@ -134,7 +165,13 @@ export const PipelineImage = forwardRef<PipelineImageRef, PipelineImageProps>(
134
165
  );
135
166
  // A numeric style is what the caller declared, so it wins and starts the
136
167
  // request a frame earlier; the measured layout is the fallback.
137
- const resize = styleSize ?? layoutSize;
168
+ const size = styleSize ?? layoutSize;
169
+ // The view's resizeMode and the bitmap's fit are the same four values;
170
+ // an explicit `fit` decouples them.
171
+ const effectiveFit = fit ?? resizeMode;
172
+ const resize: ResizeOptions | undefined = size
173
+ ? { ...size, fit: effectiveFit, allowUpscale }
174
+ : undefined;
138
175
  // Same precedence: an explicit prop wins over what style implies.
139
176
  const effectiveCornerRadius =
140
177
  cornerRadius ?? cornerRadiusForStyle(style) ?? 0;
@@ -176,6 +213,7 @@ export const PipelineImage = forwardRef<PipelineImageRef, PipelineImageProps>(
176
213
  {...viewProps}
177
214
  ref={ref}
178
215
  style={style}
216
+ resizeMode={resizeMode}
179
217
  onLayout={handleLayout}
180
218
  image={image}
181
219
  />
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  CacheOption,
24
24
  CornerRadii,
25
25
  Options,
26
+ ResizeFit,
26
27
  ResizeOptions,
27
28
  ViewOptions,
28
29
  } from './specs/nitro-image-toolkit.nitro';
@@ -13,7 +13,9 @@ import type {
13
13
  /**
14
14
  * Converts a layout size in points (dp) to the pipeline's `resize` option in
15
15
  * whole bitmap pixels using `PixelRatio.getPixelSizeForLayoutSize`. Returns
16
- * `undefined` unless both values are positive numbers.
16
+ * `undefined` unless both values are positive numbers. Spread a `fit` into
17
+ * the result for the other resize modes — under `contain` and `center` the
18
+ * size is the box the bitmap fits in, not necessarily its final size.
17
19
  */
18
20
  export function resizeForLayout(
19
21
  width: unknown,
@@ -15,13 +15,46 @@ export interface CornerRadii {
15
15
  }
16
16
 
17
17
  /**
18
- * Target bitmap size in pixels. The image is scaled to fill this size and
19
- * center-cropped (like CSS `object-fit: cover`), upscaling if needed, so the
20
- * result is exactly `width` × `height` pixels.
18
+ * How the source is fitted into the {@linkcode ResizeOptions} box. Mirrors
19
+ * `NativeNitroImage`'s `resizeMode`, so a view's `resizeMode` and the bitmap
20
+ * it displays can be derived from one value. With a `w` × `h` source and a
21
+ * `W` × `H` box, the produced bitmap is:
22
+ *
23
+ * - `cover`: scale by `max(W/w, H/h)`, center-crop → exactly `W` × `H`
24
+ * (CSS `object-fit: cover`).
25
+ * - `contain`: scale by `min(W/w, H/h)` → `round(w·s)` × `round(h·s)`, the
26
+ * largest size with the source's aspect ratio that fits the box. The
27
+ * bitmap is *not* padded to the box.
28
+ * - `stretch`: scale each axis independently → exactly `W` × `H`, aspect
29
+ * ratio ignored.
30
+ * - `center`: no scaling; center-crop to `min(w, W)` × `min(h, H)`. A source
31
+ * smaller than the box is left untouched.
32
+ */
33
+ export type ResizeFit = 'cover' | 'contain' | 'stretch' | 'center';
34
+
35
+ /**
36
+ * Target bitmap size in pixels. By default the image is scaled to fill this
37
+ * size and center-cropped (like CSS `object-fit: cover`), upscaling if
38
+ * needed, so the result is exactly `width` × `height` pixels; {@linkcode fit}
39
+ * selects the other resize modes.
21
40
  */
22
41
  export interface ResizeOptions {
23
42
  width: number;
24
43
  height: number;
44
+ /**
45
+ * How the source is fitted into `width` × `height` — see
46
+ * {@linkcode ResizeFit} for the size each mode produces.
47
+ * @default 'cover'
48
+ */
49
+ fit?: ResizeFit;
50
+ /**
51
+ * `false` never enlarges the source: the scale is clamped to at most 1 for
52
+ * `cover`, `contain` and `stretch`, so a source smaller than the box keeps
53
+ * its own size (and, under `cover`, is only cropped to the box). No effect
54
+ * on `center`, which never scales.
55
+ * @default true
56
+ */
57
+ allowUpscale?: boolean;
25
58
  }
26
59
 
27
60
  export type Options = {
@@ -55,10 +88,11 @@ export type Options = {
55
88
  */
56
89
  cornerRadius?: number | CornerRadii;
57
90
  /**
58
- * Resize the image to exactly this size in pixels (aspect-fill,
59
- * center-crop) before `blur` and `cornerRadius` are applied. Besides making
60
- * `cornerRadius` predictable, this avoids decoding and processing
61
- * full-resolution bitmaps you only display small.
91
+ * Resize the image into this box in pixels aspect-fill + center-crop by
92
+ * default, or another {@linkcode ResizeFit} — before `blur` and
93
+ * `cornerRadius` are applied. Besides making `cornerRadius` predictable,
94
+ * this avoids decoding and processing full-resolution bitmaps you only
95
+ * display small.
62
96
  *
63
97
  * @default undefined (keep the source size)
64
98
  */
@@ -90,10 +124,52 @@ export type ViewOptions = {
90
124
  /**
91
125
  * Target bitmap size in **pixels**, overriding the size measured from the
92
126
  * view. Rarely needed — without it the loader resizes to the view's
93
- * laid-out size × screen scale, which is what you want in a UI.
127
+ * laid-out size × screen scale, which is what you want in a UI. Its own
128
+ * `fit`/`allowUpscale`, when set, win over the top-level ones below.
94
129
  * @default undefined (measure the view)
95
130
  */
96
131
  resize?: ResizeOptions;
132
+ /**
133
+ * How the source is fitted into the box the loader resizes to (the view's
134
+ * size, or {@linkcode resize}) — see {@linkcode ResizeFit}. Pass the view's
135
+ * `resizeMode` so the bitmap matches how the view displays it.
136
+ * @default 'cover'
137
+ */
138
+ fit?: ResizeFit;
139
+ /**
140
+ * `false` never enlarges the source — see
141
+ * {@linkcode ResizeOptions.allowUpscale}.
142
+ * @default true
143
+ */
144
+ allowUpscale?: boolean;
145
+ /**
146
+ * Called when a view finishes displaying the image, with the size of the
147
+ * bitmap it displays in **pixels**.
148
+ *
149
+ * The bitmap itself deliberately stays native — passing the `Image` into JS
150
+ * would keep it alive there and defeat the point of the native path. Use
151
+ * `useImage`/`loadImage` when you need the `Image` object.
152
+ *
153
+ * This reports "the view is now showing this image", not a one-shot event:
154
+ * a loader is per URL + options, not per view, so every view using it
155
+ * reports, a recycled cell reports again when it re-attaches, and a single
156
+ * view can report more than once (it requests the image both when its
157
+ * `image` prop is set and when it becomes visible). Memory-cache hits, which
158
+ * resolve synchronously, report too. It does not fire for imperative loads
159
+ * through `ImageLoader.loadImage()` — those resolve through their promise.
160
+ *
161
+ * Setting it means there *is* per-image JS work; leave it unset and the
162
+ * native path stays free of JS round trips entirely.
163
+ * @default undefined (nothing crosses into JS)
164
+ */
165
+ onLoad?: (width: number, height: number) => void;
166
+ /**
167
+ * Called when a view fails to load the image, with the underlying error's
168
+ * message. Cancelling a load (the view detaching before it finishes) is not
169
+ * a failure and does not call this.
170
+ * @default undefined (failures are silent)
171
+ */
172
+ onError?: (message: string) => void;
97
173
  };
98
174
 
99
175
  export interface NitroImagePipeline extends HybridObject<{
package/src/useImage.ts CHANGED
@@ -62,8 +62,9 @@ export function useImage({
62
62
  */
63
63
  cornerRadius?: number | CornerRadii;
64
64
  /**
65
- * Resize the bitmap to exactly this size in pixels (aspect-fill,
66
- * center-crop) before blur/rounding. Typically your display size in points
65
+ * Resize the bitmap into this box in pixels before blur/rounding —
66
+ * aspect-fill + center-crop by default, or another `fit` (see
67
+ * {@linkcode ResizeOptions}). Typically your display size in points
67
68
  * multiplied by `PixelRatio.get()`. Inline object literals are fine.
68
69
  */
69
70
  resize?: ResizeOptions;
@@ -95,6 +96,8 @@ export function useImage({
95
96
  } = isUniformRadius ? {} : cornerRadius;
96
97
  const resizeWidth = resize?.width ?? 0;
97
98
  const resizeHeight = resize?.height ?? 0;
99
+ const resizeFit = resize?.fit;
100
+ const resizeAllowUpscale = resize?.allowUpscale;
98
101
 
99
102
  useEffect(() => {
100
103
  let cancelled = false;
@@ -120,7 +123,12 @@ export function useImage({
120
123
  : { topLeft, topRight, bottomLeft, bottomRight },
121
124
  resize:
122
125
  resizeWidth > 0 && resizeHeight > 0
123
- ? { width: resizeWidth, height: resizeHeight }
126
+ ? {
127
+ width: resizeWidth,
128
+ height: resizeHeight,
129
+ fit: resizeFit,
130
+ allowUpscale: resizeAllowUpscale,
131
+ }
124
132
  : undefined,
125
133
  cache,
126
134
  },
@@ -152,6 +160,8 @@ export function useImage({
152
160
  bottomRight,
153
161
  resizeWidth,
154
162
  resizeHeight,
163
+ resizeFit,
164
+ resizeAllowUpscale,
155
165
  cache,
156
166
  enabled,
157
167
  ]);
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react';
1
+ import { type RefObject, useLayoutEffect, useMemo, useRef } from 'react';
2
2
  import type { ImageLoader } from 'react-native-nitro-image';
3
3
 
4
4
  import { NitroImagePipeline } from './NitroImagePipeline';
@@ -11,6 +11,38 @@ import type {
11
11
  ViewOptions,
12
12
  } from './specs/nitro-image-toolkit.nitro';
13
13
 
14
+ // Identifies a loader in the callbacks below without them capturing it. A
15
+ // callback the native loader owns that captured the loader back would be a
16
+ // cycle across the language boundary — neither side collectable, and the JS
17
+ // runtime left to take it apart at teardown.
18
+ let nextLoaderToken = 0;
19
+
20
+ type Callbacks = Pick<ViewOptions, 'onLoad' | 'onError'>;
21
+
22
+ /**
23
+ * The callbacks handed to a native loader. Built out here, where the scope
24
+ * holds nothing but the token and the two refs, so the loader can't end up
25
+ * captured by a callback it owns.
26
+ */
27
+ function makeReporters(
28
+ token: number,
29
+ activeToken: RefObject<number>,
30
+ callbacks: RefObject<Callbacks>,
31
+ ): Required<Callbacks> {
32
+ return {
33
+ onLoad: (width, height) => {
34
+ if (activeToken.current === token) {
35
+ callbacks.current.onLoad?.(width, height);
36
+ }
37
+ },
38
+ onError: (message) => {
39
+ if (activeToken.current === token) {
40
+ callbacks.current.onError?.(message);
41
+ }
42
+ },
43
+ };
44
+ }
45
+
14
46
  /**
15
47
  * Creates (and memoizes) an {@linkcode ImageLoader} for `source` — a URL
16
48
  * string or a `require()`d asset, see {@linkcode ImageSource} — to pass to
@@ -21,7 +53,9 @@ import type {
21
53
  *
22
54
  * `blur`/`cornerRadius` are in **points** (unlike `useImage`, where they are
23
55
  * bitmap pixels); the screen scale is applied natively. Inline object
24
- * literals are fine — options are compared by value, not identity.
56
+ * literals are fine — options are compared by value, not identity, and the
57
+ * `onLoad`/`onError` callbacks are read through a ref, so inline arrows don't
58
+ * recreate the loader either.
25
59
  */
26
60
  export function usePipelineImageLoader(
27
61
  source: ImageSource,
@@ -44,8 +78,37 @@ export function usePipelineImageLoader(
44
78
  } = isUniformRadius || cornerRadius === undefined ? {} : cornerRadius;
45
79
  const resizeWidth = options?.resize?.width;
46
80
  const resizeHeight = options?.resize?.height;
81
+ const resizeFit = options?.resize?.fit;
82
+ const resizeAllowUpscale = options?.resize?.allowUpscale;
83
+ const fit = options?.fit;
84
+ const allowUpscale = options?.allowUpscale;
85
+ const onLoad = options?.onLoad;
86
+ const onError = options?.onError;
87
+ // Only *whether* a callback is set can change the loader: what the native
88
+ // side gets is a wrapper that reads the latest callbacks from a ref (like
89
+ // PipelineImage does). So an inline arrow — a new identity every render —
90
+ // doesn't recreate the loader and re-trigger the native load.
91
+ const hasOnLoad = onLoad !== undefined;
92
+ const hasOnError = onError !== undefined;
93
+ const callbacks = useRef({ onLoad, onError });
94
+ // A layout effect, not a passive one: passive effects are flushed in a later
95
+ // task, so a load reported between the commit and that flush would read the
96
+ // previous render's callbacks — a callback added alongside a new loader
97
+ // could miss its only event. This runs in the same task as the commit, and
98
+ // the view can't reach the loader before the commit installs it, so the ref
99
+ // is always current by the time native calls back. (Assigning it during
100
+ // render instead would mutate on renders React throws away.)
101
+ useLayoutEffect(() => {
102
+ callbacks.current = { onLoad, onError };
103
+ });
104
+ // The token of the loader the view is currently on. Swapping loaders (a
105
+ // changed url or options) never drops the old one — the view just stops
106
+ // referring to it — so a load it already had in flight can still report.
107
+ // Without this check the wrappers, which read the latest callbacks, would
108
+ // hand the previous image's size to the current `onLoad`.
109
+ const activeToken = useRef(-1);
47
110
 
48
- return useMemo(() => {
111
+ const memoized = useMemo(() => {
49
112
  // Runs during render, so an unregistered `require()` id must not throw:
50
113
  // it becomes a URL the native loader fails on at load time instead.
51
114
  const url = resolveImageUrlOrFallback(source);
@@ -54,25 +117,52 @@ export function usePipelineImageLoader(
54
117
  : hasCornerObject
55
118
  ? { topLeft, topRight, bottomLeft, bottomRight }
56
119
  : undefined;
120
+ // What is loaded — the callbacks are deliberately not part of it, so two
121
+ // loaders that differ only in them are the same image.
57
122
  const stableOptions: ViewOptions = {
58
123
  blur,
59
124
  cache,
60
125
  cornerRadius: cornerRadiusOption,
61
126
  resize:
62
127
  resizeWidth !== undefined && resizeHeight !== undefined
63
- ? { width: resizeWidth, height: resizeHeight }
128
+ ? {
129
+ width: resizeWidth,
130
+ height: resizeHeight,
131
+ fit: resizeFit,
132
+ allowUpscale: resizeAllowUpscale,
133
+ }
64
134
  : undefined,
135
+ fit,
136
+ allowUpscale,
65
137
  };
66
- const loader = NitroImagePipeline.createImageLoader(url, stableOptions);
138
+ const token = nextLoaderToken++;
139
+ // The reporters read the refs, but only when the native side calls them —
140
+ // the rule can't see that they aren't called here.
141
+ // oxlint-disable-next-line react/refs
142
+ const reporters = makeReporters(token, activeToken, callbacks);
143
+ const loader = NitroImagePipeline.createImageLoader(url, {
144
+ ...stableOptions,
145
+ // Passed only when the caller wants them: without a callback nothing
146
+ // ever crosses into JS, which is the point of the native path.
147
+ onLoad: hasOnLoad ? reporters.onLoad : undefined,
148
+ onError: hasOnError ? reporters.onError : undefined,
149
+ });
67
150
  // `NativeNitroImage` needs a way to tell two loader instances apart when
68
151
  // diffing its `image` prop; tag the loader with what it will load (the
69
- // same convention react-native-nitro-image's own loaders use).
152
+ // same convention react-native-nitro-image's own loaders use). Toggling a
153
+ // callback on or off makes a different loader, so the tag has to say so —
154
+ // otherwise the view keeps the old one installed. Their identities stay
155
+ // out of it: those don't change what is loaded, or which loader this is.
70
156
  Object.defineProperty(loader, '__source', {
71
157
  enumerable: true,
72
158
  configurable: true,
73
- value: { url, options: stableOptions },
159
+ value: {
160
+ url,
161
+ options: stableOptions,
162
+ callbacks: { onLoad: hasOnLoad, onError: hasOnError },
163
+ },
74
164
  });
75
- return loader;
165
+ return { loader, token };
76
166
  }, [
77
167
  source,
78
168
  blur,
@@ -86,5 +176,22 @@ export function usePipelineImageLoader(
86
176
  bottomRight,
87
177
  resizeWidth,
88
178
  resizeHeight,
179
+ resizeFit,
180
+ resizeAllowUpscale,
181
+ fit,
182
+ allowUpscale,
183
+ hasOnLoad,
184
+ hasOnError,
185
+ // Stable ref objects; listed to satisfy the exhaustive-deps rules.
186
+ activeToken,
187
+ callbacks,
89
188
  ]);
189
+
190
+ // Same reasoning as the callbacks ref: a layout effect is current before the
191
+ // view — which the commit has only just handed this loader — can report.
192
+ useLayoutEffect(() => {
193
+ activeToken.current = memoized.token;
194
+ }, [memoized, activeToken]);
195
+
196
+ return memoized.loader;
90
197
  }