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