216 lines
6.4 KiB
TypeScript
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;
|