/** * Image.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * Web-specific implementation of the cross-platform Image abstraction. */ import * as PropTypes from 'prop-types'; import * as React from 'react'; import assert from '../common/assert'; import { DEFAULT_RESIZE_MODE } from '../common/Image'; import { Types } from '../common/Interfaces'; import { Defer } from '../common/utils/PromiseDefer'; import * as _ from './utils/lodashMini'; import restyleForInlineText from './utils/restyleForInlineText'; import Styles from './Styles'; const _styles = { image: { position: 'absolute', display: 'flex', flexDirection: 'column', opacity: 0, maxWidth: '100%', maxHeight: '100%' }, defaultContainer: Styles.createImageStyle({ position: 'relative', flex: 0, overflow: 'visible', backgroundColor: 'transparent' }) }; export interface ImageState { showImgTag: boolean; xhrRequest: boolean; displayUrl: string; } export interface ImageContext { isRxParentAText?: boolean; } interface XhrBlobUrlCacheEntry { xhrBlobUrl: string; insertionDate: number; refCount: number; } class XhrBlobUrlCache { // Use a global cache to work around the image loading delays introduced by the xhr requests. This is especially // visible when scrolling a virtual list view which contains xhr images. private static _maximumItems = 128; private static _cachedXhrBlobUrls: { [source: string]: XhrBlobUrlCacheEntry } = {}; static get(source: string): string | undefined { if (this._cachedXhrBlobUrls[source]) { this._cachedXhrBlobUrls[source].refCount++; return this._cachedXhrBlobUrls[source].xhrBlobUrl; } return undefined; } static insert(source: string, xhrBlobUrl: string) { XhrBlobUrlCache._cleanupIfCapacityExceeded(); if (this._cachedXhrBlobUrls[source]) { XhrBlobUrlCache._cachedXhrBlobUrls[source].refCount++; } else { const xhrBlobUrlCacheEntry: XhrBlobUrlCacheEntry = { xhrBlobUrl: xhrBlobUrl, insertionDate: Date.now(), refCount: 1 }; XhrBlobUrlCache._cachedXhrBlobUrls[source] = xhrBlobUrlCacheEntry; } } static release(source: string) { // Keep track of which cache entries are being used as we don't want to clean up a resource that someone is // still relying on. if (this._cachedXhrBlobUrls[source]) { XhrBlobUrlCache._cachedXhrBlobUrls[source].refCount--; } } private static _cleanupIfCapacityExceeded() { // If we've reached maximum capacity, clean up the oldest freeable cache entry if any. An entry is freeable is // it's not currently in use (refCount == 0). Return whether we have room to add more entries to the cache. if (Object.keys(XhrBlobUrlCache._cachedXhrBlobUrls).length + 1 > XhrBlobUrlCache._maximumItems) { let oldestFreeableKey: string | undefined; let oldestFreeableEntry: XhrBlobUrlCacheEntry | undefined; Object.keys(XhrBlobUrlCache._cachedXhrBlobUrls).forEach(key => { if ((!oldestFreeableEntry || XhrBlobUrlCache._cachedXhrBlobUrls[key].insertionDate < oldestFreeableEntry.insertionDate) && XhrBlobUrlCache._cachedXhrBlobUrls[key].refCount === 0) { oldestFreeableKey = key; oldestFreeableEntry = XhrBlobUrlCache._cachedXhrBlobUrls[key]; } }); if (oldestFreeableKey) { URL.revokeObjectURL(oldestFreeableEntry!.xhrBlobUrl); delete XhrBlobUrlCache._cachedXhrBlobUrls[oldestFreeableKey]; } } } } export class Image extends React.Component { static contextTypes: React.ValidationMap = { isRxParentAText: PropTypes.bool }; // Provided by super, just re-typing here context!: ImageContext; static childContextTypes: React.ValidationMap = { isRxParentAText: PropTypes.bool.isRequired }; private _mountedComponent: HTMLImageElement | null = null; getChildContext() { // Let descendant RX components know that their nearest RX ancestor is not an RX.Text. // Because they're in an RX.Image, they should use their normal styling rather than their // special styling for appearing inline with text. return { isRxParentAText: false }; } static prefetch(url: string): Promise { const defer = new Defer(); const img = new window.Image(); img.onload = ((event: Event) => { defer.resolve(true); }); img.onerror = ((event: Event) => { defer.reject('Failed to prefetch url ' + url); }); img.onabort = ((event: Event) => { defer.reject('Prefetch cancelled for url ' + url); }); img.src = url; return defer.promise(); } static getMetadata(url: string): Promise { const defer = new Defer(); const img = new window.Image(); img.onload = ((event: Event) => { defer.resolve({ width: img.naturalWidth, height: img.naturalHeight }); }); img.onerror = ((event: Event) => { defer.reject('Failed to prefetch url ' + url); }); img.onabort = ((event: Event) => { defer.reject('Prefetch cancelled for url ' + url); }); img.src = url; return defer.promise(); } private _isMounted = false; private _nativeImageWidth: number | undefined; private _nativeImageHeight: number | undefined; constructor(props: Types.ImageProps) { super(props); const performXhrRequest = this._initializeAndSetState(props, true); if (performXhrRequest) { this._startXhrImageFetch(props); } } UNSAFE_componentWillReceiveProps(nextProps: Types.ImageProps) { const sourceOrHeaderChanged = (nextProps.source !== this.props.source || !_.isEqual(nextProps.headers || {}, this.props.headers || {})); if (!nextProps.onLoad !== !this.props.onLoad || !nextProps.onError !== !this.props.onError || sourceOrHeaderChanged) { const performXhrRequest = this._initializeAndSetState(nextProps, false); if (sourceOrHeaderChanged && performXhrRequest) { this._startXhrImageFetch(nextProps); } } } componentDidMount() { this._isMounted = true; } componentWillUnmount() { this._isMounted = false; if (this.state.displayUrl && this.state.xhrRequest) { XhrBlobUrlCache.release(this.props.source); } } private _initializeAndSetState(props: Types.ImageProps, initial: boolean): boolean { // Retrieve the xhr blob url from the cache if it exists. This is a performance optimization as we've seen xhr // requests take some time and cause flicker during rendering. Even when we're hitting the browser cache, we've // seen it stall and take some time. const cachedXhrBlobUrl = props.headers ? XhrBlobUrlCache.get(props.source) : null; const displayUrl = !!cachedXhrBlobUrl ? cachedXhrBlobUrl : !!props.headers ? '' : props.source; // Only make the xhr request if headers are specified and there was no cache hit. const performXhrRequest = !!props.headers && !cachedXhrBlobUrl; // We normally don't show an img tag because we use background images. However, if the caller has supplied an // onLoad or onError callback, we'll use the img tag until we receive an onLoad or onError. But if we need to // perform an XHR first to convert to a blob url, then wait on showing the img tag until we get the blob url // since the basic IMG tag will fail to load it without headers. const newState: ImageState = { showImgTag: (!performXhrRequest || !!cachedXhrBlobUrl) && (!!props.onLoad || !!props.onError), xhrRequest: !!props.headers, displayUrl: displayUrl }; if (initial) { this.state = newState; } else { this.setState(newState); } return performXhrRequest; } private _handleXhrBlob(blob: Blob) { if (!this._isMounted) { return; } const newUrl = URL.createObjectURL(blob); // Save the newly fetched xhr blob url in the cache. XhrBlobUrlCache.insert(this.props.source, newUrl); this.setState({ displayUrl: newUrl, // If we have an onload handler, we need to now load the img tag to get dimensions for the load. showImgTag: !!this.props.onLoad }); } private _startXhrImageFetch(props: Types.ImageProps) { // Test hook to simulate a slower hxr request. // Timers.setTimeout(() => this._actuallyStartXhrImageFetch(props), 2500); this._actuallyStartXhrImageFetch(props); } private _actuallyStartXhrImageFetch(props: Types.ImageProps) { // Fetch Implementation // If an 'origin' header is passed, we assume this is intended to be a crossorigin request. // In order to send the cookies with the request, the withCredentials: true / credentials: 'include' flag needs to be set. const withCredentials = props.headers && Object.keys(props.headers).some(header => header.toLowerCase() === 'origin'); if (window.fetch) { const headers = new Headers(); if (props.headers) { Object.keys(props.headers).forEach(key => { headers.append(key, props.headers![key]); }); } const xhr = new Request(props.source, { credentials: withCredentials ? 'include' : 'same-origin', method: 'GET', mode: 'cors', headers }); fetch(xhr) .then(response => { if (!response.ok) { this._onError(new Error(response.statusText)); } return response.blob().then(blob => { this._handleXhrBlob(blob); }); }, (err: Error) => { this._onError(err); }); } else { const req = new XMLHttpRequest(); req.open('GET', props.source, true); if (withCredentials) { req.withCredentials = true; } req.responseType = 'blob'; if (props.headers) { Object.keys(props.headers).forEach(key => { req.setRequestHeader(key, props.headers![key]); }); } req.onload = () => { if (req.status >= 400 || req.status < 600) { this._onError(new Error(req.statusText)); } else { this._handleXhrBlob(req.response as Blob); } }; req.onerror = () => { this._onError(new Error('Network issue downloading the image.')); }; req.send(); } } render() { const { source } = this.props; const isSourceValid = !(typeof source !== 'string' && typeof source !== 'undefined'); // Prepare image source (necessary as iOS implementation also allows objects) assert(isSourceValid, `Types/web/Image only accepts string sources! You passed: ${ source } of type ${ typeof source }`); let optionalImg: JSX.Element | null = null; if (this.state.showImgTag) { optionalImg = ( { ); } const reactElement = (
{ optionalImg } { this.props.children }
); return this.context.isRxParentAText ? restyleForInlineText(reactElement) : reactElement; } protected _onMount = (component: HTMLImageElement | null) => { this._mountedComponent = component; } private _getStyles(): React.CSSProperties { const { resizeMode } = this.props; const styles = (Styles.combine([_styles.defaultContainer, this.props.style]) || {}) as React.CSSProperties; const backgroundRepeat = resizeMode === 'repeat' ? 'repeat' : 'no-repeat'; const backgroundSize = this._buildBackgroundSize(resizeMode); // It is necessary to wrap the url in quotes as in url("a.jpg?q=(a and b)"). // If the url is unquoted and contains paranthesis, e.g. a.jpg?q=(a and b), it will become url(a.jpg?q=(a and b)) // which will not render on the screen. const backgroundImage = `url("${ this.state.displayUrl }")`; // Types doesn't support border styles other than "solid" for images. const borderStyle = styles.borderWidth ? 'solid' : 'none'; return { ...styles, backgroundPosition: 'center center', backgroundRepeat, backgroundImage, backgroundSize, borderStyle, display: 'flex' }; } private _buildBackgroundSize(resizeMode: Types.ImageResizeMode = DEFAULT_RESIZE_MODE): string { switch (resizeMode) { case 'repeat': return 'auto'; case 'stretch': return '100% 100%'; // contain | cover | auto are valid BackgroundSize values case 'contain': case 'cover': case 'auto': return resizeMode as string; // Prevents unknown resizeMode values default: return DEFAULT_RESIZE_MODE as string; } } private _onLoad = () => { if (!this._isMounted || !this._mountedComponent) { return; } const imageDOM = this._mountedComponent; // Measure the natural width & height of the image. this._nativeImageWidth = undefined; this._nativeImageHeight = undefined; this._nativeImageWidth = imageDOM.naturalWidth; this._nativeImageHeight = imageDOM.naturalHeight; // We can hide the img now. We assume that if the img. URL resolved without error, // then the background img. URL also did. this.setState({ showImgTag: false }); if (this.props.onLoad) { this.props.onLoad({ width: this._nativeImageWidth, height: this._nativeImageHeight }); } } private _imgOnError = () => { this._onError(); } private _onError(err?: Error) { if (!this._isMounted) { return; } // We can hide the img now. We assume that if the img. URL failed to resolve, // then the background img. URL also did. this.setState({ showImgTag: false }); if (this.props.onError) { this.props.onError(err); } } private _onMouseUp = (e: React.MouseEvent) => { if (e.button === 0) { // Types.Image doesn't officially support an onClick prop, but when it's // contained within a button, it may have this prop. const onClick: (e: Types.MouseEvent) => void = (this.props as any).onClick; if (onClick) { onClick(e); } } } // Note: This works only if you have an onLoaded handler and wait for the image to load. getNativeWidth(): number | undefined { return this._nativeImageWidth; } getNativeHeight(): number | undefined { return this._nativeImageHeight; } } export default Image;