react-pro-image 1.0.1 → 1.1.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.
package/README.md
CHANGED
|
@@ -1,55 +1,93 @@
|
|
|
1
1
|
<div align="center">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
<img
|
|
3
|
+
src="https://mohamedalfeky1.github.io/react-pro-image/img/logo.png"
|
|
4
|
+
alt="React Pro Image logo"
|
|
5
|
+
width="96"
|
|
6
|
+
height="96"
|
|
7
|
+
/>
|
|
8
|
+
|
|
9
|
+
<h1>react-pro-image</h1>
|
|
10
|
+
|
|
11
|
+
<p>
|
|
12
|
+
A production-ready React image component for lazy loading, AVIF/WebP format
|
|
13
|
+
negotiation, progressive placeholders, and graceful error fallbacks.
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p>
|
|
17
|
+
<a href="https://www.npmjs.com/package/react-pro-image">
|
|
18
|
+
<img src="https://img.shields.io/npm/v/react-pro-image.svg?style=flat-square&color=cb3837" alt="npm version" />
|
|
19
|
+
</a>
|
|
20
|
+
<a href="https://bundlephobia.com/package/react-pro-image">
|
|
21
|
+
<img src="https://img.shields.io/bundlephobia/minzip/react-pro-image?style=flat-square&color=44cc11" alt="bundle size" />
|
|
22
|
+
</a>
|
|
23
|
+
<a href="https://github.com/MohamedAlfeky1/react-pro-image/blob/main/LICENSE">
|
|
24
|
+
<img src="https://img.shields.io/npm/l/react-pro-image?style=flat-square&color=blue" alt="license" />
|
|
25
|
+
</a>
|
|
26
|
+
<a href="https://www.typescriptlang.org/">
|
|
27
|
+
<img src="https://img.shields.io/badge/TypeScript-Ready-3178C6?style=flat-square&logo=typescript&logoColor=white" alt="TypeScript ready" />
|
|
28
|
+
</a>
|
|
29
|
+
</p>
|
|
12
30
|
</div>
|
|
13
31
|
|
|
14
|
-
|
|
32
|
+
## Introduction
|
|
15
33
|
|
|
16
|
-
|
|
34
|
+
`react-pro-image` gives you one `<OptimizedImage />` component for the image
|
|
35
|
+
loading work most React apps repeat by hand.
|
|
17
36
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
| 🎨 **AVIF / WebP Negotiation** | Automatically detects browser support and serves the smallest modern format. Results are cached in `localStorage`. |
|
|
22
|
-
| 🌄 **Placeholder Crossfade** | Show a low-res or blurred placeholder that smoothly fades out once the full image loads. |
|
|
23
|
-
| 💥 **Error Fallback** | Gracefully display a fallback image if the primary source fails to load. |
|
|
24
|
-
| 🔗 **CDN Auto-Format** | Works with Unsplash, Imgix, Cloudinary, and any CDN that accepts a format query parameter. |
|
|
25
|
-
| 📦 **Tree-Shakeable** | ESM + CJS dual builds. Import only what you use. |
|
|
26
|
-
| 🔒 **Fully Typed** | Written in TypeScript with strict, exported types for every prop and hook. |
|
|
27
|
-
| ⚡ **Zero Dependencies** | Only `react` (≥ 17) as a peer dependency. |
|
|
37
|
+
It can delay off-screen image downloads, choose AVIF or WebP when the browser
|
|
38
|
+
supports them, show a lightweight placeholder while the full image loads, and
|
|
39
|
+
render a fallback image when the primary source fails.
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
## 📦 Installation
|
|
41
|
+
## Installation
|
|
32
42
|
|
|
33
43
|
```bash
|
|
34
|
-
# npm
|
|
35
44
|
npm install react-pro-image
|
|
45
|
+
```
|
|
36
46
|
|
|
37
|
-
|
|
47
|
+
```bash
|
|
38
48
|
yarn add react-pro-image
|
|
49
|
+
```
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
```bash
|
|
41
52
|
pnpm add react-pro-image
|
|
42
53
|
```
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
React `>=17.0.0` is required as a peer dependency.
|
|
45
56
|
|
|
46
|
-
|
|
57
|
+
## Documentation
|
|
58
|
+
|
|
59
|
+
You can find the React Pro Image documentation [on this website](https://mohamedalfeky1.github.io/react-pro-image/).
|
|
60
|
+
|
|
61
|
+
Check out the [Quick Start](https://mohamedalfeky1.github.io/react-pro-image/getting-started/quick-start) page for a quick overview.
|
|
62
|
+
|
|
63
|
+
The documentation is divided into several sections:
|
|
64
|
+
|
|
65
|
+
- [Overview](https://mohamedalfeky1.github.io/react-pro-image/)
|
|
66
|
+
- [Installation](https://mohamedalfeky1.github.io/react-pro-image/getting-started/installation)
|
|
67
|
+
- [Quick Start](https://mohamedalfeky1.github.io/react-pro-image/getting-started/quick-start)
|
|
68
|
+
- [CDN Images](https://mohamedalfeky1.github.io/react-pro-image/usage-modes/cdn-images)
|
|
69
|
+
- [Self-Hosted Images](https://mohamedalfeky1.github.io/react-pro-image/usage-modes/self-hosted-images)
|
|
70
|
+
- [Progressive Loading](https://mohamedalfeky1.github.io/react-pro-image/loading-reliability/progressive-loading)
|
|
71
|
+
- [Lazy Loading](https://mohamedalfeky1.github.io/react-pro-image/loading-reliability/lazy-loading)
|
|
72
|
+
- [Error Fallbacks](https://mohamedalfeky1.github.io/react-pro-image/loading-reliability/error-fallbacks)
|
|
73
|
+
- [Architecture](https://mohamedalfeky1.github.io/react-pro-image/technical-details/architecture)
|
|
74
|
+
- [AVIF and WebP Negotiation](https://mohamedalfeky1.github.io/react-pro-image/technical-details/avif-webp-negotiation)
|
|
75
|
+
- [Browser Support](https://mohamedalfeky1.github.io/react-pro-image/technical-details/browser-support)
|
|
76
|
+
- [Props Reference](https://mohamedalfeky1.github.io/react-pro-image/api-reference/props-reference)
|
|
77
|
+
- [AutoFormatConfig](https://mohamedalfeky1.github.io/react-pro-image/api-reference/auto-format-config)
|
|
78
|
+
- [Hooks](https://mohamedalfeky1.github.io/react-pro-image/api-reference/hooks)
|
|
79
|
+
- [Exported Types](https://mohamedalfeky1.github.io/react-pro-image/api-reference/exported-types)
|
|
80
|
+
- [Where to Get Support](https://github.com/MohamedAlfeky1/react-pro-image/issues)
|
|
81
|
+
- [Contributing](https://github.com/MohamedAlfeky1/react-pro-image/pulls)
|
|
82
|
+
|
|
83
|
+
You can improve it by sending pull requests to this repository.
|
|
47
84
|
|
|
48
|
-
##
|
|
85
|
+
## Usage Examples
|
|
49
86
|
|
|
50
|
-
|
|
87
|
+
### CDN Images
|
|
51
88
|
|
|
52
|
-
|
|
89
|
+
Use this mode when your images are served from a CDN that accepts a format query
|
|
90
|
+
parameter, such as `fm`, `f`, or `format`.
|
|
53
91
|
|
|
54
92
|
```tsx
|
|
55
93
|
import { OptimizedImage } from "react-pro-image";
|
|
@@ -57,10 +95,11 @@ import { OptimizedImage } from "react-pro-image";
|
|
|
57
95
|
function Hero() {
|
|
58
96
|
return (
|
|
59
97
|
<OptimizedImage
|
|
60
|
-
autoSrc="https://images.unsplash.com/photo-
|
|
98
|
+
autoSrc="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800"
|
|
61
99
|
autoFormat={{ formatKey: "fm", formats: ["avif", "webp"] }}
|
|
62
|
-
autoPlaceholder="https://images.unsplash.com/photo-
|
|
63
|
-
|
|
100
|
+
autoPlaceholder="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=20&blur=10"
|
|
101
|
+
autoFallback="https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=800"
|
|
102
|
+
alt="Sunlit mountain valley with golden light"
|
|
64
103
|
width={800}
|
|
65
104
|
height={400}
|
|
66
105
|
/>
|
|
@@ -68,324 +107,33 @@ function Hero() {
|
|
|
68
107
|
}
|
|
69
108
|
```
|
|
70
109
|
|
|
71
|
-
### 💡 What do these props mean?
|
|
72
|
-
|
|
73
|
-
| Prop | Simple Explanation |
|
|
74
|
-
| --- | --- |
|
|
75
|
-
| `autoSrc` | The main link to your image on the CDN. The component will automatically add the format parameter to the end of this link. |
|
|
76
|
-
| `autoFormat` | Tells the component how your CDN expects the format request. For example, Unsplash uses `fm` (so it becomes `&fm=avif`). We also tell it to try `"avif"` first, then `"webp"`. |
|
|
77
|
-
| `autoPlaceholder` | A link to a very tiny, blurry version of the same image. This loads instantly and looks nice while the user waits for the big image to download. It smoothly fades out when the real image is ready. |
|
|
78
|
-
| `alt` | Text that describes the image. Important for accessibility (screen readers) and SEO. |
|
|
79
|
-
| `width` / `height` | The size of the image container in pixels. |
|
|
80
|
-
|
|
81
|
-
---
|
|
82
|
-
|
|
83
|
-
## 📖 More Ways to Use
|
|
84
|
-
|
|
85
|
-
### 1. Manual Sources (if you host the images yourself)
|
|
86
|
-
|
|
87
|
-
If you aren't using a CDN and instead have your images saved in your project (like in a `public` folder), you can pass each format manually.
|
|
88
|
-
|
|
89
|
-
The component will automatically check the browser's capabilities and pick the best one:
|
|
90
|
-
|
|
91
|
-
```tsx
|
|
92
|
-
<OptimizedImage
|
|
93
|
-
src="/photo.jpg"
|
|
94
|
-
avifSrc="/photo.avif"
|
|
95
|
-
webpSrc="/photo.webp"
|
|
96
|
-
placeholder="/photo-tiny.jpg"
|
|
97
|
-
fallback="/photo-fallback.jpg"
|
|
98
|
-
alt="A beautiful scene"
|
|
99
|
-
width={800}
|
|
100
|
-
height={400}
|
|
101
|
-
/>
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
**How the component chooses the best image:**
|
|
105
|
-
1. Does the browser support **AVIF** and did you provide `avifSrc`? -> It uses **AVIF**.
|
|
106
|
-
2. Does the browser support **WebP** and did you provide `webpSrc`? -> It uses **WebP**.
|
|
107
|
-
3. Otherwise? -> It falls back to the standard `src` (JPEG/PNG).
|
|
108
|
-
|
|
109
|
-
*(You only need to provide the formats you have — `avifSrc` and `webpSrc` are completely optional).*
|
|
110
|
-
|
|
111
110
|
---
|
|
112
111
|
|
|
113
|
-
###
|
|
114
|
-
|
|
115
|
-
By default, all images are "lazy-loaded". This means they won't even start downloading until the user scrolls down and the image enters the screen. This saves a lot of data!
|
|
112
|
+
### Self-Hosted Images
|
|
116
113
|
|
|
117
|
-
|
|
114
|
+
Use this mode when you host your own JPEG, PNG, AVIF, or WebP files. This
|
|
115
|
+
advanced example provides modern formats, a placeholder, and an error fallback.
|
|
118
116
|
|
|
119
117
|
```tsx
|
|
120
|
-
|
|
121
|
-
src="/hero.jpg"
|
|
122
|
-
alt="Above the fold hero"
|
|
123
|
-
lazy={false}
|
|
124
|
-
width={1920}
|
|
125
|
-
height={800}
|
|
126
|
-
/>
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
---
|
|
130
|
-
|
|
131
|
-
### 3. Adjusting the Viewport Trigger (When to start loading)
|
|
132
|
-
|
|
133
|
-
You can control exactly *when* the lazy loading starts using `threshold` and `rootMargin`:
|
|
134
|
-
|
|
135
|
-
```tsx
|
|
136
|
-
<OptimizedImage
|
|
137
|
-
src="/gallery-item.jpg"
|
|
138
|
-
alt="Gallery item"
|
|
139
|
-
threshold={0.1}
|
|
140
|
-
rootMargin="200px"
|
|
141
|
-
width={400}
|
|
142
|
-
height={300}
|
|
143
|
-
/>
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
- **`threshold={0.1}`**: Start loading when just **10%** of the image area becomes visible on screen. (The default is `0.25` or 25%).
|
|
147
|
-
- **`rootMargin="200px"`**: Start loading **200 pixels before** the image even reaches the screen. This is great for making sure images are already downloaded by the time the user scrolls to them!
|
|
148
|
-
|
|
149
|
-
---
|
|
150
|
-
|
|
151
|
-
## 📚 API Reference
|
|
152
|
-
|
|
153
|
-
### `<OptimizedImage />` — Props
|
|
154
|
-
|
|
155
|
-
#### Source Props
|
|
156
|
-
|
|
157
|
-
> You must provide **either** `src` **or** `autoSrc` — never both.
|
|
158
|
-
|
|
159
|
-
| Prop | Type | Default | Description |
|
|
160
|
-
| ------------ | ------------------ | ------- | ------------------------------------------------------------------------------------------------------------- |
|
|
161
|
-
| `src` | `string` | — | Standard image URL (JPEG, PNG, etc.). **Required** if `autoSrc` is not used. |
|
|
162
|
-
| `autoSrc` | `string` | — | CDN image URL. The component appends the format query param automatically. **Required** if `src` is not used. |
|
|
163
|
-
| `autoFormat` | `AutoFormatConfig` | — | Format negotiation config. **Required** when using `autoSrc`. |
|
|
164
|
-
| `avifSrc` | `string` | — | Optional AVIF source URL. Served if the browser supports AVIF. |
|
|
165
|
-
| `webpSrc` | `string` | — | Optional WebP source URL. Served if the browser supports WebP. |
|
|
166
|
-
|
|
167
|
-
#### Placeholder Props
|
|
168
|
-
|
|
169
|
-
> You may provide **either** `placeholder` **or** `autoPlaceholder` — never both.
|
|
170
|
-
|
|
171
|
-
| Prop | Type | Default | Description |
|
|
172
|
-
| ----------------- | -------- | ------- | ----------------------------------------------------------------------------------- |
|
|
173
|
-
| `placeholder` | `string` | — | URL of a low-res or blurred placeholder image. Fades out once the full image loads. |
|
|
174
|
-
| `autoPlaceholder` | `string` | — | CDN-generated placeholder URL. |
|
|
175
|
-
|
|
176
|
-
#### Fallback Props
|
|
177
|
-
|
|
178
|
-
> You may provide **either** `fallback` **or** `autoFallback` — never both.
|
|
179
|
-
|
|
180
|
-
| Prop | Type | Default | Description |
|
|
181
|
-
| -------------- | -------- | ------- | -------------------------------------------------------------------------- |
|
|
182
|
-
| `fallback` | `string` | — | Image URL displayed if the primary source fails to load. |
|
|
183
|
-
| `autoFallback` | `string` | — | CDN fallback URL. Format param is appended automatically via `autoFormat`. |
|
|
184
|
-
| `avifFallback` | `string` | — | AVIF override for the fallback image. |
|
|
185
|
-
| `webpFallback` | `string` | — | WebP override for the fallback image. |
|
|
186
|
-
|
|
187
|
-
#### Layout and Behavior Props
|
|
188
|
-
|
|
189
|
-
| Prop | Type | Default | Description |
|
|
190
|
-
| ------------ | --------- | ------- | ---------------------------------------------------------- |
|
|
191
|
-
| `alt` | `string` | — | Accessible alt text for the image. |
|
|
192
|
-
| `width` | `number` | — | Display width of the container in pixels. |
|
|
193
|
-
| `height` | `number` | — | Display height of the container in pixels. |
|
|
194
|
-
| `className` | `string` | — | CSS class names applied to the outer wrapper div. |
|
|
195
|
-
| `lazy` | `boolean` | `true` | Enable or disable lazy loading via IntersectionObserver. |
|
|
196
|
-
| `threshold` | `number` | `0.25` | Visibility ratio (0 to 1) required to trigger loading. |
|
|
197
|
-
| `rootMargin` | `string` | `"0px"` | CSS-style margin to expand or shrink the observation area. |
|
|
198
|
-
|
|
199
|
-
> The component also spreads any additional `HTMLDivElement` attributes onto the outer wrapper.
|
|
200
|
-
|
|
201
|
-
---
|
|
202
|
-
|
|
203
|
-
### AutoFormatConfig
|
|
204
|
-
|
|
205
|
-
Configuration object for CDN format negotiation.
|
|
206
|
-
|
|
207
|
-
```ts
|
|
208
|
-
interface AutoFormatConfig {
|
|
209
|
-
formatKey: string;
|
|
210
|
-
formats: ("avif" | "webp")[];
|
|
211
|
-
}
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
- **formatKey** — The query parameter key used by the CDN (e.g. `"fm"`, `"f"`, `"format"`).
|
|
215
|
-
- **formats** — Ordered list of modern formats to try, from most preferred to least (e.g. `["avif", "webp"]`).
|
|
216
|
-
|
|
217
|
-
**CDN examples:**
|
|
218
|
-
|
|
219
|
-
| CDN | formatKey | Example URL |
|
|
220
|
-
| ---------------- | ---------- | ----------------- |
|
|
221
|
-
| Unsplash / Imgix | `"fm"` | `...?fm=avif` |
|
|
222
|
-
| Cloudinary | `"f"` | `...&f=webp` |
|
|
223
|
-
| Custom | `"format"` | `...?format=avif` |
|
|
224
|
-
|
|
225
|
-
---
|
|
226
|
-
|
|
227
|
-
## 🪝 Hooks
|
|
228
|
-
|
|
229
|
-
The package exports three composable hooks you can use independently in custom components.
|
|
230
|
-
|
|
231
|
-
### useImageFormatSupport()
|
|
232
|
-
|
|
233
|
-
Detects AVIF and WebP support by loading tiny test images. Results are cached in `localStorage` so detection runs only once per browser.
|
|
234
|
-
|
|
235
|
-
```tsx
|
|
236
|
-
import { useImageFormatSupport } from "react-pro-image";
|
|
237
|
-
|
|
238
|
-
function MyComponent() {
|
|
239
|
-
const { avif, webp, ready } = useImageFormatSupport();
|
|
240
|
-
|
|
241
|
-
if (!ready) return <p>Checking format support...</p>;
|
|
118
|
+
import { OptimizedImage } from "react-pro-image";
|
|
242
119
|
|
|
120
|
+
function GalleryImage() {
|
|
243
121
|
return (
|
|
244
|
-
<
|
|
245
|
-
src=
|
|
246
|
-
|
|
122
|
+
<OptimizedImage
|
|
123
|
+
src="/photo.jpg"
|
|
124
|
+
avifSrc="/photo.avif"
|
|
125
|
+
webpSrc="/photo.webp"
|
|
126
|
+
placeholder="/photo-tiny.jpg"
|
|
127
|
+
fallback="/photo-fallback.jpg"
|
|
128
|
+
alt="A scenic landscape"
|
|
129
|
+
width={800}
|
|
130
|
+
height={400}
|
|
247
131
|
/>
|
|
248
132
|
);
|
|
249
133
|
}
|
|
250
134
|
```
|
|
251
135
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
| Property | Type | Description |
|
|
255
|
-
| -------- | --------- | -------------------------------------- |
|
|
256
|
-
| `avif` | `boolean` | `true` if the browser can decode AVIF. |
|
|
257
|
-
| `webp` | `boolean` | `true` if the browser can decode WebP. |
|
|
258
|
-
| `ready` | `boolean` | `true` once detection is complete. |
|
|
259
|
-
|
|
260
|
-
---
|
|
261
|
-
|
|
262
|
-
### useImageLoader(options)
|
|
263
|
-
|
|
264
|
-
Preloads an image off-screen and tracks its load state. Loading is deferred until `isInView` is `true`, enabling lazy-load patterns.
|
|
265
|
-
|
|
266
|
-
```tsx
|
|
267
|
-
import { useImageLoader } from "react-pro-image";
|
|
268
|
-
|
|
269
|
-
function MyComponent() {
|
|
270
|
-
const state = useImageLoader({
|
|
271
|
-
src: "/photo.jpg",
|
|
272
|
-
avifSrc: "/photo.avif",
|
|
273
|
-
isInView: true,
|
|
274
|
-
});
|
|
275
|
-
// state: "idle" -> "loading" -> "loaded" | "error"
|
|
276
|
-
}
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
**Options (UseImageLoaderOptions):**
|
|
280
|
-
|
|
281
|
-
| Option | Type | Default | Description |
|
|
282
|
-
| ------------ | ------------------ | ------- | ---------------------------------------- |
|
|
283
|
-
| `src` | `string` | — | Baseline image source. |
|
|
284
|
-
| `autoSrc` | `string` | — | CDN image URL for auto-format mode. |
|
|
285
|
-
| `autoFormat` | `AutoFormatConfig` | — | Format config (required with `autoSrc`). |
|
|
286
|
-
| `avifSrc` | `string` | — | Optional AVIF source (highest priority). |
|
|
287
|
-
| `webpSrc` | `string` | — | Optional WebP source (second priority). |
|
|
288
|
-
| `isInView` | `boolean` | `false` | When `true`, triggers the preload. |
|
|
289
|
-
|
|
290
|
-
**Returns:** `ImageLoadState` — `"idle"` or `"loading"` or `"loaded"` or `"error"`
|
|
291
|
-
|
|
292
|
-
---
|
|
293
|
-
|
|
294
|
-
### useInView(options?)
|
|
295
|
-
|
|
296
|
-
Tracks whether a DOM element has entered the viewport using `IntersectionObserver`. One-shot: the observer disconnects after the first intersection.
|
|
297
|
-
|
|
298
|
-
```tsx
|
|
299
|
-
import { useInView } from "react-pro-image";
|
|
300
|
-
|
|
301
|
-
function MyComponent() {
|
|
302
|
-
const { ref, isInView } = useInView({ threshold: 0.25 });
|
|
303
|
-
|
|
304
|
-
return (
|
|
305
|
-
<div ref={ref}>{isInView && <img src="/photo.jpg" alt="example" />}</div>
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
**Options (UseInViewOptions):**
|
|
311
|
-
|
|
312
|
-
| Option | Type | Default | Description |
|
|
313
|
-
| ------------ | -------- | ------- | ---------------------------------------------------- |
|
|
314
|
-
| `threshold` | `number` | `0.25` | Visibility ratio (0 to 1) required to trigger. |
|
|
315
|
-
| `rootMargin` | `string` | `"0px"` | CSS margin to expand or shrink the observation area. |
|
|
316
|
-
|
|
317
|
-
**Returns:**
|
|
318
|
-
|
|
319
|
-
| Property | Type | Description |
|
|
320
|
-
| ---------- | --------------------------- | -------------------------------------------- |
|
|
321
|
-
| `ref` | `RefObject<HTMLDivElement>` | Attach to the target element. |
|
|
322
|
-
| `isInView` | `boolean` | `true` once the element meets the threshold. |
|
|
323
|
-
|
|
324
|
-
---
|
|
325
|
-
|
|
326
|
-
## 🔤 Exported Types
|
|
327
|
-
|
|
328
|
-
All types are exported and available for use in your own components:
|
|
329
|
-
|
|
330
|
-
```ts
|
|
331
|
-
import type {
|
|
332
|
-
OptimizedImageProps,
|
|
333
|
-
AutoFormatConfig,
|
|
334
|
-
ImageWithFormatsProps,
|
|
335
|
-
UseImageLoaderOptions,
|
|
336
|
-
UseInViewOptions,
|
|
337
|
-
ImageLoadState,
|
|
338
|
-
} from "react-pro-image";
|
|
339
|
-
```
|
|
340
|
-
|
|
341
|
-
| Type | Description |
|
|
342
|
-
| ----------------------- | -------------------------------------------------------------- |
|
|
343
|
-
| `OptimizedImageProps` | Full prop type for the OptimizedImage component. |
|
|
344
|
-
| `AutoFormatConfig` | Configuration for CDN format query parameters. |
|
|
345
|
-
| `ImageWithFormatsProps` | Props for the internal format-resolving image renderer. |
|
|
346
|
-
| `UseImageLoaderOptions` | Options for the useImageLoader hook. |
|
|
347
|
-
| `UseInViewOptions` | Options for the useInView hook. |
|
|
348
|
-
| `ImageLoadState` | Union type: `"idle"` or `"loading"` or `"loaded"` or `"error"` |
|
|
349
|
-
|
|
350
|
-
---
|
|
351
|
-
|
|
352
|
-
## ⚙️ How It Works
|
|
353
|
-
|
|
354
|
-
```
|
|
355
|
-
┌─────────────────────────────────────────────────────────┐
|
|
356
|
-
│ OptimizedImage │
|
|
357
|
-
│ │
|
|
358
|
-
│ 1. useInView() │
|
|
359
|
-
│ - IntersectionObserver watches the container │
|
|
360
|
-
│ - Flips isInView to true when threshold is met │
|
|
361
|
-
│ - Disconnects after first trigger (one-shot) │
|
|
362
|
-
│ │
|
|
363
|
-
│ 2. useImageLoader() │
|
|
364
|
-
│ - Waits until isInView is true │
|
|
365
|
-
│ - useImageFormatSupport() detects AVIF/WebP │
|
|
366
|
-
│ - Creates off-screen Image() to preload best format │
|
|
367
|
-
│ - State: idle -> loading -> loaded or error │
|
|
368
|
-
│ │
|
|
369
|
-
│ 3. Render │
|
|
370
|
-
│ - Placeholder layer (opacity: 1 -> 0 on load) │
|
|
371
|
-
│ - Real image layer (mounted after entering view) │
|
|
372
|
-
│ - Fallback layer (shown only on error) │
|
|
373
|
-
└─────────────────────────────────────────────────────────┘
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
---
|
|
377
|
-
|
|
378
|
-
## 🌐 Browser Support
|
|
379
|
-
|
|
380
|
-
| Feature | Requirement |
|
|
381
|
-
| ------------ | ------------------------------------------------------- |
|
|
382
|
-
| Lazy Loading | IntersectionObserver — supported in all modern browsers |
|
|
383
|
-
| AVIF | Chrome 85+, Firefox 93+, Safari 16.4+ |
|
|
384
|
-
| WebP | Chrome 32+, Firefox 65+, Safari 14+ |
|
|
385
|
-
| Fallback | Automatic — gracefully falls back to src (JPEG/PNG) |
|
|
386
|
-
|
|
387
|
-
---
|
|
388
|
-
|
|
389
|
-
## 📄 License
|
|
136
|
+
## Links
|
|
390
137
|
|
|
391
|
-
[
|
|
138
|
+
- [Documentation](https://mohamedalfeky1.github.io/react-pro-image/)
|
|
139
|
+
- [npm Package](https://www.npmjs.com/package/react-pro-image)
|
|
@@ -266,7 +266,13 @@ function ImageWithFormats({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, cus
|
|
|
266
266
|
* @see {@link useInView} — viewport detection hook
|
|
267
267
|
* @see {@link useImageLoader} — off-screen preloading hook
|
|
268
268
|
*/
|
|
269
|
-
function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy = true, threshold = .25, rootMargin = "0px", ...rest }) {
|
|
269
|
+
function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy = true, threshold = .25, rootMargin = "0px", style, ...rest }) {
|
|
270
|
+
if (src && autoSrc) throw new Error(`Conflicting props: You cannot provide both 'src' and 'autoSrc'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
271
|
+
if (fallback && autoFallback) throw new Error(`Conflicting props: You cannot provide both 'fallback' and 'autoFallback'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
272
|
+
if (placeholder && autoPlaceholder) throw new Error(`Conflicting props: You cannot provide both 'placeholder' and 'autoPlaceholder'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
273
|
+
const hasWidth = width !== void 0 || style?.width !== void 0;
|
|
274
|
+
const hasHeight = height !== void 0 || style?.height !== void 0;
|
|
275
|
+
if (!hasWidth || !hasHeight) throw new Error(`Missing dimensions: You must provide both 'width' and 'height' (as props or in style) to prevent layout collapse.`);
|
|
270
276
|
const { ref, isInView } = useInView({
|
|
271
277
|
threshold,
|
|
272
278
|
rootMargin
|
|
@@ -284,8 +290,10 @@ function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width
|
|
|
284
290
|
style: {
|
|
285
291
|
width,
|
|
286
292
|
height,
|
|
287
|
-
position: "relative"
|
|
293
|
+
position: "relative",
|
|
294
|
+
...style
|
|
288
295
|
},
|
|
296
|
+
...rest,
|
|
289
297
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
290
298
|
avifSrc: avifFallback,
|
|
291
299
|
webpSrc: webpFallback,
|
|
@@ -302,10 +310,11 @@ function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width
|
|
|
302
310
|
width,
|
|
303
311
|
height,
|
|
304
312
|
position: "relative",
|
|
305
|
-
overflow: "hidden"
|
|
313
|
+
overflow: "hidden",
|
|
314
|
+
...style
|
|
306
315
|
},
|
|
307
316
|
...rest,
|
|
308
|
-
children: [placeholder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
317
|
+
children: [(placeholder || autoPlaceholder) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
309
318
|
src: placeholder,
|
|
310
319
|
autoSrc: autoPlaceholder,
|
|
311
320
|
autoFormat,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-pro-image.cjs.js","names":[],"sources":["../src/hooks/useImageFormatSupport.ts","../src/hooks/useImageLoader.ts","../src/hooks/useInView.ts","../src/components/OptimizedImage.tsx"],"sourcesContent":["/**\r\n * useImageFormatSupport\r\n *\r\n * A React hook that detects whether the user's browser supports modern image\r\n * formats (AVIF and WebP). It works by attempting to load a tiny test image\r\n * for each format — if the browser can render it, the format is supported.\r\n *\r\n * Results are cached in `localStorage` so the detection only runs once per\r\n * browser, avoiding unnecessary network/decode work on subsequent visits.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { avif, webp, ready } = useImageFormatSupport();\r\n *\r\n * if (!ready) return <p>Checking format support…</p>;\r\n *\r\n * return (\r\n * <img src={avif ? \"/photo.avif\" : webp ? \"/photo.webp\" : \"/photo.jpg\"} />\r\n * );\r\n * ```\r\n *\r\n * @returns An object with three properties:\r\n * - `avif` — `true` if the browser can decode AVIF images\r\n * - `webp` — `true` if the browser can decode WebP images\r\n * - `ready` — `true` once detection is complete (initially `false`)\r\n */\r\nimport { useState, useEffect } from \"react\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Test assets\r\n// ---------------------------------------------------------------------------\r\n// AVIF: We fetch a 1×1 AVIF encoded as a Base64 data-URI to verify the browser can\r\n// actually decode the AVIF format.\r\nconst AVIF_TEST =\r\n \"data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAACEwAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgAPtPlAIED8GqhABwMDIBAAAAA\";\r\n\r\n// WebP: A tiny 1×1 WebP encoded as a Base64 data-URI. WebP files are small\r\n// enough to inline directly, so no network request is needed.\r\nconst WEBP_TEST =\r\n \"data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=\";\r\n\r\n// localStorage key used to persist detection results across page loads.\r\nconst CACHE_KEY = \"img-support\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Helper\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * Attempts to load an image from `src` and resolves to `true` if the browser\r\n * successfully decoded it, or `false` if loading failed.\r\n *\r\n * How it works:\r\n * 1. Create an off-screen `<img>` element (never added to the DOM).\r\n * 2. Set its `src` to the test image.\r\n * 3. Listen for `onload` (success → true) or `onerror` (failure → false).\r\n *\r\n * The Promise is typed as `Promise<boolean>` so TypeScript knows the resolved\r\n * value is a boolean — without this, it would default to `Promise<unknown>`.\r\n */\r\nfunction canLoadImage(src: string): Promise<boolean> {\r\n return new Promise<boolean>((resolve) => {\r\n const img = new Image();\r\n img.onload = () => resolve(true);\r\n img.onerror = () => resolve(false);\r\n img.src = src;\r\n });\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Hook\r\n// ---------------------------------------------------------------------------\r\n\r\nexport function useImageFormatSupport() {\r\n // State shape:\r\n // avif → does the browser support AVIF? (default: false)\r\n // webp → does the browser support WebP? (default: false)\r\n // ready → has detection finished? (default: false)\r\n //\r\n // Components can check `ready` before acting on `avif`/`webp` to avoid\r\n // a flash of incorrect content while the async check is still running.\r\n const [support, setSupport] = useState({\r\n avif: false,\r\n webp: false,\r\n ready: false,\r\n });\r\n\r\n useEffect(() => {\r\n // --- Cache hit: reuse a previous result from localStorage -------------\r\n const cached = localStorage.getItem(CACHE_KEY);\r\n\r\n if (cached) {\r\n // `cached` is a JSON string like '{\"avif\":true,\"webp\":true}'.\r\n // We spread it into state and set `ready: true` immediately.\r\n setSupport({ ...JSON.parse(cached), ready: true });\r\n return; // skip the network/decode tests entirely\r\n }\r\n\r\n // --- Cache miss: run the format detection tests -----------------------\r\n // `Promise.all` runs both checks in parallel and waits for both to finish.\r\n // The result is an array of two booleans: [avifSupported, webpSupported].\r\n Promise.all([canLoadImage(AVIF_TEST), canLoadImage(WEBP_TEST)]).then(\r\n ([avif, webp]) => {\r\n // Persist so we never re-run the tests on this browser.\r\n localStorage.setItem(CACHE_KEY, JSON.stringify({ avif, webp }));\r\n\r\n // Update React state — triggers a re-render with the final values.\r\n setSupport({ avif, webp, ready: true });\r\n },\r\n );\r\n }, []); // Empty dependency array → runs once on mount, never re-runs.\r\n\r\n return support;\r\n}\r\n","import { useEffect, useState } from \"react\";\r\nimport type { UseImageLoaderOptions } from \"../interfaces\";\r\nimport type { ImageLoadState } from \"../types\";\r\nimport { useImageFormatSupport } from \"./useImageFormatSupport\";\r\n\r\n/**\r\n * Preloads an image off-screen and exposes its load state.\r\n *\r\n * Loading is deferred until `isInView` is `true`, enabling lazy-load\r\n * behaviour when combined with an intersection observer. The hook\r\n * selects the best available format in priority order: AVIF → WebP → original.\r\n *\r\n * @param options.src - Original image URL (required fallback).\r\n * @param options.avifSrc - Optional AVIF source (highest priority).\r\n * @param options.webpSrc - Optional WebP source (second priority).\r\n * @param options.isInView - When `true`, triggers the preload. Pass `true`\r\n * directly to disable lazy behaviour (default `false`).\r\n * @returns Current load state: `\"idle\"` | `\"loading\"` | `\"loaded\"` | `\"error\"`.\r\n *\r\n * @example\r\n * ```tsx\r\n * const state = useImageLoader({ src, isInView: true });\r\n * // state: \"idle\" → \"loading\" → \"loaded\" | \"error\"\r\n * ```\r\n */\r\nexport default function useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView = false,\r\n}: UseImageLoaderOptions) {\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n const [imageState, setImageState] = useState<ImageLoadState>(\"idle\");\r\n\r\n useEffect(() => {\r\n if (!isInView || !ready) return;\r\n\r\n let activeSrc: string | undefined;\r\n\r\n if (autoSrc && autoFormat) {\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n if (bestFormat) {\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n activeSrc = `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`;\r\n } else {\r\n activeSrc = autoSrc;\r\n }\r\n } else {\r\n // --- Manual path: use explicit avifSrc / webpSrc ----------------------\r\n if (avif && avifSrc) {\r\n activeSrc = avifSrc;\r\n } else if (webp && webpSrc) {\r\n activeSrc = webpSrc;\r\n } else {\r\n activeSrc = src;\r\n }\r\n }\r\n\r\n if (!activeSrc) {\r\n setImageState(\"error\");\r\n return;\r\n }\r\n\r\n const img = new Image();\r\n img.onload = () => setImageState(\"loaded\");\r\n img.onerror = () => setImageState(\"error\");\r\n img.src = activeSrc;\r\n\r\n return () => {\r\n img.onload = null;\r\n img.onerror = null;\r\n };\r\n }, [src, autoSrc, autoFormat, avifSrc, webpSrc, avif, webp, ready, isInView]);\r\n\r\n return imageState;\r\n}\r\n","import { useEffect, useRef, useState } from \"react\";\r\nimport type { UseInViewOptions } from \"../interfaces\";\r\n\r\n/**\r\n * Tracks whether a DOM element has entered the viewport using the\r\n * `IntersectionObserver` API. Observation is one-shot: once the element\r\n * becomes visible the observer disconnects automatically.\r\n *\r\n * @param options.threshold - Visibility ratio required to trigger (default `0.25`).\r\n * @param options.rootMargin - CSS-style margin applied to the root viewport (default `\"0px\"`).\r\n * @returns `{ ref, isInView }` — attach `ref` to the target element;\r\n * `isInView` flips to `true` once the threshold is met.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { ref, isInView } = useInView({ threshold: 0.25 });\r\n * return <div ref={ref}>{isInView && <img src={src} />}</div>;\r\n * ```\r\n */\r\nexport default function useInView(options: UseInViewOptions = {}) {\r\n const { threshold = 0.25, rootMargin = \"0px\" } = options;\r\n\r\n const ref = useRef<HTMLDivElement>(null);\r\n const [isInView, setIsInView] = useState(false);\r\n\r\n useEffect(() => {\r\n const element = ref.current;\r\n if (!element) return;\r\n\r\n const observer = new IntersectionObserver(\r\n ([entry]) => {\r\n if (entry.isIntersecting) {\r\n setIsInView(true);\r\n observer.disconnect(); // One-shot: stop after first intersection\r\n }\r\n },\r\n { threshold, rootMargin },\r\n );\r\n\r\n observer.observe(element);\r\n\r\n return () => observer.disconnect();\r\n }, [threshold, rootMargin]);\r\n\r\n return { ref, isInView };\r\n}\r\n","import { useImageFormatSupport } from \"../hooks/useImageFormatSupport\";\r\nimport useImageLoader from \"../hooks/useImageLoader\";\r\nimport useInView from \"../hooks/useInView\";\r\nimport type { ImageWithFormatsProps, OptimizedImageProps } from \"../interfaces\";\r\n\r\n/**\r\n * Internal component that resolves the best image source based on browser\r\n * format support and renders the appropriate `<img>` tag.\r\n *\r\n * When `autoSrc` + `autoFormat` is provided, it iterates through the\r\n * configured formats in priority order and appends the format query param\r\n * to the URL for the first supported format.\r\n *\r\n * When manual `avifSrc` / `webpSrc` is provided, it picks the best\r\n * supported source directly.\r\n */\r\nfunction ImageWithFormats({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n customStyles,\r\n}: ImageWithFormatsProps) {\r\n /**\r\n * Base styles that position the image as an absolutely-placed cover layer.\r\n * The `transition` enables a smooth opacity crossfade between the\r\n * placeholder and the fully-loaded image.\r\n * Any `customStyles` (e.g. dynamic opacity) are spread on top.\r\n */\r\n const sharedStyles = {\r\n position: \"absolute\" as const,\r\n inset: 0,\r\n width: \"100%\",\r\n height: \"100%\",\r\n objectFit: \"cover\" as const,\r\n ...customStyles,\r\n };\r\n\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n\r\n // --- Auto-format path: build URL with format query param -----------------\r\n if (autoSrc && autoFormat && ready) {\r\n // Helper: appends \"?fm=avif\" or \"&fm=avif\" depending on whether URL already has \"?\"\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n // If a supported format was found, use it; otherwise use autoSrc as-is\r\n return (\r\n <img\r\n src={\r\n bestFormat\r\n ? `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`\r\n : autoSrc\r\n }\r\n alt={alt}\r\n style={sharedStyles}\r\n />\r\n );\r\n }\r\n\r\n // --- Manual path: use explicit avifSrc / webpSrc -------------------------\r\n if (avifSrc && ready && avif) {\r\n return <img src={avifSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n if (webpSrc && ready && webp) {\r\n return <img src={webpSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n return <img src={src} alt={alt} style={sharedStyles} />;\r\n}\r\n\r\n/**\r\n * A performance-focused image component that combines **lazy loading**,\r\n * **placeholder-to-full crossfade**, and **modern format selection**\r\n * (AVIF / WebP) into a single drop-in `<img>` replacement.\r\n *\r\n * ## How it works\r\n *\r\n * 1. **Visibility detection** — An `IntersectionObserver` (via `useInView`)\r\n * watches the container element. No network request is made until the\r\n * configured `threshold` of the element is visible in the viewport.\r\n *\r\n * 2. **Off-screen preload** — Once visible, `useImageLoader` creates a\r\n * hidden `Image()` object to download the best-available format\r\n * (AVIF → WebP → original). The component tracks the load state\r\n * (`idle` → `loading` → `loaded` | `error`).\r\n *\r\n * 3. **Crossfade transition** — The placeholder and real image are rendered\r\n * as stacked layers. When the real image finishes loading, the\r\n * placeholder's opacity is animated to `0`, revealing the full image.\r\n *\r\n * 4. **Error recovery** — If loading fails and a `fallback` src is provided,\r\n * the fallback image is rendered instead.\r\n *\r\n * @see {@link useInView} — viewport detection hook\r\n * @see {@link useImageLoader} — off-screen preloading hook\r\n */\r\nexport default function OptimizedImage({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n width,\r\n height,\r\n placeholder,\r\n autoPlaceholder,\r\n fallback,\r\n autoFallback,\r\n avifFallback,\r\n webpFallback,\r\n lazy = true,\r\n threshold = 0.25,\r\n rootMargin = \"0px\",\r\n ...rest\r\n}: OptimizedImageProps) {\r\n // Attach `ref` to the wrapper so the IntersectionObserver can track it.\r\n // `isInView` flips to `true` once the element meets the visibility threshold\r\n // and stays `true` permanently (one-shot observation).\r\n const { ref, isInView } = useInView({ threshold, rootMargin });\r\n\r\n // Start downloading the real image only after the element enters the viewport.\r\n // When `lazy` is disabled, we pass `true` directly to load immediately.\r\n const imageState = useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView: lazy ? isInView : true,\r\n });\r\n\r\n // If the image failed to load and the consumer provided a fallback,\r\n // render the fallback image (with optional AVIF/WebP variants) and bail out.\r\n if (imageState === \"error\" && (fallback || autoFallback)) {\r\n return (\r\n <div ref={ref} style={{ width, height, position: \"relative\" }}>\r\n <ImageWithFormats\r\n avifSrc={avifFallback}\r\n webpSrc={webpFallback}\r\n src={fallback}\r\n autoSrc={autoFallback}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n />\r\n </div>\r\n );\r\n }\r\n\r\n const isLoaded = imageState === \"loaded\";\r\n\r\n // The container uses `position: relative` + `overflow: hidden` to create\r\n // a stacking context. Both the placeholder and the real image are positioned\r\n // absolutely so they overlap — only their opacity differs.\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n overflow: \"hidden\",\r\n }}\r\n {...rest}\r\n >\r\n {/* Placeholder layer (bottom) — visible immediately, fades out once loaded */}\r\n {placeholder && (\r\n <ImageWithFormats\r\n src={placeholder}\r\n autoSrc={autoPlaceholder}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n customStyles={{\r\n opacity: isLoaded ? 0 : 1,\r\n }}\r\n />\r\n )}\r\n\r\n {/* Real image layer (top) — mounted only after the element enters the viewport */}\r\n {(lazy ? isInView : true) && (\r\n <ImageWithFormats\r\n src={src}\r\n autoSrc={autoSrc}\r\n autoFormat={autoFormat}\r\n avifSrc={avifSrc}\r\n webpSrc={webpSrc}\r\n alt={alt}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"react-pro-image.cjs.js","names":[],"sources":["../src/hooks/useImageFormatSupport.ts","../src/hooks/useImageLoader.ts","../src/hooks/useInView.ts","../src/components/OptimizedImage.tsx"],"sourcesContent":["/**\r\n * useImageFormatSupport\r\n *\r\n * A React hook that detects whether the user's browser supports modern image\r\n * formats (AVIF and WebP). It works by attempting to load a tiny test image\r\n * for each format — if the browser can render it, the format is supported.\r\n *\r\n * Results are cached in `localStorage` so the detection only runs once per\r\n * browser, avoiding unnecessary network/decode work on subsequent visits.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { avif, webp, ready } = useImageFormatSupport();\r\n *\r\n * if (!ready) return <p>Checking format support…</p>;\r\n *\r\n * return (\r\n * <img src={avif ? \"/photo.avif\" : webp ? \"/photo.webp\" : \"/photo.jpg\"} />\r\n * );\r\n * ```\r\n *\r\n * @returns An object with three properties:\r\n * - `avif` — `true` if the browser can decode AVIF images\r\n * - `webp` — `true` if the browser can decode WebP images\r\n * - `ready` — `true` once detection is complete (initially `false`)\r\n */\r\nimport { useState, useEffect } from \"react\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Test assets\r\n// ---------------------------------------------------------------------------\r\n// AVIF: We fetch a 1×1 AVIF encoded as a Base64 data-URI to verify the browser can\r\n// actually decode the AVIF format.\r\nconst AVIF_TEST =\r\n \"data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAACEwAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgAPtPlAIED8GqhABwMDIBAAAAA\";\r\n\r\n// WebP: A tiny 1×1 WebP encoded as a Base64 data-URI. WebP files are small\r\n// enough to inline directly, so no network request is needed.\r\nconst WEBP_TEST =\r\n \"data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=\";\r\n\r\n// localStorage key used to persist detection results across page loads.\r\nconst CACHE_KEY = \"img-support\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Helper\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * Attempts to load an image from `src` and resolves to `true` if the browser\r\n * successfully decoded it, or `false` if loading failed.\r\n *\r\n * How it works:\r\n * 1. Create an off-screen `<img>` element (never added to the DOM).\r\n * 2. Set its `src` to the test image.\r\n * 3. Listen for `onload` (success → true) or `onerror` (failure → false).\r\n *\r\n * The Promise is typed as `Promise<boolean>` so TypeScript knows the resolved\r\n * value is a boolean — without this, it would default to `Promise<unknown>`.\r\n */\r\nfunction canLoadImage(src: string): Promise<boolean> {\r\n return new Promise<boolean>((resolve) => {\r\n const img = new Image();\r\n img.onload = () => resolve(true);\r\n img.onerror = () => resolve(false);\r\n img.src = src;\r\n });\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Hook\r\n// ---------------------------------------------------------------------------\r\n\r\nexport function useImageFormatSupport() {\r\n // State shape:\r\n // avif → does the browser support AVIF? (default: false)\r\n // webp → does the browser support WebP? (default: false)\r\n // ready → has detection finished? (default: false)\r\n //\r\n // Components can check `ready` before acting on `avif`/`webp` to avoid\r\n // a flash of incorrect content while the async check is still running.\r\n const [support, setSupport] = useState({\r\n avif: false,\r\n webp: false,\r\n ready: false,\r\n });\r\n\r\n useEffect(() => {\r\n // --- Cache hit: reuse a previous result from localStorage -------------\r\n const cached = localStorage.getItem(CACHE_KEY);\r\n\r\n if (cached) {\r\n // `cached` is a JSON string like '{\"avif\":true,\"webp\":true}'.\r\n // We spread it into state and set `ready: true` immediately.\r\n setSupport({ ...JSON.parse(cached), ready: true });\r\n return; // skip the network/decode tests entirely\r\n }\r\n\r\n // --- Cache miss: run the format detection tests -----------------------\r\n // `Promise.all` runs both checks in parallel and waits for both to finish.\r\n // The result is an array of two booleans: [avifSupported, webpSupported].\r\n Promise.all([canLoadImage(AVIF_TEST), canLoadImage(WEBP_TEST)]).then(\r\n ([avif, webp]) => {\r\n // Persist so we never re-run the tests on this browser.\r\n localStorage.setItem(CACHE_KEY, JSON.stringify({ avif, webp }));\r\n\r\n // Update React state — triggers a re-render with the final values.\r\n setSupport({ avif, webp, ready: true });\r\n },\r\n );\r\n }, []); // Empty dependency array → runs once on mount, never re-runs.\r\n\r\n return support;\r\n}\r\n","import { useEffect, useState } from \"react\";\r\nimport type { UseImageLoaderOptions } from \"../interfaces\";\r\nimport type { ImageLoadState } from \"../types\";\r\nimport { useImageFormatSupport } from \"./useImageFormatSupport\";\r\n\r\n/**\r\n * Preloads an image off-screen and exposes its load state.\r\n *\r\n * Loading is deferred until `isInView` is `true`, enabling lazy-load\r\n * behaviour when combined with an intersection observer. The hook\r\n * selects the best available format in priority order: AVIF → WebP → original.\r\n *\r\n * @param options.src - Original image URL (required fallback).\r\n * @param options.avifSrc - Optional AVIF source (highest priority).\r\n * @param options.webpSrc - Optional WebP source (second priority).\r\n * @param options.isInView - When `true`, triggers the preload. Pass `true`\r\n * directly to disable lazy behaviour (default `false`).\r\n * @returns Current load state: `\"idle\"` | `\"loading\"` | `\"loaded\"` | `\"error\"`.\r\n *\r\n * @example\r\n * ```tsx\r\n * const state = useImageLoader({ src, isInView: true });\r\n * // state: \"idle\" → \"loading\" → \"loaded\" | \"error\"\r\n * ```\r\n */\r\nexport default function useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView = false,\r\n}: UseImageLoaderOptions) {\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n const [imageState, setImageState] = useState<ImageLoadState>(\"idle\");\r\n\r\n useEffect(() => {\r\n if (!isInView || !ready) return;\r\n\r\n let activeSrc: string | undefined;\r\n\r\n if (autoSrc && autoFormat) {\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n if (bestFormat) {\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n activeSrc = `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`;\r\n } else {\r\n activeSrc = autoSrc;\r\n }\r\n } else {\r\n // --- Manual path: use explicit avifSrc / webpSrc ----------------------\r\n if (avif && avifSrc) {\r\n activeSrc = avifSrc;\r\n } else if (webp && webpSrc) {\r\n activeSrc = webpSrc;\r\n } else {\r\n activeSrc = src;\r\n }\r\n }\r\n\r\n if (!activeSrc) {\r\n setImageState(\"error\");\r\n return;\r\n }\r\n\r\n const img = new Image();\r\n img.onload = () => setImageState(\"loaded\");\r\n img.onerror = () => setImageState(\"error\");\r\n img.src = activeSrc;\r\n\r\n return () => {\r\n img.onload = null;\r\n img.onerror = null;\r\n };\r\n }, [src, autoSrc, autoFormat, avifSrc, webpSrc, avif, webp, ready, isInView]);\r\n\r\n return imageState;\r\n}\r\n","import { useEffect, useRef, useState } from \"react\";\r\nimport type { UseInViewOptions } from \"../interfaces\";\r\n\r\n/**\r\n * Tracks whether a DOM element has entered the viewport using the\r\n * `IntersectionObserver` API. Observation is one-shot: once the element\r\n * becomes visible the observer disconnects automatically.\r\n *\r\n * @param options.threshold - Visibility ratio required to trigger (default `0.25`).\r\n * @param options.rootMargin - CSS-style margin applied to the root viewport (default `\"0px\"`).\r\n * @returns `{ ref, isInView }` — attach `ref` to the target element;\r\n * `isInView` flips to `true` once the threshold is met.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { ref, isInView } = useInView({ threshold: 0.25 });\r\n * return <div ref={ref}>{isInView && <img src={src} />}</div>;\r\n * ```\r\n */\r\nexport default function useInView(options: UseInViewOptions = {}) {\r\n const { threshold = 0.25, rootMargin = \"0px\" } = options;\r\n\r\n const ref = useRef<HTMLDivElement>(null);\r\n const [isInView, setIsInView] = useState(false);\r\n\r\n useEffect(() => {\r\n const element = ref.current;\r\n if (!element) return;\r\n\r\n const observer = new IntersectionObserver(\r\n ([entry]) => {\r\n if (entry.isIntersecting) {\r\n setIsInView(true);\r\n observer.disconnect(); // One-shot: stop after first intersection\r\n }\r\n },\r\n { threshold, rootMargin },\r\n );\r\n\r\n observer.observe(element);\r\n\r\n return () => observer.disconnect();\r\n }, [threshold, rootMargin]);\r\n\r\n return { ref, isInView };\r\n}\r\n","import { useImageFormatSupport } from \"../hooks/useImageFormatSupport\";\r\nimport useImageLoader from \"../hooks/useImageLoader\";\r\nimport useInView from \"../hooks/useInView\";\r\nimport type { ImageWithFormatsProps, OptimizedImageProps } from \"../interfaces\";\r\n\r\n/**\r\n * Internal component that resolves the best image source based on browser\r\n * format support and renders the appropriate `<img>` tag.\r\n *\r\n * When `autoSrc` + `autoFormat` is provided, it iterates through the\r\n * configured formats in priority order and appends the format query param\r\n * to the URL for the first supported format.\r\n *\r\n * When manual `avifSrc` / `webpSrc` is provided, it picks the best\r\n * supported source directly.\r\n */\r\nfunction ImageWithFormats({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n customStyles,\r\n}: ImageWithFormatsProps) {\r\n /**\r\n * Base styles that position the image as an absolutely-placed cover layer.\r\n * The `transition` enables a smooth opacity crossfade between the\r\n * placeholder and the fully-loaded image.\r\n * Any `customStyles` (e.g. dynamic opacity) are spread on top.\r\n */\r\n const sharedStyles = {\r\n position: \"absolute\" as const,\r\n inset: 0,\r\n width: \"100%\",\r\n height: \"100%\",\r\n objectFit: \"cover\" as const,\r\n ...customStyles,\r\n };\r\n\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n\r\n // --- Auto-format path: build URL with format query param -----------------\r\n if (autoSrc && autoFormat && ready) {\r\n // Helper: appends \"?fm=avif\" or \"&fm=avif\" depending on whether URL already has \"?\"\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n // If a supported format was found, use it; otherwise use autoSrc as-is\r\n return (\r\n <img\r\n src={\r\n bestFormat\r\n ? `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`\r\n : autoSrc\r\n }\r\n alt={alt}\r\n style={sharedStyles}\r\n />\r\n );\r\n }\r\n\r\n // --- Manual path: use explicit avifSrc / webpSrc -------------------------\r\n if (avifSrc && ready && avif) {\r\n return <img src={avifSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n if (webpSrc && ready && webp) {\r\n return <img src={webpSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n return <img src={src} alt={alt} style={sharedStyles} />;\r\n}\r\n\r\n/**\r\n * A performance-focused image component that combines **lazy loading**,\r\n * **placeholder-to-full crossfade**, and **modern format selection**\r\n * (AVIF / WebP) into a single drop-in `<img>` replacement.\r\n *\r\n * ## How it works\r\n *\r\n * 1. **Visibility detection** — An `IntersectionObserver` (via `useInView`)\r\n * watches the container element. No network request is made until the\r\n * configured `threshold` of the element is visible in the viewport.\r\n *\r\n * 2. **Off-screen preload** — Once visible, `useImageLoader` creates a\r\n * hidden `Image()` object to download the best-available format\r\n * (AVIF → WebP → original). The component tracks the load state\r\n * (`idle` → `loading` → `loaded` | `error`).\r\n *\r\n * 3. **Crossfade transition** — The placeholder and real image are rendered\r\n * as stacked layers. When the real image finishes loading, the\r\n * placeholder's opacity is animated to `0`, revealing the full image.\r\n *\r\n * 4. **Error recovery** — If loading fails and a `fallback` src is provided,\r\n * the fallback image is rendered instead.\r\n *\r\n * @see {@link useInView} — viewport detection hook\r\n * @see {@link useImageLoader} — off-screen preloading hook\r\n */\r\nexport default function OptimizedImage({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n width,\r\n height,\r\n placeholder,\r\n autoPlaceholder,\r\n fallback,\r\n autoFallback,\r\n avifFallback,\r\n webpFallback,\r\n lazy = true,\r\n threshold = 0.25,\r\n rootMargin = \"0px\",\r\n style,\r\n ...rest\r\n}: OptimizedImageProps) {\r\n if (src && autoSrc) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'src' and 'autoSrc'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n if (fallback && autoFallback) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'fallback' and 'autoFallback'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n if (placeholder && autoPlaceholder) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'placeholder' and 'autoPlaceholder'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n const hasWidth = width !== undefined || style?.width !== undefined;\r\n const hasHeight = height !== undefined || style?.height !== undefined;\r\n\r\n if (!hasWidth || !hasHeight) {\r\n throw new Error(\r\n `Missing dimensions: You must provide both 'width' and 'height' (as props or in style) to prevent layout collapse.`,\r\n );\r\n }\r\n\r\n // Attach `ref` to the wrapper so the IntersectionObserver can track it.\r\n // `isInView` flips to `true` once the element meets the visibility threshold\r\n // and stays `true` permanently (one-shot observation).\r\n const { ref, isInView } = useInView({ threshold, rootMargin });\r\n\r\n // Start downloading the real image only after the element enters the viewport.\r\n // When `lazy` is disabled, we pass `true` directly to load immediately.\r\n const imageState = useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView: lazy ? isInView : true,\r\n });\r\n\r\n // If the image failed to load and the consumer provided a fallback,\r\n // render the fallback image (with optional AVIF/WebP variants) and bail out.\r\n if (imageState === \"error\" && (fallback || autoFallback)) {\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n ...style,\r\n }}\r\n {...rest}\r\n >\r\n <ImageWithFormats\r\n avifSrc={avifFallback}\r\n webpSrc={webpFallback}\r\n src={fallback}\r\n autoSrc={autoFallback}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n />\r\n </div>\r\n );\r\n }\r\n\r\n const isLoaded = imageState === \"loaded\";\r\n\r\n // The container uses `position: relative` + `overflow: hidden` to create\r\n // a stacking context. Both the placeholder and the real image are positioned\r\n // absolutely so they overlap — only their opacity differs.\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n overflow: \"hidden\",\r\n ...style,\r\n }}\r\n {...rest}\r\n >\r\n {/* Placeholder layer (bottom) — visible immediately, fades out once loaded */}\r\n {(placeholder || autoPlaceholder) && (\r\n <ImageWithFormats\r\n src={placeholder}\r\n autoSrc={autoPlaceholder}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n customStyles={{\r\n opacity: isLoaded ? 0 : 1,\r\n }}\r\n />\r\n )}\r\n\r\n {/* Real image layer (top) — mounted only after the element enters the viewport */}\r\n {(lazy ? isInView : true) && (\r\n <ImageWithFormats\r\n src={src}\r\n autoSrc={autoSrc}\r\n autoFormat={autoFormat}\r\n avifSrc={avifSrc}\r\n webpSrc={webpSrc}\r\n alt={alt}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
@@ -265,7 +265,13 @@ function ImageWithFormats({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, cus
|
|
|
265
265
|
* @see {@link useInView} — viewport detection hook
|
|
266
266
|
* @see {@link useImageLoader} — off-screen preloading hook
|
|
267
267
|
*/
|
|
268
|
-
function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy = true, threshold = .25, rootMargin = "0px", ...rest }) {
|
|
268
|
+
function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy = true, threshold = .25, rootMargin = "0px", style, ...rest }) {
|
|
269
|
+
if (src && autoSrc) throw new Error(`Conflicting props: You cannot provide both 'src' and 'autoSrc'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
270
|
+
if (fallback && autoFallback) throw new Error(`Conflicting props: You cannot provide both 'fallback' and 'autoFallback'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
271
|
+
if (placeholder && autoPlaceholder) throw new Error(`Conflicting props: You cannot provide both 'placeholder' and 'autoPlaceholder'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`);
|
|
272
|
+
const hasWidth = width !== void 0 || style?.width !== void 0;
|
|
273
|
+
const hasHeight = height !== void 0 || style?.height !== void 0;
|
|
274
|
+
if (!hasWidth || !hasHeight) throw new Error(`Missing dimensions: You must provide both 'width' and 'height' (as props or in style) to prevent layout collapse.`);
|
|
269
275
|
const { ref, isInView } = useInView({
|
|
270
276
|
threshold,
|
|
271
277
|
rootMargin
|
|
@@ -283,8 +289,10 @@ function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width
|
|
|
283
289
|
style: {
|
|
284
290
|
width,
|
|
285
291
|
height,
|
|
286
|
-
position: "relative"
|
|
292
|
+
position: "relative",
|
|
293
|
+
...style
|
|
287
294
|
},
|
|
295
|
+
...rest,
|
|
288
296
|
children: /* @__PURE__ */ jsx(ImageWithFormats, {
|
|
289
297
|
avifSrc: avifFallback,
|
|
290
298
|
webpSrc: webpFallback,
|
|
@@ -301,10 +309,11 @@ function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width
|
|
|
301
309
|
width,
|
|
302
310
|
height,
|
|
303
311
|
position: "relative",
|
|
304
|
-
overflow: "hidden"
|
|
312
|
+
overflow: "hidden",
|
|
313
|
+
...style
|
|
305
314
|
},
|
|
306
315
|
...rest,
|
|
307
|
-
children: [placeholder && /* @__PURE__ */ jsx(ImageWithFormats, {
|
|
316
|
+
children: [(placeholder || autoPlaceholder) && /* @__PURE__ */ jsx(ImageWithFormats, {
|
|
308
317
|
src: placeholder,
|
|
309
318
|
autoSrc: autoPlaceholder,
|
|
310
319
|
autoFormat,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-pro-image.es.js","names":[],"sources":["../src/hooks/useImageFormatSupport.ts","../src/hooks/useImageLoader.ts","../src/hooks/useInView.ts","../src/components/OptimizedImage.tsx"],"sourcesContent":["/**\r\n * useImageFormatSupport\r\n *\r\n * A React hook that detects whether the user's browser supports modern image\r\n * formats (AVIF and WebP). It works by attempting to load a tiny test image\r\n * for each format — if the browser can render it, the format is supported.\r\n *\r\n * Results are cached in `localStorage` so the detection only runs once per\r\n * browser, avoiding unnecessary network/decode work on subsequent visits.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { avif, webp, ready } = useImageFormatSupport();\r\n *\r\n * if (!ready) return <p>Checking format support…</p>;\r\n *\r\n * return (\r\n * <img src={avif ? \"/photo.avif\" : webp ? \"/photo.webp\" : \"/photo.jpg\"} />\r\n * );\r\n * ```\r\n *\r\n * @returns An object with three properties:\r\n * - `avif` — `true` if the browser can decode AVIF images\r\n * - `webp` — `true` if the browser can decode WebP images\r\n * - `ready` — `true` once detection is complete (initially `false`)\r\n */\r\nimport { useState, useEffect } from \"react\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Test assets\r\n// ---------------------------------------------------------------------------\r\n// AVIF: We fetch a 1×1 AVIF encoded as a Base64 data-URI to verify the browser can\r\n// actually decode the AVIF format.\r\nconst AVIF_TEST =\r\n \"data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAACEwAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgAPtPlAIED8GqhABwMDIBAAAAA\";\r\n\r\n// WebP: A tiny 1×1 WebP encoded as a Base64 data-URI. WebP files are small\r\n// enough to inline directly, so no network request is needed.\r\nconst WEBP_TEST =\r\n \"data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=\";\r\n\r\n// localStorage key used to persist detection results across page loads.\r\nconst CACHE_KEY = \"img-support\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Helper\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * Attempts to load an image from `src` and resolves to `true` if the browser\r\n * successfully decoded it, or `false` if loading failed.\r\n *\r\n * How it works:\r\n * 1. Create an off-screen `<img>` element (never added to the DOM).\r\n * 2. Set its `src` to the test image.\r\n * 3. Listen for `onload` (success → true) or `onerror` (failure → false).\r\n *\r\n * The Promise is typed as `Promise<boolean>` so TypeScript knows the resolved\r\n * value is a boolean — without this, it would default to `Promise<unknown>`.\r\n */\r\nfunction canLoadImage(src: string): Promise<boolean> {\r\n return new Promise<boolean>((resolve) => {\r\n const img = new Image();\r\n img.onload = () => resolve(true);\r\n img.onerror = () => resolve(false);\r\n img.src = src;\r\n });\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Hook\r\n// ---------------------------------------------------------------------------\r\n\r\nexport function useImageFormatSupport() {\r\n // State shape:\r\n // avif → does the browser support AVIF? (default: false)\r\n // webp → does the browser support WebP? (default: false)\r\n // ready → has detection finished? (default: false)\r\n //\r\n // Components can check `ready` before acting on `avif`/`webp` to avoid\r\n // a flash of incorrect content while the async check is still running.\r\n const [support, setSupport] = useState({\r\n avif: false,\r\n webp: false,\r\n ready: false,\r\n });\r\n\r\n useEffect(() => {\r\n // --- Cache hit: reuse a previous result from localStorage -------------\r\n const cached = localStorage.getItem(CACHE_KEY);\r\n\r\n if (cached) {\r\n // `cached` is a JSON string like '{\"avif\":true,\"webp\":true}'.\r\n // We spread it into state and set `ready: true` immediately.\r\n setSupport({ ...JSON.parse(cached), ready: true });\r\n return; // skip the network/decode tests entirely\r\n }\r\n\r\n // --- Cache miss: run the format detection tests -----------------------\r\n // `Promise.all` runs both checks in parallel and waits for both to finish.\r\n // The result is an array of two booleans: [avifSupported, webpSupported].\r\n Promise.all([canLoadImage(AVIF_TEST), canLoadImage(WEBP_TEST)]).then(\r\n ([avif, webp]) => {\r\n // Persist so we never re-run the tests on this browser.\r\n localStorage.setItem(CACHE_KEY, JSON.stringify({ avif, webp }));\r\n\r\n // Update React state — triggers a re-render with the final values.\r\n setSupport({ avif, webp, ready: true });\r\n },\r\n );\r\n }, []); // Empty dependency array → runs once on mount, never re-runs.\r\n\r\n return support;\r\n}\r\n","import { useEffect, useState } from \"react\";\r\nimport type { UseImageLoaderOptions } from \"../interfaces\";\r\nimport type { ImageLoadState } from \"../types\";\r\nimport { useImageFormatSupport } from \"./useImageFormatSupport\";\r\n\r\n/**\r\n * Preloads an image off-screen and exposes its load state.\r\n *\r\n * Loading is deferred until `isInView` is `true`, enabling lazy-load\r\n * behaviour when combined with an intersection observer. The hook\r\n * selects the best available format in priority order: AVIF → WebP → original.\r\n *\r\n * @param options.src - Original image URL (required fallback).\r\n * @param options.avifSrc - Optional AVIF source (highest priority).\r\n * @param options.webpSrc - Optional WebP source (second priority).\r\n * @param options.isInView - When `true`, triggers the preload. Pass `true`\r\n * directly to disable lazy behaviour (default `false`).\r\n * @returns Current load state: `\"idle\"` | `\"loading\"` | `\"loaded\"` | `\"error\"`.\r\n *\r\n * @example\r\n * ```tsx\r\n * const state = useImageLoader({ src, isInView: true });\r\n * // state: \"idle\" → \"loading\" → \"loaded\" | \"error\"\r\n * ```\r\n */\r\nexport default function useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView = false,\r\n}: UseImageLoaderOptions) {\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n const [imageState, setImageState] = useState<ImageLoadState>(\"idle\");\r\n\r\n useEffect(() => {\r\n if (!isInView || !ready) return;\r\n\r\n let activeSrc: string | undefined;\r\n\r\n if (autoSrc && autoFormat) {\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n if (bestFormat) {\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n activeSrc = `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`;\r\n } else {\r\n activeSrc = autoSrc;\r\n }\r\n } else {\r\n // --- Manual path: use explicit avifSrc / webpSrc ----------------------\r\n if (avif && avifSrc) {\r\n activeSrc = avifSrc;\r\n } else if (webp && webpSrc) {\r\n activeSrc = webpSrc;\r\n } else {\r\n activeSrc = src;\r\n }\r\n }\r\n\r\n if (!activeSrc) {\r\n setImageState(\"error\");\r\n return;\r\n }\r\n\r\n const img = new Image();\r\n img.onload = () => setImageState(\"loaded\");\r\n img.onerror = () => setImageState(\"error\");\r\n img.src = activeSrc;\r\n\r\n return () => {\r\n img.onload = null;\r\n img.onerror = null;\r\n };\r\n }, [src, autoSrc, autoFormat, avifSrc, webpSrc, avif, webp, ready, isInView]);\r\n\r\n return imageState;\r\n}\r\n","import { useEffect, useRef, useState } from \"react\";\r\nimport type { UseInViewOptions } from \"../interfaces\";\r\n\r\n/**\r\n * Tracks whether a DOM element has entered the viewport using the\r\n * `IntersectionObserver` API. Observation is one-shot: once the element\r\n * becomes visible the observer disconnects automatically.\r\n *\r\n * @param options.threshold - Visibility ratio required to trigger (default `0.25`).\r\n * @param options.rootMargin - CSS-style margin applied to the root viewport (default `\"0px\"`).\r\n * @returns `{ ref, isInView }` — attach `ref` to the target element;\r\n * `isInView` flips to `true` once the threshold is met.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { ref, isInView } = useInView({ threshold: 0.25 });\r\n * return <div ref={ref}>{isInView && <img src={src} />}</div>;\r\n * ```\r\n */\r\nexport default function useInView(options: UseInViewOptions = {}) {\r\n const { threshold = 0.25, rootMargin = \"0px\" } = options;\r\n\r\n const ref = useRef<HTMLDivElement>(null);\r\n const [isInView, setIsInView] = useState(false);\r\n\r\n useEffect(() => {\r\n const element = ref.current;\r\n if (!element) return;\r\n\r\n const observer = new IntersectionObserver(\r\n ([entry]) => {\r\n if (entry.isIntersecting) {\r\n setIsInView(true);\r\n observer.disconnect(); // One-shot: stop after first intersection\r\n }\r\n },\r\n { threshold, rootMargin },\r\n );\r\n\r\n observer.observe(element);\r\n\r\n return () => observer.disconnect();\r\n }, [threshold, rootMargin]);\r\n\r\n return { ref, isInView };\r\n}\r\n","import { useImageFormatSupport } from \"../hooks/useImageFormatSupport\";\r\nimport useImageLoader from \"../hooks/useImageLoader\";\r\nimport useInView from \"../hooks/useInView\";\r\nimport type { ImageWithFormatsProps, OptimizedImageProps } from \"../interfaces\";\r\n\r\n/**\r\n * Internal component that resolves the best image source based on browser\r\n * format support and renders the appropriate `<img>` tag.\r\n *\r\n * When `autoSrc` + `autoFormat` is provided, it iterates through the\r\n * configured formats in priority order and appends the format query param\r\n * to the URL for the first supported format.\r\n *\r\n * When manual `avifSrc` / `webpSrc` is provided, it picks the best\r\n * supported source directly.\r\n */\r\nfunction ImageWithFormats({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n customStyles,\r\n}: ImageWithFormatsProps) {\r\n /**\r\n * Base styles that position the image as an absolutely-placed cover layer.\r\n * The `transition` enables a smooth opacity crossfade between the\r\n * placeholder and the fully-loaded image.\r\n * Any `customStyles` (e.g. dynamic opacity) are spread on top.\r\n */\r\n const sharedStyles = {\r\n position: \"absolute\" as const,\r\n inset: 0,\r\n width: \"100%\",\r\n height: \"100%\",\r\n objectFit: \"cover\" as const,\r\n ...customStyles,\r\n };\r\n\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n\r\n // --- Auto-format path: build URL with format query param -----------------\r\n if (autoSrc && autoFormat && ready) {\r\n // Helper: appends \"?fm=avif\" or \"&fm=avif\" depending on whether URL already has \"?\"\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n // If a supported format was found, use it; otherwise use autoSrc as-is\r\n return (\r\n <img\r\n src={\r\n bestFormat\r\n ? `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`\r\n : autoSrc\r\n }\r\n alt={alt}\r\n style={sharedStyles}\r\n />\r\n );\r\n }\r\n\r\n // --- Manual path: use explicit avifSrc / webpSrc -------------------------\r\n if (avifSrc && ready && avif) {\r\n return <img src={avifSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n if (webpSrc && ready && webp) {\r\n return <img src={webpSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n return <img src={src} alt={alt} style={sharedStyles} />;\r\n}\r\n\r\n/**\r\n * A performance-focused image component that combines **lazy loading**,\r\n * **placeholder-to-full crossfade**, and **modern format selection**\r\n * (AVIF / WebP) into a single drop-in `<img>` replacement.\r\n *\r\n * ## How it works\r\n *\r\n * 1. **Visibility detection** — An `IntersectionObserver` (via `useInView`)\r\n * watches the container element. No network request is made until the\r\n * configured `threshold` of the element is visible in the viewport.\r\n *\r\n * 2. **Off-screen preload** — Once visible, `useImageLoader` creates a\r\n * hidden `Image()` object to download the best-available format\r\n * (AVIF → WebP → original). The component tracks the load state\r\n * (`idle` → `loading` → `loaded` | `error`).\r\n *\r\n * 3. **Crossfade transition** — The placeholder and real image are rendered\r\n * as stacked layers. When the real image finishes loading, the\r\n * placeholder's opacity is animated to `0`, revealing the full image.\r\n *\r\n * 4. **Error recovery** — If loading fails and a `fallback` src is provided,\r\n * the fallback image is rendered instead.\r\n *\r\n * @see {@link useInView} — viewport detection hook\r\n * @see {@link useImageLoader} — off-screen preloading hook\r\n */\r\nexport default function OptimizedImage({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n width,\r\n height,\r\n placeholder,\r\n autoPlaceholder,\r\n fallback,\r\n autoFallback,\r\n avifFallback,\r\n webpFallback,\r\n lazy = true,\r\n threshold = 0.25,\r\n rootMargin = \"0px\",\r\n ...rest\r\n}: OptimizedImageProps) {\r\n // Attach `ref` to the wrapper so the IntersectionObserver can track it.\r\n // `isInView` flips to `true` once the element meets the visibility threshold\r\n // and stays `true` permanently (one-shot observation).\r\n const { ref, isInView } = useInView({ threshold, rootMargin });\r\n\r\n // Start downloading the real image only after the element enters the viewport.\r\n // When `lazy` is disabled, we pass `true` directly to load immediately.\r\n const imageState = useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView: lazy ? isInView : true,\r\n });\r\n\r\n // If the image failed to load and the consumer provided a fallback,\r\n // render the fallback image (with optional AVIF/WebP variants) and bail out.\r\n if (imageState === \"error\" && (fallback || autoFallback)) {\r\n return (\r\n <div ref={ref} style={{ width, height, position: \"relative\" }}>\r\n <ImageWithFormats\r\n avifSrc={avifFallback}\r\n webpSrc={webpFallback}\r\n src={fallback}\r\n autoSrc={autoFallback}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n />\r\n </div>\r\n );\r\n }\r\n\r\n const isLoaded = imageState === \"loaded\";\r\n\r\n // The container uses `position: relative` + `overflow: hidden` to create\r\n // a stacking context. Both the placeholder and the real image are positioned\r\n // absolutely so they overlap — only their opacity differs.\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n overflow: \"hidden\",\r\n }}\r\n {...rest}\r\n >\r\n {/* Placeholder layer (bottom) — visible immediately, fades out once loaded */}\r\n {placeholder && (\r\n <ImageWithFormats\r\n src={placeholder}\r\n autoSrc={autoPlaceholder}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n customStyles={{\r\n opacity: isLoaded ? 0 : 1,\r\n }}\r\n />\r\n )}\r\n\r\n {/* Real image layer (top) — mounted only after the element enters the viewport */}\r\n {(lazy ? isInView : true) && (\r\n <ImageWithFormats\r\n src={src}\r\n autoSrc={autoSrc}\r\n autoFormat={autoFormat}\r\n avifSrc={avifSrc}\r\n webpSrc={webpSrc}\r\n alt={alt}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"react-pro-image.es.js","names":[],"sources":["../src/hooks/useImageFormatSupport.ts","../src/hooks/useImageLoader.ts","../src/hooks/useInView.ts","../src/components/OptimizedImage.tsx"],"sourcesContent":["/**\r\n * useImageFormatSupport\r\n *\r\n * A React hook that detects whether the user's browser supports modern image\r\n * formats (AVIF and WebP). It works by attempting to load a tiny test image\r\n * for each format — if the browser can render it, the format is supported.\r\n *\r\n * Results are cached in `localStorage` so the detection only runs once per\r\n * browser, avoiding unnecessary network/decode work on subsequent visits.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { avif, webp, ready } = useImageFormatSupport();\r\n *\r\n * if (!ready) return <p>Checking format support…</p>;\r\n *\r\n * return (\r\n * <img src={avif ? \"/photo.avif\" : webp ? \"/photo.webp\" : \"/photo.jpg\"} />\r\n * );\r\n * ```\r\n *\r\n * @returns An object with three properties:\r\n * - `avif` — `true` if the browser can decode AVIF images\r\n * - `webp` — `true` if the browser can decode WebP images\r\n * - `ready` — `true` once detection is complete (initially `false`)\r\n */\r\nimport { useState, useEffect } from \"react\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Test assets\r\n// ---------------------------------------------------------------------------\r\n// AVIF: We fetch a 1×1 AVIF encoded as a Base64 data-URI to verify the browser can\r\n// actually decode the AVIF format.\r\nconst AVIF_TEST =\r\n \"data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAACEwAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgAPtPlAIED8GqhABwMDIBAAAAA\";\r\n\r\n// WebP: A tiny 1×1 WebP encoded as a Base64 data-URI. WebP files are small\r\n// enough to inline directly, so no network request is needed.\r\nconst WEBP_TEST =\r\n \"data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=\";\r\n\r\n// localStorage key used to persist detection results across page loads.\r\nconst CACHE_KEY = \"img-support\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// Helper\r\n// ---------------------------------------------------------------------------\r\n\r\n/**\r\n * Attempts to load an image from `src` and resolves to `true` if the browser\r\n * successfully decoded it, or `false` if loading failed.\r\n *\r\n * How it works:\r\n * 1. Create an off-screen `<img>` element (never added to the DOM).\r\n * 2. Set its `src` to the test image.\r\n * 3. Listen for `onload` (success → true) or `onerror` (failure → false).\r\n *\r\n * The Promise is typed as `Promise<boolean>` so TypeScript knows the resolved\r\n * value is a boolean — without this, it would default to `Promise<unknown>`.\r\n */\r\nfunction canLoadImage(src: string): Promise<boolean> {\r\n return new Promise<boolean>((resolve) => {\r\n const img = new Image();\r\n img.onload = () => resolve(true);\r\n img.onerror = () => resolve(false);\r\n img.src = src;\r\n });\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// Hook\r\n// ---------------------------------------------------------------------------\r\n\r\nexport function useImageFormatSupport() {\r\n // State shape:\r\n // avif → does the browser support AVIF? (default: false)\r\n // webp → does the browser support WebP? (default: false)\r\n // ready → has detection finished? (default: false)\r\n //\r\n // Components can check `ready` before acting on `avif`/`webp` to avoid\r\n // a flash of incorrect content while the async check is still running.\r\n const [support, setSupport] = useState({\r\n avif: false,\r\n webp: false,\r\n ready: false,\r\n });\r\n\r\n useEffect(() => {\r\n // --- Cache hit: reuse a previous result from localStorage -------------\r\n const cached = localStorage.getItem(CACHE_KEY);\r\n\r\n if (cached) {\r\n // `cached` is a JSON string like '{\"avif\":true,\"webp\":true}'.\r\n // We spread it into state and set `ready: true` immediately.\r\n setSupport({ ...JSON.parse(cached), ready: true });\r\n return; // skip the network/decode tests entirely\r\n }\r\n\r\n // --- Cache miss: run the format detection tests -----------------------\r\n // `Promise.all` runs both checks in parallel and waits for both to finish.\r\n // The result is an array of two booleans: [avifSupported, webpSupported].\r\n Promise.all([canLoadImage(AVIF_TEST), canLoadImage(WEBP_TEST)]).then(\r\n ([avif, webp]) => {\r\n // Persist so we never re-run the tests on this browser.\r\n localStorage.setItem(CACHE_KEY, JSON.stringify({ avif, webp }));\r\n\r\n // Update React state — triggers a re-render with the final values.\r\n setSupport({ avif, webp, ready: true });\r\n },\r\n );\r\n }, []); // Empty dependency array → runs once on mount, never re-runs.\r\n\r\n return support;\r\n}\r\n","import { useEffect, useState } from \"react\";\r\nimport type { UseImageLoaderOptions } from \"../interfaces\";\r\nimport type { ImageLoadState } from \"../types\";\r\nimport { useImageFormatSupport } from \"./useImageFormatSupport\";\r\n\r\n/**\r\n * Preloads an image off-screen and exposes its load state.\r\n *\r\n * Loading is deferred until `isInView` is `true`, enabling lazy-load\r\n * behaviour when combined with an intersection observer. The hook\r\n * selects the best available format in priority order: AVIF → WebP → original.\r\n *\r\n * @param options.src - Original image URL (required fallback).\r\n * @param options.avifSrc - Optional AVIF source (highest priority).\r\n * @param options.webpSrc - Optional WebP source (second priority).\r\n * @param options.isInView - When `true`, triggers the preload. Pass `true`\r\n * directly to disable lazy behaviour (default `false`).\r\n * @returns Current load state: `\"idle\"` | `\"loading\"` | `\"loaded\"` | `\"error\"`.\r\n *\r\n * @example\r\n * ```tsx\r\n * const state = useImageLoader({ src, isInView: true });\r\n * // state: \"idle\" → \"loading\" → \"loaded\" | \"error\"\r\n * ```\r\n */\r\nexport default function useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView = false,\r\n}: UseImageLoaderOptions) {\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n const [imageState, setImageState] = useState<ImageLoadState>(\"idle\");\r\n\r\n useEffect(() => {\r\n if (!isInView || !ready) return;\r\n\r\n let activeSrc: string | undefined;\r\n\r\n if (autoSrc && autoFormat) {\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n if (bestFormat) {\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n activeSrc = `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`;\r\n } else {\r\n activeSrc = autoSrc;\r\n }\r\n } else {\r\n // --- Manual path: use explicit avifSrc / webpSrc ----------------------\r\n if (avif && avifSrc) {\r\n activeSrc = avifSrc;\r\n } else if (webp && webpSrc) {\r\n activeSrc = webpSrc;\r\n } else {\r\n activeSrc = src;\r\n }\r\n }\r\n\r\n if (!activeSrc) {\r\n setImageState(\"error\");\r\n return;\r\n }\r\n\r\n const img = new Image();\r\n img.onload = () => setImageState(\"loaded\");\r\n img.onerror = () => setImageState(\"error\");\r\n img.src = activeSrc;\r\n\r\n return () => {\r\n img.onload = null;\r\n img.onerror = null;\r\n };\r\n }, [src, autoSrc, autoFormat, avifSrc, webpSrc, avif, webp, ready, isInView]);\r\n\r\n return imageState;\r\n}\r\n","import { useEffect, useRef, useState } from \"react\";\r\nimport type { UseInViewOptions } from \"../interfaces\";\r\n\r\n/**\r\n * Tracks whether a DOM element has entered the viewport using the\r\n * `IntersectionObserver` API. Observation is one-shot: once the element\r\n * becomes visible the observer disconnects automatically.\r\n *\r\n * @param options.threshold - Visibility ratio required to trigger (default `0.25`).\r\n * @param options.rootMargin - CSS-style margin applied to the root viewport (default `\"0px\"`).\r\n * @returns `{ ref, isInView }` — attach `ref` to the target element;\r\n * `isInView` flips to `true` once the threshold is met.\r\n *\r\n * @example\r\n * ```tsx\r\n * const { ref, isInView } = useInView({ threshold: 0.25 });\r\n * return <div ref={ref}>{isInView && <img src={src} />}</div>;\r\n * ```\r\n */\r\nexport default function useInView(options: UseInViewOptions = {}) {\r\n const { threshold = 0.25, rootMargin = \"0px\" } = options;\r\n\r\n const ref = useRef<HTMLDivElement>(null);\r\n const [isInView, setIsInView] = useState(false);\r\n\r\n useEffect(() => {\r\n const element = ref.current;\r\n if (!element) return;\r\n\r\n const observer = new IntersectionObserver(\r\n ([entry]) => {\r\n if (entry.isIntersecting) {\r\n setIsInView(true);\r\n observer.disconnect(); // One-shot: stop after first intersection\r\n }\r\n },\r\n { threshold, rootMargin },\r\n );\r\n\r\n observer.observe(element);\r\n\r\n return () => observer.disconnect();\r\n }, [threshold, rootMargin]);\r\n\r\n return { ref, isInView };\r\n}\r\n","import { useImageFormatSupport } from \"../hooks/useImageFormatSupport\";\r\nimport useImageLoader from \"../hooks/useImageLoader\";\r\nimport useInView from \"../hooks/useInView\";\r\nimport type { ImageWithFormatsProps, OptimizedImageProps } from \"../interfaces\";\r\n\r\n/**\r\n * Internal component that resolves the best image source based on browser\r\n * format support and renders the appropriate `<img>` tag.\r\n *\r\n * When `autoSrc` + `autoFormat` is provided, it iterates through the\r\n * configured formats in priority order and appends the format query param\r\n * to the URL for the first supported format.\r\n *\r\n * When manual `avifSrc` / `webpSrc` is provided, it picks the best\r\n * supported source directly.\r\n */\r\nfunction ImageWithFormats({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n customStyles,\r\n}: ImageWithFormatsProps) {\r\n /**\r\n * Base styles that position the image as an absolutely-placed cover layer.\r\n * The `transition` enables a smooth opacity crossfade between the\r\n * placeholder and the fully-loaded image.\r\n * Any `customStyles` (e.g. dynamic opacity) are spread on top.\r\n */\r\n const sharedStyles = {\r\n position: \"absolute\" as const,\r\n inset: 0,\r\n width: \"100%\",\r\n height: \"100%\",\r\n objectFit: \"cover\" as const,\r\n ...customStyles,\r\n };\r\n\r\n const { avif, webp, ready } = useImageFormatSupport();\r\n\r\n // --- Auto-format path: build URL with format query param -----------------\r\n if (autoSrc && autoFormat && ready) {\r\n // Helper: appends \"?fm=avif\" or \"&fm=avif\" depending on whether URL already has \"?\"\r\n const separator = autoSrc.includes(\"?\") ? \"&\" : \"?\";\r\n\r\n // Pick the best format: always prefer avif over webp (best quality first)\r\n // The formats array controls which formats are allowed, not the priority\r\n let bestFormat: string | undefined;\r\n\r\n if (avif && autoFormat.formats.includes(\"avif\")) {\r\n bestFormat = \"avif\";\r\n } else if (webp && autoFormat.formats.includes(\"webp\")) {\r\n bestFormat = \"webp\";\r\n }\r\n\r\n // If a supported format was found, use it; otherwise use autoSrc as-is\r\n return (\r\n <img\r\n src={\r\n bestFormat\r\n ? `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}`\r\n : autoSrc\r\n }\r\n alt={alt}\r\n style={sharedStyles}\r\n />\r\n );\r\n }\r\n\r\n // --- Manual path: use explicit avifSrc / webpSrc -------------------------\r\n if (avifSrc && ready && avif) {\r\n return <img src={avifSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n if (webpSrc && ready && webp) {\r\n return <img src={webpSrc} alt={alt} style={sharedStyles} />;\r\n }\r\n\r\n return <img src={src} alt={alt} style={sharedStyles} />;\r\n}\r\n\r\n/**\r\n * A performance-focused image component that combines **lazy loading**,\r\n * **placeholder-to-full crossfade**, and **modern format selection**\r\n * (AVIF / WebP) into a single drop-in `<img>` replacement.\r\n *\r\n * ## How it works\r\n *\r\n * 1. **Visibility detection** — An `IntersectionObserver` (via `useInView`)\r\n * watches the container element. No network request is made until the\r\n * configured `threshold` of the element is visible in the viewport.\r\n *\r\n * 2. **Off-screen preload** — Once visible, `useImageLoader` creates a\r\n * hidden `Image()` object to download the best-available format\r\n * (AVIF → WebP → original). The component tracks the load state\r\n * (`idle` → `loading` → `loaded` | `error`).\r\n *\r\n * 3. **Crossfade transition** — The placeholder and real image are rendered\r\n * as stacked layers. When the real image finishes loading, the\r\n * placeholder's opacity is animated to `0`, revealing the full image.\r\n *\r\n * 4. **Error recovery** — If loading fails and a `fallback` src is provided,\r\n * the fallback image is rendered instead.\r\n *\r\n * @see {@link useInView} — viewport detection hook\r\n * @see {@link useImageLoader} — off-screen preloading hook\r\n */\r\nexport default function OptimizedImage({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n alt,\r\n width,\r\n height,\r\n placeholder,\r\n autoPlaceholder,\r\n fallback,\r\n autoFallback,\r\n avifFallback,\r\n webpFallback,\r\n lazy = true,\r\n threshold = 0.25,\r\n rootMargin = \"0px\",\r\n style,\r\n ...rest\r\n}: OptimizedImageProps) {\r\n if (src && autoSrc) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'src' and 'autoSrc'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n if (fallback && autoFallback) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'fallback' and 'autoFallback'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n if (placeholder && autoPlaceholder) {\r\n throw new Error(\r\n `Conflicting props: You cannot provide both 'placeholder' and 'autoPlaceholder'. For more info, see: https://github.com/MohamedAlfeky1/react-pro-image`,\r\n );\r\n }\r\n\r\n const hasWidth = width !== undefined || style?.width !== undefined;\r\n const hasHeight = height !== undefined || style?.height !== undefined;\r\n\r\n if (!hasWidth || !hasHeight) {\r\n throw new Error(\r\n `Missing dimensions: You must provide both 'width' and 'height' (as props or in style) to prevent layout collapse.`,\r\n );\r\n }\r\n\r\n // Attach `ref` to the wrapper so the IntersectionObserver can track it.\r\n // `isInView` flips to `true` once the element meets the visibility threshold\r\n // and stays `true` permanently (one-shot observation).\r\n const { ref, isInView } = useInView({ threshold, rootMargin });\r\n\r\n // Start downloading the real image only after the element enters the viewport.\r\n // When `lazy` is disabled, we pass `true` directly to load immediately.\r\n const imageState = useImageLoader({\r\n src,\r\n autoSrc,\r\n autoFormat,\r\n avifSrc,\r\n webpSrc,\r\n isInView: lazy ? isInView : true,\r\n });\r\n\r\n // If the image failed to load and the consumer provided a fallback,\r\n // render the fallback image (with optional AVIF/WebP variants) and bail out.\r\n if (imageState === \"error\" && (fallback || autoFallback)) {\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n ...style,\r\n }}\r\n {...rest}\r\n >\r\n <ImageWithFormats\r\n avifSrc={avifFallback}\r\n webpSrc={webpFallback}\r\n src={fallback}\r\n autoSrc={autoFallback}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n />\r\n </div>\r\n );\r\n }\r\n\r\n const isLoaded = imageState === \"loaded\";\r\n\r\n // The container uses `position: relative` + `overflow: hidden` to create\r\n // a stacking context. Both the placeholder and the real image are positioned\r\n // absolutely so they overlap — only their opacity differs.\r\n return (\r\n <div\r\n ref={ref}\r\n style={{\r\n width,\r\n height,\r\n position: \"relative\",\r\n overflow: \"hidden\",\r\n ...style,\r\n }}\r\n {...rest}\r\n >\r\n {/* Placeholder layer (bottom) — visible immediately, fades out once loaded */}\r\n {(placeholder || autoPlaceholder) && (\r\n <ImageWithFormats\r\n src={placeholder}\r\n autoSrc={autoPlaceholder}\r\n autoFormat={autoFormat}\r\n alt={alt}\r\n customStyles={{\r\n opacity: isLoaded ? 0 : 1,\r\n }}\r\n />\r\n )}\r\n\r\n {/* Real image layer (top) — mounted only after the element enters the viewport */}\r\n {(lazy ? isInView : true) && (\r\n <ImageWithFormats\r\n src={src}\r\n autoSrc={autoSrc}\r\n autoFormat={autoFormat}\r\n avifSrc={avifSrc}\r\n webpSrc={webpSrc}\r\n alt={alt}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
@@ -25,4 +25,4 @@ import { OptimizedImageProps } from '../interfaces';
|
|
|
25
25
|
* @see {@link useInView} — viewport detection hook
|
|
26
26
|
* @see {@link useImageLoader} — off-screen preloading hook
|
|
27
27
|
*/
|
|
28
|
-
export default function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy, threshold, rootMargin, ...rest }: OptimizedImageProps): import("react/jsx-runtime").JSX.Element;
|
|
28
|
+
export default function OptimizedImage({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, width, height, placeholder, autoPlaceholder, fallback, autoFallback, avifFallback, webpFallback, lazy, threshold, rootMargin, style, ...rest }: OptimizedImageProps): import("react/jsx-runtime").JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-pro-image",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "One single <OptimizedImage /> component, a few props, and you get lazy loading, AVIF/WebP auto-format, placeholder crossfade, and error fallback — out of the box.",
|
|
6
6
|
"author": "MohamedAlfeky1",
|