// See https://github.com/Automattic/wp-calypso/blob/6923bed938911a931e722a1efa6fcbbf942677a9/packages/onboarding/src/mshots-image/index.tsx // TODO: @automattic/onboarding is not published to npm, so we can't use it here. We'll need to add the "requeue" option to MShotsOptions and we could remove this when it's published. /** * External dependencies */ import { addQueryArgs } from '@wordpress/url'; import classnames from 'classnames'; import { useState, useEffect, useRef } from 'react'; interface MShotsImageProps { url: string; alt: string; 'aria-labelledby': string; options: MShotsOptions; scrollable?: boolean; } export type MShotsOptions = { vpw: number; vph: number; w: number; h?: number; screen_height?: number; format?: 'png' | 'jpeg'; requeue?: boolean; }; // eslint-disable-next-line no-console const debug = console.debug; export function getMshotsUrl( targetUrl: string, options: MShotsOptions, count = 0 ): string { const mshotsUrl = 'https://s0.wp.com/mshots/v1/'; const mshotsRequest = addQueryArgs( mshotsUrl + encodeURIComponent( targetUrl ), { ...options, count, } ); return mshotsRequest; } const MAXTRIES = 10; // This custom react hook returns undefined while the image is loading and // a HTMLImageElement (i.e. the class you get from `new Image()`) once loading // is complete. // // It also triggers a re-render (via setState()) when the value changes, so just // check if it's truthy and then treat it like any other Image. // // Note the loading may occur immediately and synchronously if the image is // already or may take up to several seconds if mshots has to generate and cache // new images. // // The calling code doesn't need to worry about the details except that you'll // want some sort of loading display. // // Inspired by https://stackoverflow.com/a/60458593 const useMshotsImg = ( src: string, options: MShotsOptions ): HTMLImageElement | undefined => { const [ loadedImg, setLoadedImg ] = useState< HTMLImageElement >(); const [ count, setCount ] = useState( 0 ); const previousSrc = useRef( src ); const imgRef = useRef< HTMLImageElement >(); const timeoutIdRef = useRef< number >(); const previousImg = useRef< HTMLImageElement >(); const previousOptions = useRef< MShotsOptions >(); // Oddly, we need to assign to current here after ref creation in order to // pass the equivalence check and avoid a spurious reset previousOptions.current = options; // Note: Mshots doesn't care about the "count" param, but it is important // to browser caching. Getting this wrong looks like the url resolving // before the image is ready. useEffect( () => { // If there's been a "props" change we need to reset everything: if ( options !== previousOptions.current || ( src !== previousSrc.current && imgRef.current ) ) { // Make sure an old image can't trigger a spurious state update debug( 'resetting mShotsUrl request' ); if ( src !== previousSrc.current ) { debug( 'src changed\nfrom', previousSrc.current, '\nto', src ); } if ( options !== previousOptions.current ) { debug( 'options changed\nfrom', previousOptions.current, '\nto', options ); } if ( previousImg.current && previousImg.current.onload ) { previousImg.current.onload = null; if ( timeoutIdRef.current ) { clearTimeout( timeoutIdRef.current ); timeoutIdRef.current = undefined; } } setLoadedImg( undefined ); setCount( 0 ); previousImg.current = imgRef.current; previousOptions.current = options; previousSrc.current = src; } const srcUrl = getMshotsUrl( src, options, count ); const newImage = new Image(); newImage.onload = () => { // Detect default image (Don't request a 400x300 image). // // If this turns out to be a problem, it might help to know that the // http request status for the default is a 307. Unfortunately we // don't get the request through an img element so we'd need to // take a completely different approach using ajax. if ( newImage.naturalWidth !== 400 || newImage.naturalHeight !== 300 ) { // Note we're using the naked object here, not the ref, because // this is the callback on the image itself. We'd never want // the image to finish loading and set some other image. setLoadedImg( newImage ); } else if ( count < MAXTRIES ) { // Only refresh 10 times // Triggers a target.src change with increasing timeouts timeoutIdRef.current = window.setTimeout( () => setCount( ( _count ) => _count + 1 ), count * 500 ); } }; newImage.src = srcUrl; imgRef.current = newImage; return () => { if ( imgRef.current && imgRef.current.onload ) { imgRef.current.onload = null; } clearTimeout( timeoutIdRef.current ); }; }, [ src, count, options ] ); return loadedImg; }; // For hover-scroll, we use a div with a background image (rather than an img element) // in order to use transitions between `top` and `bottom` on the // `background-position` property. // The "normal" top & bottom properties are problematic individually because we // don't know how big the images will be, and using both gets the // right positions but with no transition (as they're different properties). export const MShotsImage = ( { url, 'aria-labelledby': labelledby, alt, options, scrollable = false, }: MShotsImageProps ) => { const maybeImage = useMshotsImg( url, options ); const src: string = maybeImage?.src || ''; const visible = !! src; const backgroundImage = maybeImage?.src && `url( ${ maybeImage?.src } )`; const animationScrollSpeedInPixelsPerSecond = 400; const animationDuration = ( maybeImage?.naturalHeight || 600 ) / animationScrollSpeedInPixelsPerSecond; const scrollableStyles = { backgroundImage, transition: `background-position ${ animationDuration }s`, }; const style = { ...( scrollable ? scrollableStyles : {} ), }; const className = classnames( 'mshots-image__container', scrollable && 'hover-scroll', visible ? 'mshots-image-visible' : 'mshots-image__loader' ); // The "! visible" here is only to dodge a particularly specific css // rule effecting the placeholder while loading static images: // '.design-picker .design-picker__image-frame img { ..., height: auto }' return scrollable || ! visible ? (
) : ( ); }; export default MShotsImage;