import { PureComponent, ReactNode } from 'react'; import classnames from 'classnames'; import Box from './Box'; import styles from './Image.css'; type Fit = 'cover' | 'contain' | 'none'; const shouldScaleImage = (fit?: Fit | null) => fit === 'cover' || fit === 'contain'; type Props = { /** * Alt text read by screen readers. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/alt) for more details. */ alt: string; /** * Children content will be overlaid on the image. See the [Overlay example](https://gestalt.pinterest.systems/web/image#Overlay) for more details. */ children?: ReactNode; /** * Used as a visual placeholder while the image is loading. See the [Placeholders example](https://gestalt.pinterest.systems/web/image#placeholders) for more details. */ color: string; /** * Specifies the CORS setting to use when retrieving the image. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin) for more details. */ crossOrigin?: 'anonymous' | 'use-credentials'; /** * Sends a hint to the browser specifying whether or not it is allowed to try to parallelize loading your image. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decoding) for more details. */ decoding?: 'sync' | 'async' | 'auto'; /** * HTML attribute for performance profiling (see https://developer.mozilla.org/en-US/docs/Web/API/Element_timing_API). Note that it only works if the \`fit\` prop is not set to \`"cover"\` or \`"contain"\`. */ elementTiming?: string; /** * Priority hints provide developers a way to indicate a resource's relative importance to the browser, allowing more control over the order resources are loaded (only available via Chrome Origin Trial). \`"high"\`: the developer considers the resource to be high priority. \`"low"\`: the developer considers the resource to be low priority. \`auto\` the developer does not indicate a preference. * Note that this feature is currently experimental; please see the [attribute spec](https://wicg.github.io/priority-hints/) for more details. */ fetchPriority?: 'high' | 'low' | 'auto'; /** * Sets how the image is resized to fit its container. See the [Fit example](https://gestalt.pinterest.systems/web/image#fit) for more details. * Note: this doesn't work with srcSet or sizes. */ fit?: Fit; /** * Controls if loading the image should be deferred when it's off-screen. \`"lazy"\` defers the load until the image or iframe reaches a distance threshold from the viewport. \`"eager"\` loads the resource immediately. \`"auto"\` uses the default behavior, which is to eagerly load the resource. See the [Lazy example](https://gestalt.pinterest.systems/web/image#Lazy) for more details. */ loading?: 'eager' | 'lazy' | 'auto'; /** * Exact height of source image. See the [Dimensions example](https://gestalt.pinterest.systems/web/image#Dimensions) for more details. */ naturalHeight: number; /** * Exact width of source image. See the [Dimensions example](https://gestalt.pinterest.systems/web/image#Dimensions) for more details. */ naturalWidth: number; /** * Callback fired when the image loading has an error. */ onError?: (arg1: { event: React.SyntheticEvent }) => void; /** * Callback fired when the image successfully loads. */ onLoad?: (arg1: { event: React.SyntheticEvent }) => void; /** * When Image is used purely as a presentational or decorative addition, the \`role\` should be set to "presentation" for better accessibility. See the [Presentational Images with Role example](https://gestalt.pinterest.systems/web/image#Presentational-Images-with-Role) for more details. */ role?: 'img' | 'presentation'; /** * A comma-separated list of one or more strings indicating a set of source sizes. */ sizes?: string; /** * The URL for the image. */ src: string | undefined; /** * A comma-separated list of one or more strings indicating a set of possible image sources for the user agent to use. */ srcSet?: string; /** * Experimental prop: Fixes an issue where onLoad is not triggered if the image is already loaded by the time the component renders (i.e. SSR) */ _fixCompletedOnLoad?: boolean; }; /** * [Image](https://gestalt.pinterest.systems/web/image) is the workhorse of Pinterest. If you define Pinterest to be all about collecting ideas, then images are how we choose to represent those ideas. In response, we've added a few extra superpowers to the regular img tag to make it even more awesome. * * ![Image light mode](https://raw.githubusercontent.com/pinterest/gestalt/master/playwright/visual-test/Image.spec.ts-snapshots/Image-chromium-darwin.png) * ![Image dark mode](https://raw.githubusercontent.com/pinterest/gestalt/master/playwright/visual-test/Image-dark.spec.ts-snapshots/Image-dark-chromium-darwin.png) * */ export default class Image extends PureComponent { static defaultProps: { color: string; fetchPriority?: 'high' | 'low' | 'auto'; fit?: 'contain' | 'cover' | 'none'; loading?: 'eager' | 'lazy' | 'auto'; } = { color: 'transparent', fetchPriority: 'auto', fit: 'none', loading: 'auto', }; static displayName: string | null | undefined = 'Image'; onLoadCalled: boolean = false; componentDidMount() { if (shouldScaleImage(this.props.fit)) { this.loadImage(); } } componentDidUpdate(prevProps: Props) { const { fit, src } = this.props; if (shouldScaleImage(fit) && prevProps.src !== src) { this.loadImage(); } } handleLoad: (event: React.SyntheticEvent) => void = (event) => { this.onLoadCalled = true; this.props.onLoad?.({ event }); }; handleError: (event: React.SyntheticEvent) => void = (event) => { this.props.onError?.({ event }); }; loadImage() { if (typeof window !== 'undefined') { const image = new window.Image(); // @ts-expect-error - TS2322 - Type '(event: SyntheticEvent) => void' is not assignable to type '(this: GlobalEventHandlers, ev: Event) => any'. image.onload = this.handleLoad; // @ts-expect-error - TS2322 - Type '(event: SyntheticEvent) => void' is not assignable to type 'OnErrorEventHandler'. image.onerror = this.handleError; if (this.props.src) { image.src = this.props.src; } } } refCallback = (node: HTMLImageElement | null) => { const { _fixCompletedOnLoad } = this.props; // For certain scenarios, such as server-side rendering, the image may already be loaded by the time the component is rendered resulting in the onLoad event not being triggered. // To address these, we can use a ref callback and check whether the image is already loaded - if it is, we trigger the onLoad event manually. if (_fixCompletedOnLoad && node?.complete && !this.onLoadCalled) { // Since we don't have the SyntheticEvent here, // we must create one with the same shape. // See https://reactjs.org/docs/events.html const loadEvent = new Event('load'); Object.defineProperty(loadEvent, 'target', { writable: false, value: node }); this.handleLoad({ ...loadEvent, nativeEvent: loadEvent, currentTarget: node, target: node, isDefaultPrevented: () => false, isPropagationStopped: () => false, persist: () => {}, preventDefault: () => {}, stopPropagation: () => {}, }); } }; render() { const { alt, color, children, crossOrigin, decoding, elementTiming, fetchPriority, fit, loading, naturalHeight, naturalWidth, role = 'img', sizes, src, srcSet, } = this.props; const childContent = children ? ( {children} ) : null; const isScaledImage = shouldScaleImage(fit); const fitStyles = fit === 'cover' || fit === 'contain' ? styles.scaledImg : undefined; const imageStyles = classnames(styles.img, fitStyles); const elementTimingValue: { elementtiming?: string; } = elementTiming ? { elementtiming: elementTiming } : {}; const styleValue = isScaledImage ? { style: { objectFit: fit } } : {}; const conditionalProps = { ...elementTimingValue, ...styleValue, } as const; return ( {alt} {childContent} ); } }