1import React, { useState, useRef } from 'react';
2
3import { ImageProps, ImageSource } from '../Image.types';
4import { isBlurhashString, isThumbhashString } from '../utils/resolveSources';
5
6function findBestSourceForSize(
7  sources: ImageSource[] | undefined,
8  size: DOMRect | null
9): ImageSource | null {
10  if (sources?.length === 1) {
11    return sources[0];
12  }
13  return (
14    [...(sources || [])]
15      // look for the smallest image that's still larger then a container
16      ?.map((source) => {
17        if (!size) {
18          return { source, penalty: 0, covers: false };
19        }
20        const { width, height } =
21          typeof source === 'object' ? source : { width: null, height: null };
22        if (width == null || height == null) {
23          return { source, penalty: 0, covers: false };
24        }
25        if (width < size.width || height < size.height) {
26          return {
27            source,
28            penalty: Math.max(size.width - width, size.height - height),
29            covers: false,
30          };
31        }
32        return { source, penalty: (width - size.width) * (height - size.height), covers: true };
33      })
34      .sort((a, b) => a.penalty - b.penalty)
35      .sort((a, b) => Number(b.covers) - Number(a.covers))[0]?.source ?? null
36  );
37}
38
39export interface SrcSetSource extends ImageSource {
40  srcset: string;
41  sizes: string;
42  // used as key and a fallback in case srcset is not supported
43  uri: string;
44  type: 'srcset';
45}
46
47function getCSSMediaQueryForSource(source: ImageSource) {
48  return `(max-width: ${source.webMaxViewportWidth ?? source.width}px) ${source.width}px`;
49}
50
51function selectSource(
52  sources: ImageSource[] | undefined,
53  size: DOMRect | null,
54  responsivePolicy: ImageProps['responsivePolicy']
55): ImageSource | SrcSetSource | null {
56  if (sources == null || sources.length === 0) {
57    return null;
58  }
59
60  if (responsivePolicy !== 'static') {
61    return findBestSourceForSize(sources, size);
62  }
63  const staticSupportedSources = sources
64    .filter(
65      (s) => s.uri && s.width != null && !isBlurhashString(s.uri) && !isThumbhashString(s.uri)
66    )
67    .sort(
68      (a, b) => (a.webMaxViewportWidth ?? a.width ?? 0) - (b.webMaxViewportWidth ?? b.width ?? 0)
69    );
70
71  if (staticSupportedSources.length === 0) {
72    console.warn(
73      "You've set the `static` responsivePolicy but none of the sources have the `width` properties set. Make sure you set both `width` and `webMaxViewportWidth` for best results when using static responsiveness. Falling back to the `initial` policy."
74    );
75    return findBestSourceForSize(sources, size);
76  }
77
78  const srcset = staticSupportedSources
79    ?.map((source) => `${source.uri} ${source.width}w`)
80    .join(', ');
81  const sizes = `${staticSupportedSources
82    ?.map(getCSSMediaQueryForSource)
83    .join(', ')}, ${staticSupportedSources[staticSupportedSources.length - 1]?.width}px`;
84  return {
85    srcset,
86    sizes,
87    uri: staticSupportedSources[staticSupportedSources.length - 1]?.uri ?? '',
88    type: 'srcset',
89  };
90}
91
92type UseSourceSelectionReturn = {
93  containerRef: (element: HTMLDivElement) => void;
94  source: ImageSource | SrcSetSource | null;
95};
96
97export default function useSourceSelection(
98  sources?: ImageSource[],
99  responsivePolicy: ImageProps['responsivePolicy'] = 'static',
100  measurementCallback?: (target: HTMLElement, size: DOMRect) => void
101): UseSourceSelectionReturn {
102  const hasMoreThanOneSource = (sources?.length ?? 0) > 1;
103  // null - not calculated yet, DOMRect - size available
104  const [size, setSize] = useState<null | DOMRect>(null);
105  const resizeObserver = useRef<ResizeObserver | null>(null);
106
107  React.useEffect(() => {
108    return () => {
109      resizeObserver.current?.disconnect();
110    };
111  }, []);
112
113  const containerRef = React.useCallback(
114    (element: HTMLDivElement) => {
115      // we can't short circuit here since we need to read the size for better animated transitions
116      if (!hasMoreThanOneSource && !measurementCallback) {
117        return;
118      }
119      const rect = element?.getBoundingClientRect();
120      measurementCallback?.(element, rect);
121      setSize(rect);
122
123      if (responsivePolicy === 'live') {
124        resizeObserver.current?.disconnect();
125        if (!element) {
126          return;
127        }
128        resizeObserver.current = new ResizeObserver((entries) => {
129          setSize(entries[0].contentRect);
130          measurementCallback?.(entries[0].target as any, entries[0].contentRect);
131        });
132        resizeObserver.current.observe(element);
133      }
134    },
135    [hasMoreThanOneSource, responsivePolicy]
136  );
137
138  const source = selectSource(sources, size, responsivePolicy);
139
140  return React.useMemo(
141    () => ({
142      containerRef,
143      source,
144    }),
145    [source]
146  );
147}
148