/**
* Copyright (c) Nicolas Gallagher.
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import applyNativeMethods from '../../modules/applyNativeMethods';
import createElement from '../createElement';
import css from '../StyleSheet/css';
import { getAssetByID } from '../../modules/AssetRegistry';
import resolveShadowValue from '../StyleSheet/resolveShadowValue';
import ImageLoader from '../../modules/ImageLoader';
import ImageResizeMode from './ImageResizeMode';
import ImageSourcePropType from './ImageSourcePropType';
import ImageStylePropTypes from './ImageStylePropTypes';
import ImageUriCache from './ImageUriCache';
import StyleSheet from '../StyleSheet';
import StyleSheetPropType from '../../modules/StyleSheetPropType';
import View from '../View';
import ViewPropTypes from '../ViewPropTypes';
import { bool, func, number, oneOf, shape } from 'prop-types';
import React, { Component } from 'react';
const emptyObject = {};
const STATUS_ERRORED = 'ERRORED';
const STATUS_LOADED = 'LOADED';
const STATUS_LOADING = 'LOADING';
const STATUS_PENDING = 'PENDING';
const STATUS_IDLE = 'IDLE';
const getImageState = (uri, shouldDisplaySource) => {
return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE;
};
const resolveAssetDimensions = source => {
if (typeof source === 'number') {
const { height, width } = getAssetByID(source);
return { height, width };
} else if (typeof source === 'object') {
const { height, width } = source;
return { height, width };
}
};
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
const resolveAssetUri = source => {
let uri = '';
if (typeof source === 'number') {
// get the URI from the packager
const asset = getAssetByID(source);
const scale = asset.scales[0];
const scaleSuffix = scale !== 1 ? `@${scale}x` : '';
uri = asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : '';
} else if (typeof source === 'string') {
uri = source;
} else if (source && typeof source.uri === 'string') {
uri = source.uri;
}
if (uri) {
const match = uri.match(svgDataUriPattern);
// inline SVG markup may contain characters (e.g., #, ") that need to be escaped
if (match) {
const [, prefix, svg] = match;
const encodedSvg = encodeURIComponent(svg);
return `${prefix}${encodedSvg}`;
}
}
return uri;
};
let filterId = 0;
const createTintColorSVG = (tintColor, id) =>
tintColor && id != null ? (
<svg style={{ position: 'absolute', height: 0, visibility: 'hidden', width: 0 }}>
<defs>
<filter id={`tint-${id}`}>
<feFlood floodColor={`${tintColor}`} />
<feComposite in2="SourceAlpha" operator="atop" />
</filter>
</defs>
</svg>
) : null;
type State = {
layout: Object,
shouldDisplaySource: boolean
};
class Image extends Component<*, State> {
static displayName = 'Image';
static contextTypes = {
isInAParentText: bool
};
static propTypes = {
...ViewPropTypes,
blurRadius: number,
defaultSource: ImageSourcePropType,
draggable: bool,
onError: func,
onLayout: func,
onLoad: func,
onLoadEnd: func,
onLoadStart: func,
resizeMode: oneOf(Object.keys(ImageResizeMode)),
source: ImageSourcePropType,
style: StyleSheetPropType(ImageStylePropTypes),
// compatibility with React Native
/* eslint-disable react/sort-prop-types */
capInsets: shape({ top: number, left: number, bottom: number, right: number }),
resizeMethod: oneOf(['auto', 'resize', 'scale'])
/* eslint-enable react/sort-prop-types */
};
static defaultProps = {
style: emptyObject
};
static getSize(uri, success, failure) {
ImageLoader.getSize(uri, success, failure);
}
static prefetch(uri) {
return ImageLoader.prefetch(uri).then(() => {
// Add the uri to the cache so it can be immediately displayed when used
// but also immediately remove it to correctly reflect that it has no active references
ImageUriCache.add(uri);
ImageUriCache.remove(uri);
});
}
static queryCache(uris) {
const result = {};
uris.forEach(u => {
if (ImageUriCache.has(u)) {
result[u] = 'disk/memory';
}
});
return Promise.resolve(result);
}
_filterId = 0;
_imageRef = null;
_imageRequestId = null;
_imageState = null;
_isMounted = false;
constructor(props, context) {
super(props, context);
// If an image has been loaded before, render it immediately
const uri = resolveAssetUri(props.source);
const shouldDisplaySource = ImageUriCache.has(uri);
this.state = { layout: {}, shouldDisplaySource };
this._imageState = getImageState(uri, shouldDisplaySource);
this._filterId = filterId;
filterId++;
}
componentDidMount() {
this._isMounted = true;
if (this._imageState === STATUS_PENDING) {
this._createImageLoader();
} else if (this._imageState === STATUS_LOADED) {
this._onLoad({ target: this._imageRef });
}
}
componentDidUpdate(prevProps) {
const prevUri = resolveAssetUri(prevProps.source);
const uri = resolveAssetUri(this.props.source);
const hasDefaultSource = this.props.defaultSource != null;
if (prevUri !== uri) {
ImageUriCache.remove(prevUri);
const isPreviouslyLoaded = ImageUriCache.has(uri);
isPreviouslyLoaded && ImageUriCache.add(uri);
this._updateImageState(getImageState(uri, isPreviouslyLoaded), hasDefaultSource);
} else if (hasDefaultSource && prevProps.defaultSource !== this.props.defaultSource) {
this._updateImageState(this._imageState, hasDefaultSource);
}
if (this._imageState === STATUS_PENDING) {
this._createImageLoader();
}
}
componentWillUnmount() {
const uri = resolveAssetUri(this.props.source);
ImageUriCache.remove(uri);
this._destroyImageLoader();
this._isMounted = false;
}
render() {
const { shouldDisplaySource } = this.state;
const {
accessibilityLabel,
accessible,
blurRadius,
defaultSource,
draggable,
source,
testID,
/* eslint-disable */
capInsets,
onError,
onLayout,
onLoad,
onLoadEnd,
onLoadStart,
resizeMethod,
resizeMode,
/* eslint-enable */
...other
} = this.props;
if (process.env.NODE_ENV !== 'production') {
if (this.props.src) {
console.warn('The <Image> component requires a `source` property rather than `src`.');
}
if (this.props.children) {
throw new Error(
'The <Image> component cannot contain children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.'
);
}
}
const selectedSource = shouldDisplaySource ? source : defaultSource;
const displayImageUri = resolveAssetUri(selectedSource);
const imageSizeStyle = resolveAssetDimensions(selectedSource);
const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null;
const flatStyle = { ...StyleSheet.flatten(this.props.style) };
const finalResizeMode = resizeMode || flatStyle.resizeMode || ImageResizeMode.cover;
// CSS filters
const filters = [];
const tintColor = flatStyle.tintColor;
if (flatStyle.filter) {
filters.push(flatStyle.filter);
}
if (blurRadius) {
filters.push(`blur(${blurRadius}px)`);
}
if (flatStyle.shadowOffset) {
const shadowString = resolveShadowValue(flatStyle);
if (shadowString) {
filters.push(`drop-shadow(${shadowString})`);
}
}
if (flatStyle.tintColor) {
filters.push(`url(#tint-${this._filterId})`);
}
// these styles were converted to filters
delete flatStyle.shadowColor;
delete flatStyle.shadowOpacity;
delete flatStyle.shadowOffset;
delete flatStyle.shadowRadius;
delete flatStyle.tintColor;
// these styles are not supported on View
delete flatStyle.overlayColor;
delete flatStyle.resizeMode;
// Accessibility image allows users to trigger the browser's image context menu
const hiddenImage = displayImageUri
? createElement('img', {
alt: accessibilityLabel || '',
classList: [classes.accessibilityImage],
draggable: draggable || false,
ref: this._setImageRef,
src: displayImageUri
})
: null;
return (
<View
{...other}
accessibilityLabel={accessibilityLabel}
accessible={accessible}
onLayout={this._createLayoutHandler(finalResizeMode)}
style={[
styles.root,
this.context.isInAParentText && styles.inline,
imageSizeStyle,
flatStyle
]}
testID={testID}
>
<View
style={[
styles.image,
resizeModeStyles[finalResizeMode],
this._getBackgroundSize(finalResizeMode),
backgroundImage && { backgroundImage },
filters.length > 0 && { filter: filters.join(' ') }
]}
/>
{hiddenImage}
{createTintColorSVG(tintColor, this._filterId)}
</View>
);
}
_createImageLoader() {
const { source } = this.props;
this._destroyImageLoader();
const uri = resolveAssetUri(source);
this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError);
this._onLoadStart();
}
_destroyImageLoader() {
if (this._imageRequestId) {
ImageLoader.abort(this._imageRequestId);
this._imageRequestId = null;
}
}
_createLayoutHandler = resizeMode => {
const { onLayout } = this.props;
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
return e => {
const { layout } = e.nativeEvent;
onLayout && onLayout(e);
this.setState(() => ({ layout }));
};
}
};
_getBackgroundSize = resizeMode => {
if (this._imageRef && (resizeMode === 'center' || resizeMode === 'repeat')) {
const { naturalHeight, naturalWidth } = this._imageRef;
const { height, width } = this.state.layout;
if (naturalHeight && naturalWidth && height && width) {
const scaleFactor = Math.min(1, width / naturalWidth, height / naturalHeight);
const x = Math.ceil(scaleFactor * naturalWidth);
const y = Math.ceil(scaleFactor * naturalHeight);
return {
backgroundSize: `${x}px ${y}px`
};
}
}
};
_onError = () => {
const { onError, source } = this.props;
this._updateImageState(STATUS_ERRORED);
if (onError) {
onError({
nativeEvent: {
error: `Failed to load resource ${resolveAssetUri(source)} (404)`
}
});
}
this._onLoadEnd();
};
_onLoad = e => {
const { onLoad, source } = this.props;
const event = { nativeEvent: e };
ImageUriCache.add(resolveAssetUri(source));
this._updateImageState(STATUS_LOADED);
if (onLoad) {
onLoad(event);
}
this._onLoadEnd();
};
_onLoadEnd() {
const { onLoadEnd } = this.props;
if (onLoadEnd) {
onLoadEnd();
}
}
_onLoadStart() {
const { defaultSource, onLoadStart } = this.props;
this._updateImageState(STATUS_LOADING, defaultSource != null);
if (onLoadStart) {
onLoadStart();
}
}
_setImageRef = ref => {
this._imageRef = ref;
};
_updateImageState(status: ?string, hasDefaultSource: ?boolean = false) {
this._imageState = status;
const shouldDisplaySource =
this._imageState === STATUS_LOADED ||
(this._imageState === STATUS_LOADING && !hasDefaultSource);
// only triggers a re-render when the image is loading and has no default image (to support PJPEG), loaded, or failed
if (shouldDisplaySource !== this.state.shouldDisplaySource) {
if (this._isMounted) {
this.setState(() => ({ shouldDisplaySource }));
}
}
}
}
const classes = css.create({
accessibilityImage: {
...StyleSheet.absoluteFillObject,
height: '100%',
opacity: 0,
width: '100%',
zIndex: -1
}
});
const styles = StyleSheet.create({
root: {
flexBasis: 'auto',
overflow: 'hidden',
zIndex: 0
},
inline: {
display: 'inline-flex'
},
image: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'transparent',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
height: '100%',
width: '100%',
zIndex: -1
}
});
const resizeModeStyles = StyleSheet.create({
center: {
backgroundSize: 'auto'
},
contain: {
backgroundSize: 'contain'
},
cover: {
backgroundSize: 'cover'
},
none: {
backgroundPosition: '0 0',
backgroundSize: 'auto'
},
repeat: {
backgroundPosition: '0 0',
backgroundRepeat: 'repeat',
backgroundSize: 'auto'
},
stretch: {
backgroundSize: '100% 100%'
}
});
export default applyNativeMethods(Image);