react-pro-image 1.0.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 +391 -0
- package/dist/react-pro-image.cjs.js +330 -0
- package/dist/react-pro-image.cjs.js.map +1 -0
- package/dist/react-pro-image.es.js +326 -0
- package/dist/react-pro-image.es.js.map +1 -0
- package/dist/src/components/OptimizedImage.d.ts +28 -0
- package/dist/src/hooks/useImageFormatSupport.d.ts +5 -0
- package/dist/src/hooks/useImageLoader.d.ts +23 -0
- package/dist/src/hooks/useInView.d.ts +21 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/interfaces/index.d.ts +145 -0
- package/dist/src/types/index.d.ts +1 -0
- package/package.json +83 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let react = require("react");
|
|
3
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
4
|
+
//#region src/hooks/useImageFormatSupport.ts
|
|
5
|
+
/**
|
|
6
|
+
* useImageFormatSupport
|
|
7
|
+
*
|
|
8
|
+
* A React hook that detects whether the user's browser supports modern image
|
|
9
|
+
* formats (AVIF and WebP). It works by attempting to load a tiny test image
|
|
10
|
+
* for each format — if the browser can render it, the format is supported.
|
|
11
|
+
*
|
|
12
|
+
* Results are cached in `localStorage` so the detection only runs once per
|
|
13
|
+
* browser, avoiding unnecessary network/decode work on subsequent visits.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const { avif, webp, ready } = useImageFormatSupport();
|
|
18
|
+
*
|
|
19
|
+
* if (!ready) return <p>Checking format support…</p>;
|
|
20
|
+
*
|
|
21
|
+
* return (
|
|
22
|
+
* <img src={avif ? "/photo.avif" : webp ? "/photo.webp" : "/photo.jpg"} />
|
|
23
|
+
* );
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @returns An object with three properties:
|
|
27
|
+
* - `avif` — `true` if the browser can decode AVIF images
|
|
28
|
+
* - `webp` — `true` if the browser can decode WebP images
|
|
29
|
+
* - `ready` — `true` once detection is complete (initially `false`)
|
|
30
|
+
*/
|
|
31
|
+
var AVIF_TEST = "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAACEwAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKBzgAPtPlAIED8GqhABwMDIBAAAAA";
|
|
32
|
+
var WEBP_TEST = "data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=";
|
|
33
|
+
var CACHE_KEY = "img-support";
|
|
34
|
+
/**
|
|
35
|
+
* Attempts to load an image from `src` and resolves to `true` if the browser
|
|
36
|
+
* successfully decoded it, or `false` if loading failed.
|
|
37
|
+
*
|
|
38
|
+
* How it works:
|
|
39
|
+
* 1. Create an off-screen `<img>` element (never added to the DOM).
|
|
40
|
+
* 2. Set its `src` to the test image.
|
|
41
|
+
* 3. Listen for `onload` (success → true) or `onerror` (failure → false).
|
|
42
|
+
*
|
|
43
|
+
* The Promise is typed as `Promise<boolean>` so TypeScript knows the resolved
|
|
44
|
+
* value is a boolean — without this, it would default to `Promise<unknown>`.
|
|
45
|
+
*/
|
|
46
|
+
function canLoadImage(src) {
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
const img = new Image();
|
|
49
|
+
img.onload = () => resolve(true);
|
|
50
|
+
img.onerror = () => resolve(false);
|
|
51
|
+
img.src = src;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function useImageFormatSupport() {
|
|
55
|
+
const [support, setSupport] = (0, react.useState)({
|
|
56
|
+
avif: false,
|
|
57
|
+
webp: false,
|
|
58
|
+
ready: false
|
|
59
|
+
});
|
|
60
|
+
(0, react.useEffect)(() => {
|
|
61
|
+
const cached = localStorage.getItem(CACHE_KEY);
|
|
62
|
+
if (cached) {
|
|
63
|
+
setSupport({
|
|
64
|
+
...JSON.parse(cached),
|
|
65
|
+
ready: true
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
Promise.all([canLoadImage(AVIF_TEST), canLoadImage(WEBP_TEST)]).then(([avif, webp]) => {
|
|
70
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
|
71
|
+
avif,
|
|
72
|
+
webp
|
|
73
|
+
}));
|
|
74
|
+
setSupport({
|
|
75
|
+
avif,
|
|
76
|
+
webp,
|
|
77
|
+
ready: true
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}, []);
|
|
81
|
+
return support;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/hooks/useImageLoader.ts
|
|
85
|
+
/**
|
|
86
|
+
* Preloads an image off-screen and exposes its load state.
|
|
87
|
+
*
|
|
88
|
+
* Loading is deferred until `isInView` is `true`, enabling lazy-load
|
|
89
|
+
* behaviour when combined with an intersection observer. The hook
|
|
90
|
+
* selects the best available format in priority order: AVIF → WebP → original.
|
|
91
|
+
*
|
|
92
|
+
* @param options.src - Original image URL (required fallback).
|
|
93
|
+
* @param options.avifSrc - Optional AVIF source (highest priority).
|
|
94
|
+
* @param options.webpSrc - Optional WebP source (second priority).
|
|
95
|
+
* @param options.isInView - When `true`, triggers the preload. Pass `true`
|
|
96
|
+
* directly to disable lazy behaviour (default `false`).
|
|
97
|
+
* @returns Current load state: `"idle"` | `"loading"` | `"loaded"` | `"error"`.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```tsx
|
|
101
|
+
* const state = useImageLoader({ src, isInView: true });
|
|
102
|
+
* // state: "idle" → "loading" → "loaded" | "error"
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
function useImageLoader({ src, autoSrc, autoFormat, avifSrc, webpSrc, isInView = false }) {
|
|
106
|
+
const { avif, webp, ready } = useImageFormatSupport();
|
|
107
|
+
const [imageState, setImageState] = (0, react.useState)("idle");
|
|
108
|
+
(0, react.useEffect)(() => {
|
|
109
|
+
if (!isInView || !ready) return;
|
|
110
|
+
let activeSrc;
|
|
111
|
+
if (autoSrc && autoFormat) {
|
|
112
|
+
let bestFormat;
|
|
113
|
+
if (avif && autoFormat.formats.includes("avif")) bestFormat = "avif";
|
|
114
|
+
else if (webp && autoFormat.formats.includes("webp")) bestFormat = "webp";
|
|
115
|
+
if (bestFormat) activeSrc = `${autoSrc}${autoSrc.includes("?") ? "&" : "?"}${autoFormat.formatKey}=${bestFormat}`;
|
|
116
|
+
else activeSrc = autoSrc;
|
|
117
|
+
} else if (avif && avifSrc) activeSrc = avifSrc;
|
|
118
|
+
else if (webp && webpSrc) activeSrc = webpSrc;
|
|
119
|
+
else activeSrc = src;
|
|
120
|
+
if (!activeSrc) {
|
|
121
|
+
setImageState("error");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const img = new Image();
|
|
125
|
+
img.onload = () => setImageState("loaded");
|
|
126
|
+
img.onerror = () => setImageState("error");
|
|
127
|
+
img.src = activeSrc;
|
|
128
|
+
return () => {
|
|
129
|
+
img.onload = null;
|
|
130
|
+
img.onerror = null;
|
|
131
|
+
};
|
|
132
|
+
}, [
|
|
133
|
+
src,
|
|
134
|
+
autoSrc,
|
|
135
|
+
autoFormat,
|
|
136
|
+
avifSrc,
|
|
137
|
+
webpSrc,
|
|
138
|
+
avif,
|
|
139
|
+
webp,
|
|
140
|
+
ready,
|
|
141
|
+
isInView
|
|
142
|
+
]);
|
|
143
|
+
return imageState;
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/hooks/useInView.ts
|
|
147
|
+
/**
|
|
148
|
+
* Tracks whether a DOM element has entered the viewport using the
|
|
149
|
+
* `IntersectionObserver` API. Observation is one-shot: once the element
|
|
150
|
+
* becomes visible the observer disconnects automatically.
|
|
151
|
+
*
|
|
152
|
+
* @param options.threshold - Visibility ratio required to trigger (default `0.25`).
|
|
153
|
+
* @param options.rootMargin - CSS-style margin applied to the root viewport (default `"0px"`).
|
|
154
|
+
* @returns `{ ref, isInView }` — attach `ref` to the target element;
|
|
155
|
+
* `isInView` flips to `true` once the threshold is met.
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```tsx
|
|
159
|
+
* const { ref, isInView } = useInView({ threshold: 0.25 });
|
|
160
|
+
* return <div ref={ref}>{isInView && <img src={src} />}</div>;
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
function useInView(options = {}) {
|
|
164
|
+
const { threshold = .25, rootMargin = "0px" } = options;
|
|
165
|
+
const ref = (0, react.useRef)(null);
|
|
166
|
+
const [isInView, setIsInView] = (0, react.useState)(false);
|
|
167
|
+
(0, react.useEffect)(() => {
|
|
168
|
+
const element = ref.current;
|
|
169
|
+
if (!element) return;
|
|
170
|
+
const observer = new IntersectionObserver(([entry]) => {
|
|
171
|
+
if (entry.isIntersecting) {
|
|
172
|
+
setIsInView(true);
|
|
173
|
+
observer.disconnect();
|
|
174
|
+
}
|
|
175
|
+
}, {
|
|
176
|
+
threshold,
|
|
177
|
+
rootMargin
|
|
178
|
+
});
|
|
179
|
+
observer.observe(element);
|
|
180
|
+
return () => observer.disconnect();
|
|
181
|
+
}, [threshold, rootMargin]);
|
|
182
|
+
return {
|
|
183
|
+
ref,
|
|
184
|
+
isInView
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/components/OptimizedImage.tsx
|
|
189
|
+
/**
|
|
190
|
+
* Internal component that resolves the best image source based on browser
|
|
191
|
+
* format support and renders the appropriate `<img>` tag.
|
|
192
|
+
*
|
|
193
|
+
* When `autoSrc` + `autoFormat` is provided, it iterates through the
|
|
194
|
+
* configured formats in priority order and appends the format query param
|
|
195
|
+
* to the URL for the first supported format.
|
|
196
|
+
*
|
|
197
|
+
* When manual `avifSrc` / `webpSrc` is provided, it picks the best
|
|
198
|
+
* supported source directly.
|
|
199
|
+
*/
|
|
200
|
+
function ImageWithFormats({ src, autoSrc, autoFormat, avifSrc, webpSrc, alt, customStyles }) {
|
|
201
|
+
/**
|
|
202
|
+
* Base styles that position the image as an absolutely-placed cover layer.
|
|
203
|
+
* The `transition` enables a smooth opacity crossfade between the
|
|
204
|
+
* placeholder and the fully-loaded image.
|
|
205
|
+
* Any `customStyles` (e.g. dynamic opacity) are spread on top.
|
|
206
|
+
*/
|
|
207
|
+
const sharedStyles = {
|
|
208
|
+
position: "absolute",
|
|
209
|
+
inset: 0,
|
|
210
|
+
width: "100%",
|
|
211
|
+
height: "100%",
|
|
212
|
+
objectFit: "cover",
|
|
213
|
+
...customStyles
|
|
214
|
+
};
|
|
215
|
+
const { avif, webp, ready } = useImageFormatSupport();
|
|
216
|
+
if (autoSrc && autoFormat && ready) {
|
|
217
|
+
const separator = autoSrc.includes("?") ? "&" : "?";
|
|
218
|
+
let bestFormat;
|
|
219
|
+
if (avif && autoFormat.formats.includes("avif")) bestFormat = "avif";
|
|
220
|
+
else if (webp && autoFormat.formats.includes("webp")) bestFormat = "webp";
|
|
221
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
222
|
+
src: bestFormat ? `${autoSrc}${separator}${autoFormat.formatKey}=${bestFormat}` : autoSrc,
|
|
223
|
+
alt,
|
|
224
|
+
style: sharedStyles
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (avifSrc && ready && avif) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
228
|
+
src: avifSrc,
|
|
229
|
+
alt,
|
|
230
|
+
style: sharedStyles
|
|
231
|
+
});
|
|
232
|
+
if (webpSrc && ready && webp) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
233
|
+
src: webpSrc,
|
|
234
|
+
alt,
|
|
235
|
+
style: sharedStyles
|
|
236
|
+
});
|
|
237
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
|
|
238
|
+
src,
|
|
239
|
+
alt,
|
|
240
|
+
style: sharedStyles
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* A performance-focused image component that combines **lazy loading**,
|
|
245
|
+
* **placeholder-to-full crossfade**, and **modern format selection**
|
|
246
|
+
* (AVIF / WebP) into a single drop-in `<img>` replacement.
|
|
247
|
+
*
|
|
248
|
+
* ## How it works
|
|
249
|
+
*
|
|
250
|
+
* 1. **Visibility detection** — An `IntersectionObserver` (via `useInView`)
|
|
251
|
+
* watches the container element. No network request is made until the
|
|
252
|
+
* configured `threshold` of the element is visible in the viewport.
|
|
253
|
+
*
|
|
254
|
+
* 2. **Off-screen preload** — Once visible, `useImageLoader` creates a
|
|
255
|
+
* hidden `Image()` object to download the best-available format
|
|
256
|
+
* (AVIF → WebP → original). The component tracks the load state
|
|
257
|
+
* (`idle` → `loading` → `loaded` | `error`).
|
|
258
|
+
*
|
|
259
|
+
* 3. **Crossfade transition** — The placeholder and real image are rendered
|
|
260
|
+
* as stacked layers. When the real image finishes loading, the
|
|
261
|
+
* placeholder's opacity is animated to `0`, revealing the full image.
|
|
262
|
+
*
|
|
263
|
+
* 4. **Error recovery** — If loading fails and a `fallback` src is provided,
|
|
264
|
+
* the fallback image is rendered instead.
|
|
265
|
+
*
|
|
266
|
+
* @see {@link useInView} — viewport detection hook
|
|
267
|
+
* @see {@link useImageLoader} — off-screen preloading hook
|
|
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 }) {
|
|
270
|
+
const { ref, isInView } = useInView({
|
|
271
|
+
threshold,
|
|
272
|
+
rootMargin
|
|
273
|
+
});
|
|
274
|
+
const imageState = useImageLoader({
|
|
275
|
+
src,
|
|
276
|
+
autoSrc,
|
|
277
|
+
autoFormat,
|
|
278
|
+
avifSrc,
|
|
279
|
+
webpSrc,
|
|
280
|
+
isInView: lazy ? isInView : true
|
|
281
|
+
});
|
|
282
|
+
if (imageState === "error" && (fallback || autoFallback)) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
283
|
+
ref,
|
|
284
|
+
style: {
|
|
285
|
+
width,
|
|
286
|
+
height,
|
|
287
|
+
position: "relative"
|
|
288
|
+
},
|
|
289
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
290
|
+
avifSrc: avifFallback,
|
|
291
|
+
webpSrc: webpFallback,
|
|
292
|
+
src: fallback,
|
|
293
|
+
autoSrc: autoFallback,
|
|
294
|
+
autoFormat,
|
|
295
|
+
alt
|
|
296
|
+
})
|
|
297
|
+
});
|
|
298
|
+
const isLoaded = imageState === "loaded";
|
|
299
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
300
|
+
ref,
|
|
301
|
+
style: {
|
|
302
|
+
width,
|
|
303
|
+
height,
|
|
304
|
+
position: "relative",
|
|
305
|
+
overflow: "hidden"
|
|
306
|
+
},
|
|
307
|
+
...rest,
|
|
308
|
+
children: [placeholder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
309
|
+
src: placeholder,
|
|
310
|
+
autoSrc: autoPlaceholder,
|
|
311
|
+
autoFormat,
|
|
312
|
+
alt,
|
|
313
|
+
customStyles: { opacity: isLoaded ? 0 : 1 }
|
|
314
|
+
}), (lazy ? isInView : true) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageWithFormats, {
|
|
315
|
+
src,
|
|
316
|
+
autoSrc,
|
|
317
|
+
autoFormat,
|
|
318
|
+
avifSrc,
|
|
319
|
+
webpSrc,
|
|
320
|
+
alt
|
|
321
|
+
})]
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
//#endregion
|
|
325
|
+
exports.OptimizedImage = OptimizedImage;
|
|
326
|
+
exports.useImageFormatSupport = useImageFormatSupport;
|
|
327
|
+
exports.useImageLoader = useImageLoader;
|
|
328
|
+
exports.useInView = useInView;
|
|
329
|
+
|
|
330
|
+
//# sourceMappingURL=react-pro-image.cjs.js.map
|
|
@@ -0,0 +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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"}
|