react-native-web
Version:
React Native for Web
444 lines (404 loc) • 11.4 kB
JavaScript
/**
* Copyright (c) Nicolas Gallagher.
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
'use client';
import type { ImageProps } from './types';
import * as React from 'react';
import createElement from '../createElement';
import { getAssetByID } from '../../modules/AssetRegistry';
import { createBoxShadowValue } from '../StyleSheet/preprocess';
import ImageLoader from '../../modules/ImageLoader';
import PixelRatio from '../PixelRatio';
import StyleSheet from '../StyleSheet';
import TextAncestorContext from '../Text/TextAncestorContext';
import View from '../View';
import { warnOnce } from '../../modules/warnOnce';
export type { ImageProps };
const ERRORED = 'ERRORED';
const LOADED = 'LOADED';
const LOADING = 'LOADING';
const IDLE = 'IDLE';
let _filterId = 0;
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
function createTintColorSVG(tintColor, id) {
return tintColor && id != null ? (
<svg
style={{
position: 'absolute',
height: 0,
visibility: 'hidden',
width: 0
}}
>
<defs>
<filter id={`tint-${id}`} suppressHydrationWarning={true}>
<feFlood floodColor={`${tintColor}`} key={tintColor} />
<feComposite in2="SourceAlpha" operator="in" />
</filter>
</defs>
</svg>
) : null;
}
function extractNonStandardStyleProps(
style,
blurRadius,
filterId,
tintColorProp
) {
const flatStyle = StyleSheet.flatten(style);
const { filter, resizeMode, shadowOffset, tintColor } = flatStyle;
if (flatStyle.resizeMode) {
warnOnce(
'Image.style.resizeMode',
'Image: style.resizeMode is deprecated. Please use props.resizeMode.'
);
}
if (flatStyle.tintColor) {
warnOnce(
'Image.style.tintColor',
'Image: style.tintColor is deprecated. Please use props.tintColor.'
);
}
// Add CSS filters
// React Native exposes these features as props and proprietary styles
const filters = [];
let _filter = null;
if (filter) {
filters.push(filter);
}
if (blurRadius) {
filters.push(`blur(${blurRadius}px)`);
}
if (shadowOffset) {
const shadowString = createBoxShadowValue(flatStyle);
if (shadowString) {
filters.push(`drop-shadow(${shadowString})`);
}
}
if ((tintColorProp || tintColor) && filterId != null) {
filters.push(`url(#tint-${filterId})`);
}
if (filters.length > 0) {
_filter = filters.join(' ');
}
return [resizeMode, _filter, tintColor];
}
function resolveAssetDimensions(source) {
if (typeof source === 'number') {
const { height, width } = getAssetByID(source);
return { height, width };
} else if (
source != null &&
!Array.isArray(source) &&
typeof source === 'object'
) {
const { height, width } = source;
return { height, width };
}
}
function resolveAssetUri(source): ?string {
let uri = null;
if (typeof source === 'number') {
// get the URI from the packager
const asset = getAssetByID(source);
if (asset == null) {
throw new Error(
`Image: asset with ID "${source}" could not be found. Please check the image source or packager.`
);
}
let scale = asset.scales[0];
if (asset.scales.length > 1) {
const preferredScale = PixelRatio.get();
// Get the scale which is closest to the preferred scale
scale = asset.scales.reduce((prev, curr) =>
Math.abs(curr - preferredScale) < Math.abs(prev - preferredScale)
? curr
: prev
);
}
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;
}
interface ImageStatics {
getSize: (
uri: string,
success: (width: number, height: number) => void,
failure: () => void
) => void;
prefetch: (uri: string) => Promise<void>;
queryCache: (
uris: Array<string>
) => Promise<{| [uri: string]: 'disk/memory' |}>;
}
const Image: React.AbstractComponent<
ImageProps,
React.ElementRef<typeof View>
> = React.forwardRef((props, ref) => {
const {
'aria-label': _ariaLabel,
accessibilityLabel,
blurRadius,
defaultSource,
draggable,
onError,
onLayout,
onLoad,
onLoadEnd,
onLoadStart,
pointerEvents,
source,
style,
...rest
} = props;
const ariaLabel = _ariaLabel || accessibilityLabel;
if (process.env.NODE_ENV !== 'production') {
if (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 [state, updateState] = React.useState(() => {
const uri = resolveAssetUri(source);
if (uri != null) {
const isLoaded = ImageLoader.has(uri);
if (isLoaded) {
return LOADED;
}
}
return IDLE;
});
const [layout, updateLayout] = React.useState({});
const hasTextAncestor = React.useContext(TextAncestorContext);
const hiddenImageRef = React.useRef(null);
const filterRef = React.useRef(_filterId++);
const requestRef = React.useRef(null);
const shouldDisplaySource =
state === LOADED || (state === LOADING && defaultSource == null);
const [_resizeMode, filter, _tintColor] = extractNonStandardStyleProps(
style,
blurRadius,
filterRef.current,
props.tintColor
);
const resizeMode = props.resizeMode || _resizeMode || 'cover';
const tintColor = props.tintColor || _tintColor;
const selectedSource = shouldDisplaySource ? source : defaultSource;
const displayImageUri = resolveAssetUri(selectedSource);
const imageSizeStyle = resolveAssetDimensions(selectedSource);
const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null;
const backgroundSize = getBackgroundSize();
// Accessibility image allows users to trigger the browser's image context menu
const hiddenImage = displayImageUri
? createElement('img', {
alt: ariaLabel || '',
style: styles.accessibilityImage$raw,
draggable: draggable || false,
ref: hiddenImageRef,
src: displayImageUri
})
: null;
function getBackgroundSize(): ?string {
if (
hiddenImageRef.current != null &&
(resizeMode === 'center' || resizeMode === 'repeat')
) {
const { naturalHeight, naturalWidth } = hiddenImageRef.current;
const { height, width } = 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 `${x}px ${y}px`;
}
}
}
function handleLayout(e) {
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
const { layout } = e.nativeEvent;
onLayout && onLayout(e);
updateLayout(layout);
}
}
// Image loading
const uri = resolveAssetUri(source);
React.useEffect(() => {
abortPendingRequest();
if (uri != null) {
updateState(LOADING);
if (onLoadStart) {
onLoadStart();
}
requestRef.current = ImageLoader.load(
uri,
function load(e) {
updateState(LOADED);
if (onLoad) {
onLoad(e);
}
if (onLoadEnd) {
onLoadEnd();
}
},
function error() {
updateState(ERRORED);
if (onError) {
onError({
nativeEvent: {
error: `Failed to load resource ${uri}`
}
});
}
if (onLoadEnd) {
onLoadEnd();
}
}
);
}
function abortPendingRequest() {
if (requestRef.current != null) {
ImageLoader.abort(requestRef.current);
requestRef.current = null;
}
}
return abortPendingRequest;
}, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]);
return (
<View
{...rest}
aria-label={ariaLabel}
onLayout={handleLayout}
pointerEvents={pointerEvents}
ref={ref}
style={[
styles.root,
hasTextAncestor && styles.inline,
imageSizeStyle,
style,
styles.undo,
// TEMP: avoid deprecated shadow props regression
// until Image refactored to use createElement.
{ boxShadow: null }
]}
>
<View
style={[
styles.image,
resizeModeStyles[resizeMode],
{ backgroundImage, filter },
backgroundSize != null && { backgroundSize }
]}
suppressHydrationWarning={true}
/>
{hiddenImage}
{createTintColorSVG(tintColor, filterRef.current)}
</View>
);
});
Image.displayName = 'Image';
// $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet
const ImageWithStatics = (Image: React.AbstractComponent<
ImageProps,
React.ElementRef<typeof View>
> &
ImageStatics);
ImageWithStatics.getSize = function (uri, success, failure) {
ImageLoader.getSize(uri, success, failure);
};
ImageWithStatics.prefetch = function (uri) {
return ImageLoader.prefetch(uri);
};
ImageWithStatics.queryCache = function (uris) {
return ImageLoader.queryCache(uris);
};
const styles = StyleSheet.create({
root: {
flexBasis: 'auto',
overflow: 'hidden',
zIndex: 0
},
inline: {
display: 'inline-flex'
},
undo: {
// These styles are converted to CSS filters applied to the
// element displaying the background image.
blurRadius: null,
shadowColor: null,
shadowOpacity: null,
shadowOffset: null,
shadowRadius: null,
tintColor: null,
// These styles are not supported
overlayColor: null,
resizeMode: null
},
image: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'transparent',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
height: '100%',
width: '100%',
zIndex: -1
},
accessibilityImage$raw: {
...StyleSheet.absoluteFillObject,
height: '100%',
opacity: 0,
width: '100%',
zIndex: -1
}
});
const resizeModeStyles = StyleSheet.create({
center: {
backgroundSize: 'auto'
},
contain: {
backgroundSize: 'contain'
},
cover: {
backgroundSize: 'cover'
},
none: {
backgroundPosition: '0',
backgroundSize: 'auto'
},
repeat: {
backgroundPosition: '0',
backgroundRepeat: 'repeat',
backgroundSize: 'auto'
},
stretch: {
backgroundSize: '100% 100%'
}
});
export default ImageWithStatics;