From 7f8ad0fecb72c273251ad070334c828657eef73f Mon Sep 17 00:00:00 2001 From: Charlie Croom Date: Thu, 27 Jan 2022 15:07:59 -0500 Subject: [PATCH] feat: rework image component to simplify and add additional functionality --- .../__snapshots__/index-test.js.snap | 168 ++++-------------- .../src/exports/Image/index.js | 128 +++---------- .../src/exports/Image/types.js | 9 +- .../ImageBackground/__tests__/index.js | 3 +- .../src/modules/ImageLoader/index.js | 46 ++++- 5 files changed, 111 insertions(+), 243 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap index bf1367a01..64f0222d9 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap @@ -5,13 +5,9 @@ exports[`components/Image prop "accessibilityLabel" 1`] = ` aria-label="accessibilityLabel" class="css-view-1dbjc4n r-flexBasis-1mlwlqe r-overflow-1udh08x r-zIndex-417010" > -
accessibilityLabel @@ -22,15 +18,12 @@ exports[`components/Image prop "blurRadius" 1`] = `
-
`; @@ -40,15 +33,12 @@ exports[`components/Image prop "defaultSource" does not override "height" and "w class="css-view-1dbjc4n r-flexBasis-1mlwlqe r-overflow-1udh08x r-zIndex-417010" style="height: 20px; width: 40px;" > -
`; @@ -58,13 +48,9 @@ exports[`components/Image prop "defaultSource" sets "height" and "width" styles class="css-view-1dbjc4n r-flexBasis-1mlwlqe r-overflow-1udh08x r-zIndex-417010" style="height: 10px; width: 20px;" > -
@@ -75,13 +61,9 @@ exports[`components/Image prop "defaultSource" sets background image when value
-
@@ -92,13 +74,9 @@ exports[`components/Image prop "defaultSource" sets background image when value
-
@@ -109,13 +87,9 @@ exports[`components/Image prop "draggable" 1`] = `
-
@@ -126,95 +100,59 @@ exports[`components/Image prop "focusable" 1`] = `
-
-
+/> `; exports[`components/Image prop "nativeID" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "contain" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "cover" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "none" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "repeat" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "stretch" 1`] = `
-
-
+/> `; exports[`components/Image prop "resizeMode" value "undefined" 1`] = `
-
-
+/> `; exports[`components/Image prop "source" is correctly updated only when loaded if defaultSource provided 1`] = `
-
@@ -225,13 +163,9 @@ exports[`components/Image prop "source" is correctly updated only when loaded if
-
@@ -242,13 +176,9 @@ exports[`components/Image prop "source" is correctly updated when missing in ini
-
@@ -259,13 +189,9 @@ exports[`components/Image prop "source" is not set immediately if the image has
-
@@ -276,13 +202,9 @@ exports[`components/Image prop "source" is set immediately if the image has alre
-
@@ -293,13 +215,9 @@ exports[`components/Image prop "source" is set immediately if the image has alre
-
@@ -310,13 +228,9 @@ exports[`components/Image prop "source" is set immediately if the image was prel
-
@@ -327,16 +241,12 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`]
-
-
-
+/> `; exports[`components/Image prop "style" supports "shadow" properties (convert to filter) 1`] = `
-
-
+/> `; exports[`components/Image prop "style" supports "tintcolor" property (convert to filter) 1`] = `
-
-
-
+/> `; diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 7cbde9f58..f3f4eb10f 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -15,8 +15,7 @@ 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 PixelRatio from '../PixelRatio'; +import ImageLoader, { resolveAssetUri } from '../../modules/ImageLoader'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from '../Text/TextAncestorContext'; import View from '../View'; @@ -29,7 +28,6 @@ const LOADING = 'LOADING'; const IDLE = 'IDLE'; let _filterId = 0; -const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; function createTintColorSVG(tintColor, id) { return tintColor && id != null ? ( @@ -98,40 +96,6 @@ function resolveAssetDimensions(source) { } } -function resolveAssetUri(source): ?string { - let uri = null; - if (typeof source === 'number') { - // get the URI from the packager - const asset = getAssetByID(source); - 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, @@ -179,7 +143,6 @@ const Image: React.AbstractComponent> return IDLE; }); - const [layout, updateLayout] = React.useState({}); const hasTextAncestor = React.useContext(TextAncestorContext); const hiddenImageRef = React.useRef(null); const filterRef = React.useRef(_filterId++); @@ -194,54 +157,36 @@ const Image: React.AbstractComponent> const selectedSource = shouldDisplaySource ? source : defaultSource; const displayImageUri = resolveAssetUri(selectedSource); const imageSizeStyle = resolveAssetDimensions(selectedSource); - const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; - const backgroundSize = getBackgroundSize(); + const crossOrigin = typeof selectedSource === 'object' ? selectedSource.crossOrigin : undefined; // Accessibility image allows users to trigger the browser's image context menu - const hiddenImage = displayImageUri + const image = displayImageUri ? createElement('img', { - alt: accessibilityLabel || '', - classList: [classes.accessibilityImage], + alt: accessibilityLabel !== null ? accessibilityLabel || '' : undefined, + classList: [classes.image], + crossOrigin: crossOrigin, draggable: draggable || false, ref: hiddenImageRef, - src: displayImageUri + src: displayImageUri, + style: [style, resizeModeStyles[resizeMode], { filter }] }) : 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 + // NOTE: in order to prevent costly reloads when objects aren't equal, we only check the resolved URI here + // however, what we really want is to deepEqual test the source var on all props that might affect loading. const uri = resolveAssetUri(source); React.useEffect(() => { abortPendingRequest(); - if (uri != null) { + if (source && uri != null) { updateState(LOADING); if (onLoadStart) { onLoadStart(); } requestRef.current = ImageLoader.load( - uri, + source, function load(e) { updateState(LOADED); if (onLoad) { @@ -256,7 +201,7 @@ const Image: React.AbstractComponent> if (onError) { onError({ nativeEvent: { - error: `Failed to load resource ${uri} (404)` + error: `Failed to load resource ${source.toString()} (404)` } }); } @@ -281,21 +226,12 @@ const Image: React.AbstractComponent> - - {hiddenImage} + {image} {createTintColorSVG(tintColor, filterRef.current)} ); @@ -324,12 +260,9 @@ ImageWithStatics.queryCache = function (uris) { }; const classes = css.create({ - accessibilityImage: { - ...StyleSheet.absoluteFillObject, + image: { height: '100%', - opacity: 0, - width: '100%', - zIndex: -1 + width: '100%' } }); @@ -341,40 +274,29 @@ const styles = StyleSheet.create({ }, 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' + objectFit: 'scale-down' }, contain: { - backgroundSize: 'contain' + objectFit: 'contain' }, cover: { - backgroundSize: 'cover' + objectFit: 'cover' }, none: { - backgroundPosition: '0 0', - backgroundSize: 'auto' + objectPosition: '0 0', + objectFit: 'scale-down' }, + /* Repeat is not properly supported */ repeat: { - backgroundPosition: '0 0', - backgroundRepeat: 'repeat', - backgroundSize: 'auto' + objectFit: 'scale-down' }, stretch: { - backgroundSize: '100% 100%' + objectFit: 'fill' } }); diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 174958137..74327fd45 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -48,6 +48,13 @@ type SourceObject = { * @platform ios */ cache?: 'default' | 'reload' | 'force-cache' | 'only-if-cached', + /** + * `crossOrigin` specifies the CORS mode to use to load the image. This replaces headers and body on web + * and should allow cookie-authenticated servers to serve images. + * + * @platform web + */ + crossOrigin?: 'anonymous' | 'use-credentials', /** * `headers` is an object representing the HTTP headers to send along with the * request for a remote image. @@ -79,7 +86,7 @@ type SourceObject = { export type ResizeMode = 'center' | 'contain' | 'cover' | 'none' | 'repeat' | 'stretch'; -export type Source = number | string | SourceObject | Array; +export type Source = number | string | SourceObject; export type ImageStyle = { ...AnimationStyles, diff --git a/packages/react-native-web/src/exports/ImageBackground/__tests__/index.js b/packages/react-native-web/src/exports/ImageBackground/__tests__/index.js index eb1e925bb..b4d98595a 100644 --- a/packages/react-native-web/src/exports/ImageBackground/__tests__/index.js +++ b/packages/react-native-web/src/exports/ImageBackground/__tests__/index.js @@ -25,7 +25,8 @@ describe('components/ImageBackground', () => { test('sets the style of the underlying Image', () => { const imageStyle = { width: 40, height: 60 }; const { container } = render(); - expect(findImage(container).getAttribute('style')).toBe('height: 60px; width: 40px;'); + expect(findImage(container).getAttribute('style')).toContain('height: 60px;'); + expect(findImage(container).getAttribute('style')).toContain('width: 40px;'); }); }); diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index db93fbfa4..857696b65 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -7,6 +7,45 @@ * @flow */ +import { getAssetByID } from '../../modules/AssetRegistry'; +import PixelRatio from '../../exports/PixelRatio'; +import type { Source } from '../../exports/Image/types'; + +const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; +export function resolveAssetUri(source: ?Source): ?string { + let uri = null; + if (typeof source === 'number') { + // get the URI from the packager + const asset = getAssetByID(source); + 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; +} + const dataUriPattern = /^data:/; export class ImageUriCache { @@ -113,7 +152,7 @@ const ImageLoader = { has(uri: string): boolean { return ImageUriCache.has(uri); }, - load(uri: string, onLoad: Function, onError: Function): number { + load(source: Source, onLoad: Function, onError: Function): number { id += 1; const image = new window.Image(); image.onerror = onError; @@ -129,7 +168,12 @@ const ImageLoader = { setTimeout(onDecode, 0); } }; + + const uri = resolveAssetUri(source); image.src = uri; + if (typeof source === 'object' && source.crossOrigin) { + image.crossOrigin = source.crossOrigin; + } requests[`${id}`] = image; return id; },