UNPKG

react-native-web

Version:
444 lines (404 loc) 11.4 kB
/** * 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;