@sarthak61199/react-smart-image
Version:
Smart, responsive React image component with BlurHash/LQIP placeholders and CDN-friendly srcset/sizes.
208 lines (204 loc) • 6.26 kB
JavaScript
// src/components/Image.tsx
import { decode } from "blurhash";
import React, { forwardRef, useCallback as useCallback2, useEffect, useMemo, useRef as useRef2, useState as useState2 } from "react";
// src/hooks/useInView.ts
import { useCallback, useRef, useState } from "react";
function useInView(options = {}) {
const {
root,
rootMargin = "0px 0px 200px 0px",
threshold = 0,
once = true
} = options;
const observerRef = useRef(null);
const [inView, setInView] = useState(false);
const setTarget = useCallback(
(node) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (!node) return;
if (typeof window === "undefined" || !("IntersectionObserver" in window)) {
setInView(true);
return;
}
observerRef.current = new IntersectionObserver(
([entry]) => {
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
if (isVisible) {
setInView(true);
if (once && observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
} else if (!once) {
setInView(false);
}
},
{ root, rootMargin, threshold }
);
observerRef.current.observe(node);
},
[root, rootMargin, threshold, once]
);
return [setTarget, inView];
}
// src/utils/index.ts
var getSrcSet = (breakpoints, transformUrl, src) => {
if (!breakpoints) return void 0;
const entries = Object.entries(breakpoints).map(([k, v]) => [k, v]).sort((a, b) => Number(a[0]) - Number(b[0]));
return entries.map(([_, rawVal]) => {
const numeric = typeof rawVal === "number" ? rawVal : parseInt(rawVal, 10);
if (Number.isFinite(numeric)) {
const url = transformUrl ? transformUrl(src, numeric) : `${src}?w=${numeric}`;
return `${url} ${numeric}w`;
}
return void 0;
}).filter(Boolean).join(", ");
};
var getSizes = (breakpoints) => {
if (!breakpoints) return void 0;
const entries = Object.entries(breakpoints).map(([k, v]) => [k, v]).sort((a, b) => Number(a[0]) - Number(b[0]));
return entries.map(([bp, val]) => {
return `(min-width: ${bp}px) ${typeof val === "number" ? `${val}px` : val}`;
}).join(", ");
};
var getAspectStyle = (width, height, style = {}) => {
return width !== void 0 && height !== void 0 && width !== 0 ? { aspectRatio: `${width} / ${height}`, ...style } : style;
};
// src/components/Image.tsx
var Image = forwardRef(({
src,
alt,
width,
height,
breakpoints,
placeholder = "none",
blurhash,
priority = false,
transformUrl,
style,
deferUntilInView,
onLoad,
...rest
}, ref) => {
const [loaded, setLoaded] = useState2(false);
const canvasRef = useRef2(null);
const [setInViewRef, inView] = useInView({ rootMargin: "0px 0px 200px 0px" });
useEffect(() => {
if (placeholder === "blurhash" && blurhash && canvasRef.current) {
const pixels = decode(blurhash, 32, 32);
const ctx = canvasRef.current.getContext("2d");
if (ctx) {
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
}
}
}, [placeholder, blurhash]);
const aspectStyle = getAspectStyle(width, height, style);
const resolvedSrc = useMemo(() => {
if (deferUntilInView && !inView) return void 0;
return transformUrl ? transformUrl(src, width) : src;
}, [deferUntilInView, inView, transformUrl, src, width]);
const srcSet = useMemo(() => {
if (deferUntilInView && !inView) return void 0;
return getSrcSet(breakpoints, transformUrl, src);
}, [deferUntilInView, inView, breakpoints, transformUrl, src]);
const sizes = useMemo(() => {
if (deferUntilInView && !inView) return void 0;
return getSizes(breakpoints);
}, [deferUntilInView, inView, breakpoints]);
const lqipStyleBackground = useMemo(() => {
if (placeholder !== "lqip") return void 0;
if (transformUrl) {
return transformUrl(src, 16);
}
return `${src}?lqip`;
}, [placeholder, transformUrl, src]);
const composedOnLoad = useCallback2(
(e) => {
setLoaded(true);
onLoad?.(e);
},
[onLoad]
);
const setMergedRef = useCallback2(
(node) => {
setInViewRef(node);
if (!ref) return;
if (typeof ref === "function") {
ref(node);
} else {
ref.current = node;
}
},
[ref, setInViewRef]
);
return /* @__PURE__ */ React.createElement("div", { style: { position: "relative", ...aspectStyle } }, placeholder === "blurhash" && blurhash && /* @__PURE__ */ React.createElement(
"canvas",
{
ref: canvasRef,
width: 32,
height: 32,
style: {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
filter: "blur(2px)",
transition: "opacity 0.3s ease",
opacity: loaded ? 0 : 1,
pointerEvents: "none"
}
}
), placeholder === "lqip" && lqipStyleBackground && /* @__PURE__ */ React.createElement(
"div",
{
style: {
backgroundImage: `url(${lqipStyleBackground})`,
backgroundSize: "cover",
backgroundPosition: "center",
position: "absolute",
inset: 0,
filter: "blur(4px)",
transition: "opacity 0.3s ease",
opacity: loaded ? 0 : 1,
pointerEvents: "none"
}
}
), /* @__PURE__ */ React.createElement(
"img",
{
ref: setMergedRef,
alt,
width,
height,
srcSet,
sizes,
loading: priority ? "eager" : "lazy",
fetchPriority: priority ? "high" : void 0,
decoding: "async",
style: {
width: "100%",
height: "100%",
objectFit: style?.objectFit || "cover",
opacity: loaded ? 1 : 0,
transition: "opacity 0.3s ease",
// Ensure img above placeholder for fade-in
position: "relative",
zIndex: 1
},
...rest,
...resolvedSrc ? { src: resolvedSrc } : {},
onLoad: composedOnLoad
}
));
});
Image.displayName = "Image";
export {
Image,
useInView
};