1import React, { CSSProperties, SyntheticEvent, useEffect, Ref, useMemo } from 'react'; 2 3import { 4 ImageContentPositionObject, 5 ImageContentPositionValue, 6 ImageNativeProps, 7 ImageSource, 8} from '../Image.types'; 9import { useBlurhash } from '../utils/blurhash/useBlurhash'; 10import { isBlurhashString, isThumbhashString } from '../utils/resolveSources'; 11import { thumbHashStringToDataURL } from '../utils/thumbhash/thumbhash'; 12import { SrcSetSource } from './useSourceSelection'; 13 14function ensureUnit(value: string | number) { 15 const trimmedValue = String(value).trim(); 16 if (trimmedValue.endsWith('%')) { 17 return trimmedValue; 18 } 19 return `${trimmedValue}px`; 20} 21 22type KeysOfUnion<T> = T extends T ? keyof T : never; 23 24function getObjectPositionFromContentPositionObject( 25 contentPosition?: ImageContentPositionObject 26): string { 27 const resolvedPosition = { ...contentPosition } as Record< 28 KeysOfUnion<ImageContentPositionObject>, 29 ImageContentPositionValue 30 >; 31 if (!resolvedPosition) { 32 return '50% 50%'; 33 } 34 if (resolvedPosition.top == null && resolvedPosition.bottom == null) { 35 resolvedPosition.top = '50%'; 36 } 37 if (resolvedPosition.left == null && resolvedPosition.right == null) { 38 resolvedPosition.left = '50%'; 39 } 40 41 return ( 42 ['top', 'bottom', 'left', 'right'] 43 .map((key) => { 44 if (key in resolvedPosition) { 45 return `${key} ${ensureUnit(resolvedPosition[key])}`; 46 } 47 return ''; 48 }) 49 .join(' ') || '50% 50%' 50 ); 51} 52 53function getFetchPriorityFromImagePriority(priority: ImageNativeProps['priority'] = 'normal') { 54 return priority && ['low', 'high'].includes(priority) ? priority : 'auto'; 55} 56 57function getImgPropsFromSource(source: ImageSource | SrcSetSource | null | undefined) { 58 if (source && 'srcset' in source) { 59 return { 60 srcSet: source.srcset, 61 sizes: source.sizes, 62 }; 63 } 64 return {}; 65} 66 67const ImageWrapper = React.forwardRef( 68 ( 69 { 70 source, 71 events, 72 contentPosition, 73 hashPlaceholderContentPosition, 74 priority, 75 style, 76 hashPlaceholderStyle, 77 tintColor, 78 className, 79 accessibilityLabel, 80 ...props 81 }: { 82 source?: ImageSource | SrcSetSource | null; 83 events?: { 84 onLoad?: (((event: SyntheticEvent<HTMLImageElement, Event>) => void) | undefined | null)[]; 85 onError?: ((({ source }: { source: ImageSource | null }) => void) | undefined | null)[]; 86 onTransitionEnd?: ((() => void) | undefined | null)[]; 87 onMount?: ((() => void) | undefined | null)[]; 88 }; 89 contentPosition?: ImageContentPositionObject; 90 hashPlaceholderContentPosition?: ImageContentPositionObject; 91 priority?: string | null; 92 style: CSSProperties; 93 tintColor?: string | null; 94 hashPlaceholderStyle?: CSSProperties; 95 className?: string; 96 accessibilityLabel?: string; 97 }, 98 ref: Ref<HTMLImageElement> 99 ) => { 100 useEffect(() => { 101 events?.onMount?.forEach((e) => e?.()); 102 }, []); 103 const isBlurhash = isBlurhashString(source?.uri || ''); 104 const isThumbhash = isThumbhashString(source?.uri || ''); 105 const isHash = isBlurhash || isThumbhash; 106 107 // Thumbhash uri always has to start with 'thumbhash:/' 108 const thumbhash = source?.uri?.replace(/thumbhash:\//, ''); 109 const thumbhashUri = useMemo( 110 () => (isThumbhash ? thumbHashStringToDataURL(thumbhash ?? '') : null), 111 [thumbhash] 112 ); 113 114 const blurhashUri = useBlurhash(isBlurhash ? source?.uri : null, source?.width, source?.height); 115 if (!source) { 116 return null; 117 } 118 119 const objectPosition = getObjectPositionFromContentPositionObject( 120 isHash ? hashPlaceholderContentPosition : contentPosition 121 ); 122 123 const uri = isHash ? blurhashUri ?? thumbhashUri : source?.uri; 124 return ( 125 <> 126 {tintColor && ( 127 <svg> 128 <defs> 129 <filter id={`tint-${tintColor}`} x="0" y="0" width="0" height="0"> 130 <feFlood floodColor={tintColor} floodOpacity="1" result="flood" /> 131 <feComposite in="flood" in2="SourceAlpha" operator="in" /> 132 </filter> 133 </defs> 134 </svg> 135 )} 136 <img 137 ref={ref} 138 alt={accessibilityLabel} 139 className={className} 140 src={uri || undefined} 141 {...getImgPropsFromSource(source)} 142 key={source?.uri} 143 {...props} 144 style={{ 145 width: '100%', 146 height: '100%', 147 position: 'absolute', 148 left: 0, 149 right: 0, 150 objectPosition, 151 filter: tintColor ? `url(#tint-${tintColor})` : '', 152 ...style, 153 ...(isHash ? hashPlaceholderStyle : {}), 154 }} 155 // @ts-ignore 156 // eslint-disable-next-line react/no-unknown-property 157 fetchpriority={getFetchPriorityFromImagePriority(priority || 'normal')} 158 onLoad={(event) => { 159 if (typeof window !== 'undefined') { 160 // this ensures the animation will run, since the starting class is applied at least 1 frame before the target class set in the onLoad event callback 161 window.requestAnimationFrame(() => { 162 events?.onLoad?.forEach((e) => e?.(event)); 163 }); 164 } else { 165 events?.onLoad?.forEach((e) => e?.(event)); 166 } 167 }} 168 onTransitionEnd={() => events?.onTransitionEnd?.forEach((e) => e?.())} 169 onError={() => events?.onError?.forEach((e) => e?.({ source: source || null }))} 170 /> 171 </> 172 ); 173 } 174); 175 176export default ImageWrapper; 177