UNPKG

@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
// 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 };