@shopgate/pwa-common
Version:
Common library for the Shopgate Connect PWA.
199 lines (191 loc) • 5.98 kB
JavaScript
import React, { useMemo, useState, useEffect, useRef, useCallback, memo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import noop from 'lodash/noop';
import { themeConfig } from '@shopgate/engage';
import { getFullImageSource } from '@shopgate/engage/core/helpers';
import styles from "./style";
import ImageInner from "./ImageInner";
import { jsx as _jsx } from "react/jsx-runtime";
const {
colors: themeColors
} = themeConfig;
/**
* Calculates the Greatest Common Divisor (GCD) of two numbers using the Euclidean algorithm.
*
* @param {number} a - The first number (must be a positive integer).
* @param {number} b - The second number (must be a positive integer).
* @returns {number} The greatest common divisor of `a` and `b`.
*
* @example
* gcd(1920, 1080); // Returns 120
* gcd(10, 15); // Returns 5
* gcd(100, 25); // Returns 25
*/
const gcd = (a, b) => b === 0 ? a : gcd(b, a % b);
/**
* The image component.
* @param {Object} props The components props.
* @returns {JSX.Element}
*/
const Image = ({
alt,
backgroundColor,
className,
classNameImg,
forcePlaceholder: parentRendersPlaceholder,
highestResolutionLoaded,
onError,
onLoad,
ratio,
resolutions,
src,
lazy,
unwrapped
}) => {
// Prepare two image sources - a small preview image and a large main image. The idea is to
// display an image as soon as possible. Small images might be also available in the cache from
// the previous page.
const sources = useMemo(() => {
// Create a preview source when resolutions array has more than one element
const preview = resolutions.length > 1 ? getFullImageSource(src, resolutions[resolutions.length - 2]) : null;
// Create a main source when resolutions array has at least one element (highest resolution)
const main = resolutions.length > 0 ? getFullImageSource(src, resolutions[resolutions.length - 1]) : null;
return {
// Only assign preview source if it is different from the main source. Image swap logic
// will not run when no preview source is available.
preview: preview !== main ? preview : null,
main
};
}, [resolutions, src]);
const imgRef = useRef(null);
const [isInView, setIsInView] = useState(!lazy);
// Effect to create an Intersection Observer to enable lazy loading of preview images
useEffect(() => {
if (!lazy || !sources.preview) return undefined;
// Intersection Observer to check if the image is in (or near) the viewport
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
// stop observing once visible
observer.unobserve(entry.target);
}
},
// load a bit earlier
{
rootMargin: '100px'
});
if (imgRef.current) {
// start observing the image element
observer.observe(imgRef.current);
}
return () => {
// disconnect the observer when the component is unmounted
observer.disconnect();
};
}, [lazy, sources.preview]);
/**
* Handles the onLoad event of the image.
*/
const handleOnLoad = useCallback(e => {
highestResolutionLoaded();
onLoad(e);
}, [highestResolutionLoaded, onLoad]);
/**
* Handles the onError event of the image.
*/
const handleOnError = useCallback(e => {
onError(e);
}, [onError]);
/**
* Memoized calculation of aspect ratio and CSS padding-hack ratio for responsive elements.
*
* Returns n object containing:
* - `aspectRatio` {string} - The aspect ratio in the format `width / height` (e.g., `16 / 9`).
* - `paddingHackRatio` {string} - The CSS padding-hack ratio as a percentage for older browsers
* (e.g., `56.250%` for a 16:9 ratio).
*/
const {
aspectRatio,
paddingHackRatio
} = useMemo(() => {
let width;
let height;
if (ratio) {
[width, height] = ratio;
} else {
({
width,
height
} = resolutions[resolutions.length - 1]);
}
const divisor = gcd(width, height);
return {
aspectRatio: `${width / divisor} / ${height / divisor}`,
paddingHackRatio: `${(height / width * 100).toFixed(3)}%`
};
}, [ratio, resolutions]);
if (unwrapped) {
if (!(src && !parentRendersPlaceholder)) return null;
return /*#__PURE__*/_jsx(ImageInner, {
ref: imgRef,
src: sources.main,
className: classNames(classNameImg),
style: {
aspectRatio,
...(isInView && sources.preview && {
backgroundImage: `url(${sources.preview})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center'
})
},
alt: alt,
lazy: lazy,
onLoad: handleOnLoad,
onError: handleOnError
});
}
const containerStyle = styles.container(backgroundColor, paddingHackRatio);
return /*#__PURE__*/_jsx("div", {
className: classNames(containerStyle, className, 'common__image__container'),
children: src && !parentRendersPlaceholder && /*#__PURE__*/_jsx(ImageInner, {
ref: imgRef,
src: sources.main,
className: classNames(classNameImg),
style: {
aspectRatio,
...(isInView && sources.preview && {
backgroundImage: `url(${sources.preview})`,
backgroundSize: 'contain',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center'
})
},
alt: alt,
lazy: lazy,
onLoad: handleOnLoad,
onError: handleOnError
})
});
};
const defaultResolutions = [{
width: 440,
height: 440
}];
Image.defaultProps = {
alt: null,
backgroundColor: themeColors.placeholder,
className: '',
classNameImg: '',
forcePlaceholder: false,
highestResolutionLoaded: noop,
onError: noop,
onLoad: noop,
ratio: null,
resolutions: defaultResolutions,
src: null,
unwrapped: false,
lazy: true
};
export default /*#__PURE__*/memo(Image);