import { encryptImageToken, getClassNames, IImageResize, ImageStrategy, isObject } from '@websolutespa/bom-core'; import { getRemoteApiUrl } from '@websolutespa/bom-mixer-store'; import { CSSProperties, memo, useContext, useEffect, useRef } from 'react'; import styled, { ThemeContext } from 'styled-components'; import { UIStyledComponentProps } from '../../components/types'; import { IThemeResponsive } from '../../theme'; import { SizeVariant } from '../../variants'; import { IMediaItemProps } from './media-items'; export type ImageSizeMap = Partial>; const IMAGE_SIZE_VARIANTS = ['xs', 'sm', 'md', 'lg', 'xl']; const IMAGE_SIZES = process.env.IMAGE_SIZES ? process.env.IMAGE_SIZES.split(',').map(x => parseInt(x)) : [750, 960, 1440, 1600, 1920]; const IMAGE_SIZEMAP: ImageSizeMap = Object.fromEntries(IMAGE_SIZES.map((x, i) => ( [IMAGE_SIZE_VARIANTS[i], x] ))); const IMAGE_FOCAL_X = 50; const IMAGE_FOCAL_Y = 50; const IMAGE_QUALITY = process.env.IMAGE_QUALITY ? parseInt(process.env.IMAGE_QUALITY) : 90; const IMAGE_STRATEGY: ImageStrategy = process.env.IMAGE_STRATEGY ? process.env.IMAGE_STRATEGY as ImageStrategy : ImageStrategy.Mixer; export type MediaSizeVariant = SizeVariant; export type MediaSizeVariants = ImageSizeMap; export type MediaSize = MediaSizeVariants | MediaSizeVariant | number; export type MediaRatio = Record; export function isMediaSizeVariant(item: unknown): item is MediaSizeVariant { return typeof item === 'string'; // return typeof item === 'string' && IMAGE_SIZE_VARIANTS.includes(item); } export function isMediaSizeVariants(item: unknown): item is MediaSizeVariants { return isObject(item) && Object.entries(item).reduce((p, [k, v]) => p && typeof v === 'number', true); // return Array.isArray(item) && item.length > 0 && item.reduce((p, c) => p && typeof c === 'number', true); } const StyledMediaImage = styled.img` max-width: 100%; user-select: none; color: transparent; `; function getMediaSizes( mediaQuery: IThemeResponsive, ratio: MediaRatio, size?: MediaSize, maxWidth: number = Number.POSITIVE_INFINITY, maxHeight: number = Number.POSITIVE_INFINITY ): [number, number, number][] { // console.log('getMediaSizes', mediaQuery, ratio, size, maxWidth, maxHeight); let sizes: [number, number, number][] = []; if (size) { if (typeof size === 'number') { const s: number = size; // console.log('typeof size === \'number\'', size); sizes = Object.entries(ratio).map(([k, v]) => { const w = IMAGE_SIZEMAP[k as keyof typeof IMAGE_SIZEMAP]; return [ (w && s > w) ? w : s, parseInt(String(mediaQuery[k])), v, ]; }).filter(x => x[0] >= x[1]) as [number, number, number][]; // console.log(size, 'sizes', sizes); } else if (isMediaSizeVariant(size)) { // console.log('isMediaSizeVariant'); const sizeLimit = IMAGE_SIZEMAP[size] || 0; // console.log(size, ratio[size], sizeLimit); sizes = Object.entries(IMAGE_SIZEMAP).filter(([k, v]) => ( v <= sizeLimit )).map(([k, v]) => [ v, parseInt(String(mediaQuery[k])), ratio[k as SizeVariant], ]); } } else { size = size || IMAGE_SIZEMAP; // console.log('size || IMAGE_SIZEMAP', size, IMAGE_SIZEMAP); sizes = Object.entries(size).map(([k, v]) => [ v, parseInt(String(mediaQuery[k])), ratio[k as SizeVariant], ]) as [number, number, number][]; } // console.log(size, 'sizes', sizes, ratio); sizes = sizes.filter((size, i) => i === 0 || ( size[0] <= maxWidth && ( size[2] === 0 || size[0] / size[2] <= maxHeight ) )); // console.log(sizes[0][2] ? sizes[0][0] / sizes[0][2] : 0, maxHeight); return sizes; } function getImageProps( { src, ...props }: Omit, mediaQuery: IThemeResponsive ) { if (IMAGE_STRATEGY === ImageStrategy.None || !isValidSrc(src)) { return { src, ...props, }; } const { width, height, size, ...otherProps } = props; let useSize = size; let useWidth: number | undefined = undefined; if (typeof width === 'string' && width.indexOf('px') !== -1) { useWidth = parseInt(width); useSize = useWidth; } else if (typeof width === 'number') { useWidth = width; } let useHeight: number | undefined = undefined; if (typeof height === 'string' && height.indexOf('px') !== -1) { useHeight = parseInt(height); } else if (typeof height === 'number') { useHeight = height; } const ratio = getMediaRatio(useWidth, useHeight, props.aspectRatio, props.aspectRatioSm, props.aspectRatioMd, props.aspectRatioLg, props.aspectRatioXl); const sizes = getMediaSizes(mediaQuery, ratio, useSize, props.mediaWidth, props.mediaHeight); const imageSrc = getImageSrc(src, sizes[0][0], sizes[0][2], props.mediaFocalX, props.mediaFocalY); const imageSrcSet = getImageSrcset(src, sizes, props.mediaFocalX, props.mediaFocalY); const imageSizes = getImageSizes(sizes); // console.log('useSize', useSize, 'width', width, useWidth, 'height', height, useHeight); return { src: imageSrc, srcSet: imageSrcSet, sizes: imageSizes, width: useWidth ? `${useWidth}px` : undefined, height: useHeight ? `${useHeight}px` : undefined, ...otherProps, }; } function getMediaRatio( width: number | undefined, height: number | undefined, aspectRatio: CSSProperties['aspectRatio'], aspectRatioSm: CSSProperties['aspectRatio'], aspectRatioMd: CSSProperties['aspectRatio'], aspectRatioLg: CSSProperties['aspectRatio'], aspectRatioXl: CSSProperties['aspectRatio'] ): MediaRatio { // console.log('getMediaRatio', width, height, aspectRatio); const defaultRatio = width && height ? (width / height) : 0; const xs = getRatio(aspectRatio, defaultRatio); const sm = getRatio(aspectRatioSm, xs); const md = getRatio(aspectRatioMd, sm); const lg = getRatio(aspectRatioLg, md); const xl = getRatio(aspectRatioXl, lg); const ratio: MediaRatio = { xs, sm, md, lg, xl, }; return ratio; } function getRatio(aspectRatio?: CSSProperties['aspectRatio'], defaultRatio = 0): number { return typeof aspectRatio === 'string' ? parseFloat(aspectRatio) : (aspectRatio || defaultRatio); } const BASE_URLS = { [ImageStrategy.Mixer]: '/api/_image', [ImageStrategy.Token]: '/api/_image', [ImageStrategy.Next]: '/_next/image', [ImageStrategy.Oven]: getRemoteApiUrl('/_image'), }; const BASE_URL = BASE_URLS[IMAGE_STRATEGY as keyof typeof BASE_URLS]; function parseUrl(src: string) { if (src.indexOf('http') === 0) { const url = new URL(src); return url; } const pathname = src.split('/').map(x => encodeURIComponent(x)).join('/'); const url = { origin: '', pathname, search: '', href: pathname }; return url; } function getImageSrc(src: string, width: number, ratio: number, focalX: number = IMAGE_FOCAL_X, focalY: number = IMAGE_FOCAL_Y, quality: number = IMAGE_QUALITY): string { if (!isValidSrc(src)) { return src; } switch (IMAGE_STRATEGY) { case ImageStrategy.Token: { const { origin, pathname, search, href } = parseUrl(src); const imageResize: IImageResize = ratio !== 0 ? { w: width, h: Math.round(width / ratio), x: focalX, y: focalY, q: quality, src: href, } : { w: width, q: quality, src: href, }; const token = encryptImageToken(imageResize); return `${BASE_URL}${pathname}?origin=${encodeURIComponent(origin)}&search=${encodeURIComponent(search)}&t=${encodeURIComponent(token)}`; } case ImageStrategy.Mixer: case ImageStrategy.Oven: { const { origin, pathname, search } = parseUrl(src); return ratio !== 0 ? `${BASE_URL}${pathname}?origin=${encodeURIComponent(origin)}&search=${encodeURIComponent(search)}&w=${width}&h=${Math.round(width / ratio)}&x=${focalX}&y=${focalY}&q=${quality}` : `${BASE_URL}${pathname}?origin=${encodeURIComponent(origin)}&search=${encodeURIComponent(search)}&w=${width}&x=${focalX}&y=${focalY}&q=${quality}`; } default: return ratio !== 0 ? `${BASE_URL}?url=${encodeURIComponent(src)}&w=${width}&h=${Math.round(width / ratio)}&q=${quality}` : `${BASE_URL}?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`; } } function getImageSrcset(src: string, sizes: [number, number, number][], focalX: number = IMAGE_FOCAL_X, focalY: number = IMAGE_FOCAL_Y, quality: number = IMAGE_QUALITY): string | undefined { if (!isValidSrc(src)) { return undefined; } return sizes.length > 1 ? sizes.map(size => getImageSrc(src, size[0], size[2], focalX, focalY, quality) + ` ${size[0]}w`).join(', ') : undefined; } function getImageSizes( sizes: [number, number, number][] ): string | undefined { return sizes.length > 1 ? sizes.map((size, i) => `${i < sizes.length - 1 ? `(max-width: ${sizes[i + 1][1] - 1}px) ` : ''}${size[0]}px`).join(', ') : undefined; } function isValidSrc(src?: string): src is string { // undefined if (!src) { return false; } // svg if (isSVG(src)) { return false; } // gif if (isGIF(src)) { return false; } // data image if (src.indexOf('data:') === 0) { return false; } // valid return true; } function isSVG(src: string): boolean { return src.indexOf('.svg') !== -1; } function isGIF(src: string): boolean { return src.indexOf('.gif') !== -1; } export type MediaImageProps = UIStyledComponentProps; const MediaImage_ = ({ className, loading = 'lazy', onLoaded, canLoad = true, mimeType, ...props }: MediaImageProps) => { const theme = useContext(ThemeContext); if (!theme) { throw new Error('MediaImage must be used within ThemeProvider'); } const imageRef = useRef(null); const classNames = getClassNames(className, 'image', props.src && { 'image-svg': isSVG(props.src) }); const imageProps = getImageProps(props, theme.mediaQuery); useEffect(() => { if (imageRef.current && canLoad) { const image = imageRef.current; if (image.complete) { if (typeof onLoaded === 'function') { onLoaded(imageRef as React.RefObject); } } else { const onLoad = () => { if (typeof onLoaded === 'function') { onLoaded(imageRef as React.RefObject); } }; image.addEventListener('load', onLoad); return () => { if (image) { image.removeEventListener('load', onLoad); } }; } } return () => { }; }, [canLoad, onLoaded]); return ( ); }; export const MediaImage = memo(MediaImage_);