diff --git a/ui/component/optimizedImage/index.js b/ui/component/optimizedImage/index.js new file mode 100644 index 000000000..0ec9fdc1a --- /dev/null +++ b/ui/component/optimizedImage/index.js @@ -0,0 +1,2 @@ +import OptimizedImage from './view'; +export default OptimizedImage; diff --git a/ui/component/optimizedImage/view.jsx b/ui/component/optimizedImage/view.jsx new file mode 100644 index 000000000..1cc8743f6 --- /dev/null +++ b/ui/component/optimizedImage/view.jsx @@ -0,0 +1,108 @@ +// @flow +import React from 'react'; +import { getThumbnailCdnUrl } from 'util/thumbnail'; + +function scaleToDevicePixelRatio(value: number, window: any) { + const devicePixelRatio = window.devicePixelRatio || 1.0; + return Math.ceil(value * devicePixelRatio); +} + +type Props = { + src: string, + objectFit?: string, +}; + +function OptimizedImage(props: Props) { + const { objectFit, src, ...imgProps } = props; + const [optimizedSrc, setOptimizedSrc] = React.useState(''); + const ref = React.useRef(); + + function getOptimizedImgUrl(url, width, height) { + let optimizedUrl = url; + if (url && !url.startsWith('/public/')) { + optimizedUrl = url.trim().replace(/^http:\/\//i, 'https://'); + + // @if TARGET='web' + if (!optimizedUrl.endsWith('.gif')) { + optimizedUrl = getThumbnailCdnUrl({ thumbnail: optimizedUrl, width, height, quality: 85 }); + } + // @endif + } + return optimizedUrl; + } + + function getOptimumSize(elem) { + if (!elem || !elem.parentElement || !elem.parentElement.clientWidth || !elem.parentElement.clientHeight) { + return null; + } + + let width = elem.parentElement.clientWidth; + let height = elem.parentElement.clientHeight; + + width = scaleToDevicePixelRatio(width, window); + height = scaleToDevicePixelRatio(height, window); + + // Round to next 100px for better caching + width = Math.ceil(width / 100) * 100; + height = Math.ceil(height / 100) * 100; + + // Reminder: CDN expects integers. + return { width, height }; + } + + function adjustOptimizationIfNeeded(elem, objectFit, origSrc) { + if (objectFit === 'cover' && elem) { + const containerSize = getOptimumSize(elem); + if (containerSize) { + // $FlowFixMe + if (elem.naturalWidth < containerSize.width) { + // For 'cover', we don't want to stretch the image. We started off by + // filling up the container height, but the width still has a gap for + // this instance (usually due to aspect ratio mismatch). + // If the original image is much larger, we can request for a larger + // image so that "objectFit=cover" will center it without stretching and + // making it blur. The double fetch might seem wasteful, but on + // average the total transferred bytes is still less than the original. + const probablyMaxedOut = elem.naturalHeight < containerSize.height; + if (!probablyMaxedOut) { + const newOptimizedSrc = getOptimizedImgUrl(origSrc, containerSize.width, 0); + if (newOptimizedSrc && newOptimizedSrc !== optimizedSrc) { + setOptimizedSrc(newOptimizedSrc); + } + } + } + } + } + } + + React.useEffect(() => { + const containerSize = getOptimumSize(ref.current); + if (containerSize) { + const width = 0; // The CDN will fill the zeroed attribute per image's aspect ratio. + const height = containerSize.height; + + const newOptimizedSrc = getOptimizedImgUrl(src, width, height); + + if (newOptimizedSrc !== optimizedSrc) { + setOptimizedSrc(newOptimizedSrc); + } + } else { + setOptimizedSrc(src); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (!src) { + return null; + } + + return ( + adjustOptimizationIfNeeded(ref.current, objectFit, src)} + /> + ); +} + +export default OptimizedImage;