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