woocommerce/plugins/woocommerce-admin/client/customize-store/transitional/mshots-image.tsx

216 lines
6.4 KiB
TypeScript

// 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 ? (
<div
className={ className }
style={ style }
aria-labelledby={ labelledby }
/>
) : (
<img
{ ...{ className, style, src, alt } }
aria-labelledby={ labelledby }
alt={ alt }
/>
);
};
export default MShotsImage;