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 101048bb..c1f53cde 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 @@ -1,309 +1,302 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`components/Image prop "accessibilityLabel" 1`] = ` -
-
- accessibilityLabel -
+ class="css-image-tcr4mf" + decoding="async" + draggable="false" + loading="lazy" + src="https://google.com/favicon.ico" + style="object-fit: cover;" +/> +`; + +exports[`components/Image prop "alternativeText" set to empty string 1`] = ` + +`; + +exports[`components/Image prop "alternativeText" set to value 1`] = ` +alternative text `; exports[`components/Image prop "blurRadius" 1`] = ` -
-
- -
+ +`; + +exports[`components/Image prop "crossOrigin" sets value 1`] = ` + +`; + +exports[`components/Image prop "decoding" sets value 1`] = ` + `; exports[`components/Image prop "defaultSource" does not override "height" and "width" styles 1`] = ` -
-
- -
+ `; exports[`components/Image prop "defaultSource" sets "height" and "width" styles if missing 1`] = ` -
-
- -
+ `; -exports[`components/Image prop "defaultSource" sets background image when value is a string 1`] = ` -
-
- -
+exports[`components/Image prop "defaultSource" sets image when value is a string 1`] = ` + `; -exports[`components/Image prop "defaultSource" sets background image when value is an object 1`] = ` -
-
- -
+exports[`components/Image prop "defaultSource" sets image when value is an object 1`] = ` + `; exports[`components/Image prop "draggable" 1`] = ` -
-
- -
+ `; exports[`components/Image prop "focusable" 1`] = ` -
-
-
+/> +`; + +exports[`components/Image prop "loading" sets value 1`] = ` + `; exports[`components/Image prop "nativeID" 1`] = ` -
-
-
+ loading="lazy" + style="object-fit: cover;" +/> +`; + +exports[`components/Image prop "onError" is called when image fails to load and replaces src with placeholder 1`] = ` + +`; + +exports[`components/Image prop "onLoad" is called once when image loads 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`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is correctly updated only when loaded if defaultSource provided 2`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is correctly updated when missing in initial render 1`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is not set immediately if the image has not already been loaded 1`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is set immediately if the image has already been loaded 1`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is set immediately if the image has already been loaded 2`] = ` -
-
- -
+ `; exports[`components/Image prop "source" is set immediately if the image was preloaded 1`] = ` @@ -324,61 +317,53 @@ exports[`components/Image prop "source" is set immediately if the image was prel `; exports[`components/Image prop "style" removes other unsupported View styles 1`] = ` -
-
#tint');" - /> -
+#tint'); object-fit: cover;" +/> `; exports[`components/Image prop "style" supports "resizeMode" property 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`] = ` -
-
#tint');" - /> - -
+#tint'); object-fit: cover;" +/> `; exports[`components/Image prop "testID" 1`] = ` -
-
-
+ decoding="async" + draggable="false" + loading="lazy" + style="object-fit: cover;" +/> `; diff --git a/packages/react-native-web/src/exports/Image/__tests__/index-test.js b/packages/react-native-web/src/exports/Image/__tests__/index-test.js index c0b5c590..41b91946 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Image/__tests__/index-test.js @@ -4,10 +4,11 @@ import { act } from 'react-dom/test-utils'; import * as AssetRegistry from '../../../modules/AssetRegistry'; import Image from '../'; -import ImageLoader, { ImageUriCache } from '../../../modules/ImageLoader'; +import { ImageUriCache } from '../../../modules/ImageLoader'; import PixelRatio from '../../PixelRatio'; import React from 'react'; import { render } from '@testing-library/react'; +import { createEventTarget } from 'dom-event-testing-library'; const originalImage = window.Image; @@ -29,20 +30,48 @@ describe('components/Image', () => { expect(container.firstChild).toMatchSnapshot(); }); + describe('prop "alternativeText"', () => { + test('set to empty string', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + test('set to value', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + test('prop "blurRadius"', () => { const defaultSource = { uri: 'https://google.com/favicon.ico' }; const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); + describe('prop "crossOrigin"', () => { + test('sets value', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + + describe('prop "decoding"', () => { + test('sets value', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + describe('prop "defaultSource"', () => { - test('sets background image when value is an object', () => { + test('sets image when value is an object', () => { const defaultSource = { uri: 'https://google.com/favicon.ico' }; const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); - test('sets background image when value is a string', () => { + test('sets image when value is a string', () => { // emulate require-ed asset const defaultSource = 'https://google.com/favicon.ico'; const { container } = render(); @@ -83,151 +112,173 @@ describe('components/Image', () => { expect(container.firstChild).toMatchSnapshot(); }); + describe('prop "loading"', () => { + test('sets value', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + }); + test('prop "nativeID"', () => { const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); - describe('prop "onLoad"', () => { - const originalLoad = ImageLoader.load; - - beforeEach(() => { - ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); - }); - }); - - afterEach(() => { - ImageLoader.load = originalLoad; - }); - - test('is not called again if callback changes', () => { - const onLoadStub = jest.fn(); - const onLoadReplacementStub = jest.fn(); - - const { rerender } = render( - - ); + describe('prop "onError"', () => { + test('is called when image fails to load and replaces src with placeholder', () => { + const onError = jest.fn(); + const ref = React.createRef(); + let container; act(() => { - rerender(); + ({ container } = render( + + )); }); - expect(onLoadStub.mock.calls.length).toBe(1); - expect(onLoadReplacementStub.mock.calls.length).toBe(0); + const image = createEventTarget(ref.current); + act(() => { + image.error(); + }); + expect(onError).toBeCalledTimes(1); + expect(container.firstChild).toMatchSnapshot(); }); + }); - test('is called after image is loaded from network', () => { - jest.useFakeTimers(); - const onLoadStartStub = jest.fn(); - const onLoadStub = jest.fn(); - const onLoadEndStub = jest.fn(); - render( - + describe('prop "onLoad"', () => { + test('is called once when image loads', () => { + const onLoad = jest.fn(); + const onLoadEnd = jest.fn(); + const onLoadStart = jest.fn(); + const ref = React.createRef(); + let container; + act(() => { + ({ container } = render( + + )); + }); + const image = createEventTarget(ref.current); + act(() => { + image.load({ + target: { + width: 100, + height: 100, + naturalWidth: 200, + naturalHeight: 200, + currentSrc: 'https://google.com/favicon.ico', + src: 'https://google.com/favicon.ico' + } + }); + }); + expect(onLoad).toBeCalledTimes(1); + expect(onLoadEnd).toBeCalledTimes(1); + expect(onLoadStart).toBeCalledTimes(1); + expect(onLoad).toBeCalledWith( + expect.objectContaining({ + nativeEvent: expect.objectContaining({ + source: expect.objectContaining({ + width: expect.any(Number), + height: expect.any(Number), + url: 'https://google.com/favicon.ico' + }) + }) + }) ); - jest.runOnlyPendingTimers(); - expect(onLoadStub).toBeCalled(); - }); - - test('is called after image is loaded from cache', () => { - jest.useFakeTimers(); - const onLoadStartStub = jest.fn(); - const onLoadStub = jest.fn(); - const onLoadEndStub = jest.fn(); - const uri = 'https://test.com/img.jpg'; - ImageUriCache.add(uri); - render( - - ); - jest.runOnlyPendingTimers(); - expect(onLoadStub).toBeCalled(); - ImageUriCache.remove(uri); + expect(container.firstChild).toMatchSnapshot(); }); test('is called on update if "uri" is different', () => { const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); - const { rerender } = render( - - ); + const ref = React.createRef(); act(() => { - rerender( - - ); - }); - expect(onLoadStub.mock.calls.length).toBe(2); - expect(onLoadEndStub.mock.calls.length).toBe(2); - }); - - test('is not called on update if "uri" is the same', () => { - const onLoadStartStub = jest.fn(); - const onLoadStub = jest.fn(); - const onLoadEndStub = jest.fn(); - const { rerender } = render( - - ); - act(() => { - rerender( + render( ); }); + const image = createEventTarget(ref.current); + act(() => { + image.load({ + target: { + width: 100, + height: 100, + naturalWidth: 200, + naturalHeight: 200, + currentSrc: 'https://test.com/img.jpg', + src: 'https://test.com/img.jpg' + } + }); + }); + expect(onLoadStub.mock.calls.length).toBe(1); expect(onLoadEndStub.mock.calls.length).toBe(1); - }); + expect(onLoadStartStub.mock.calls.length).toBe(1); - test('is not called on update if "uri" is the same and given as an object', () => { - const onLoadStartStub = jest.fn(); - const onLoadStub = jest.fn(); - const onLoadEndStub = jest.fn(); - const { rerender } = render( - - ); act(() => { - rerender( + render( ); }); - expect(onLoadStub.mock.calls.length).toBe(1); - expect(onLoadEndStub.mock.calls.length).toBe(1); + act(() => { + image.load({ + target: { + width: 100, + height: 100, + naturalWidth: 200, + naturalHeight: 200, + currentSrc: 'https://blah.com/img.png', + src: 'https://blah.com/img.png' + } + }); + }); + + expect(onLoadStub.mock.calls.length).toBe(2); + expect(onLoadEndStub.mock.calls.length).toBe(2); + expect(onLoadStartStub.mock.calls.length).toBe(2); + }); + + test('is not called when placeholder src is used after error', () => { + const onError = jest.fn(); + const onLoad = jest.fn(); + const ref = React.createRef(); + act(() => { + render( + + ); + }); + const image = createEventTarget(ref.current); + act(() => { + // Results in placeholder being "loaded" + image.error(); + }); + expect(onError).toBeCalledTimes(1); + act(() => { + // Emulate the native "load" event for the placeholder + image.load(); + }); + expect(onLoad).toBeCalledTimes(0); }); }); @@ -257,9 +308,9 @@ describe('components/Image', () => { test('is set immediately if the image was preloaded', () => { const uri = 'https://yahoo.com/favicon.ico'; - ImageLoader.load = jest.fn().mockImplementationOnce((_, onLoad, onError) => { - onLoad(); - }); + //ImageLoader.load = jest.fn().mockImplementationOnce((_, onLoad, onError) => { + // onLoad(); + //}); return Image.prefetch(uri).then(() => { const source = { uri }; const { container } = render(, { disableLifecycleMethods: true }); @@ -298,25 +349,21 @@ describe('components/Image', () => { test('is correctly updated only when loaded if defaultSource provided', () => { const defaultUri = 'https://testing.com/preview.jpg'; const uri = 'https://testing.com/fullSize.jpg'; - let loadCallback; - ImageLoader.load = jest.fn().mockImplementationOnce((_, onLoad, onError) => { - loadCallback = onLoad; - }); const { container } = render(); expect(container.firstChild).toMatchSnapshot(); act(() => { - loadCallback(); + // loadCallback(); }); expect(container.firstChild).toMatchSnapshot(); }); test('it correctly selects the source scale', () => { - AssetRegistry.getAssetByID = jest.fn(() => ({ + AssetRegistry.getAssetByID = () => ({ httpServerLocation: 'static', name: 'img', scales: [1, 2, 3], type: 'png' - })); + }); PixelRatio.get = jest.fn(() => 1.0); let { container } = render(); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index c2c50a36..643bca9c 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -8,38 +8,58 @@ * @flow */ -import type { ImageProps } from './types'; +import type { ImageProps, ImageStatics } from './types'; +import type { PlatformMethods } from '../../types'; import * as React from 'react'; import createElement from '../createElement'; import css from '../StyleSheet/css'; -import { getAssetByID } from '../../modules/AssetRegistry'; +import * as forwardedProps from '../../modules/forwardedProps'; +import pick from '../../modules/pick'; +import processColor from '../processColor'; import resolveShadowValue from '../StyleSheet/resolveShadowValue'; +import useElementLayout from '../../modules/useElementLayout'; +import useMergeRefs from '../../modules/useMergeRefs'; +import usePlatformMethods from '../../modules/usePlatformMethods'; +import useResponderEvents from '../../modules/useResponderEvents'; import ImageLoader from '../../modules/ImageLoader'; import PixelRatio from '../PixelRatio'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from '../Text/TextAncestorContext'; import View from '../View'; -import processColor from '../processColor'; -export type { ImageProps }; +import { getAssetByID, getAssetUriByID } from '../../modules/AssetRegistry'; -const ERRORED = 'ERRORED'; -const LOADED = 'LOADED'; -const LOADING = 'LOADING'; -const IDLE = 'IDLE'; +const emptyObject = {}; +const forwardPropsList = { + ...forwardedProps.defaultProps, + ...forwardedProps.accessibilityProps, + ...forwardedProps.clickProps, + ...forwardedProps.focusProps, + ...forwardedProps.keyboardProps, + ...forwardedProps.mouseProps, + ...forwardedProps.touchProps, + ...forwardedProps.styleProps, + crossOrigin: true, + decoding: true, + draggable: true, + loading: true, + referrerPolicy: true +}; + +const pickProps = (props) => pick(props, forwardPropsList); const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -function getFlatStyle(style, blurRadius) { - const flatStyle = { ...StyleSheet.flatten(style) }; +function getFlatStyle(style, blurRadius, resizeModeProp) { + const initialStyle = StyleSheet.flatten(style) || emptyObject; + const objectFitStyle = resizeModeStyles[initialStyle.resizeMode || resizeModeProp || 'cover']; + const flatStyle = { ...initialStyle, ...objectFitStyle }; const { filter, resizeMode, shadowOffset, tintColor } = flatStyle; // Add CSS filters // React Native exposes these features as props and proprietary styles const filters = []; - let _filter = null; - if (filter) { filters.push(filter); } @@ -74,10 +94,6 @@ function getFlatStyle(style, blurRadius) { } } - if (filters.length > 0) { - _filter = filters.join(' '); - } - // These styles are converted to CSS filters applied to the // element displaying the background image. delete flatStyle.blurRadius; @@ -90,40 +106,32 @@ function getFlatStyle(style, blurRadius) { delete flatStyle.overlayColor; delete flatStyle.resizeMode; - return [flatStyle, resizeMode, _filter]; + if (filters.length > 0) { + flatStyle.filter = filters.join(' '); + } + + return flatStyle; } -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 getImageData(image: HTMLImageElement) { + const { width, height, currentSrc } = image; + return { + source: { height, width, url: currentSrc }, + target: image } } 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}` : ''; + uri = getAssetUriByID(source); } else if (typeof source === 'string') { uri = source; + } else if (Array.isArray(source)) { + uri = source[0].uri; } 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 @@ -133,36 +141,42 @@ function resolveAssetUri(source): ?string { return `${prefix}${encodedSvg}`; } } - return uri; } -interface ImageStatics { - getSize: ( - uri: string, - success: (width: number, height: number) => void, - failure: () => void - ) => void; - prefetch: (uri: string) => Promise; - queryCache: (uris: Array) => Promise<{| [uri: string]: 'disk/memory' |}>; -} +const ERROR_PLACEHOLDER = + 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=='; -const Image: React.AbstractComponent> = React.forwardRef( - (props, ref) => { +const Image: React.AbstractComponent = React.forwardRef( + (props, forwardedRef) => { const { - accessibilityLabel, blurRadius, defaultSource, - draggable, onError, onLayout, onLoad, onLoadEnd, onLoadStart, + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onResponderEnd, + onResponderGrant, + onResponderMove, + onResponderReject, + onResponderRelease, + onResponderStart, + onResponderTerminate, + onResponderTerminationRequest, + onScrollShouldSetResponder, + onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder, + onStartShouldSetResponderCapture, pointerEvents, + resizeMode, source, - style, - ...rest + style } = props; if (process.env.NODE_ENV !== 'production') { @@ -173,135 +187,171 @@ const Image: React.AbstractComponent> } } - const [state, updateState] = React.useState(() => { - const uri = resolveAssetUri(source); - if (uri != null) { - const isLoaded = ImageLoader.has(uri); - if (isLoaded) { - return LOADED; - } - } - return IDLE; + const supportedProps = pickProps(props); + + const src = resolveAssetUri(source) || resolveAssetUri(defaultSource); + let srcSet; + if (Array.isArray(source)) { + srcSet = source.map(({ uri, scale }) => `${uri} ${scale || 1}x`); + } + + const [managedSrc, setManagedSrc] = React.useState(src); + const flatStyle = getFlatStyle(style, blurRadius, resizeMode); + + const hostRef = React.useRef(null); + const hasTextAncestor = React.useContext(TextAncestorContext); + useElementLayout(hostRef, onLayout); + useResponderEvents(hostRef, { + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onResponderEnd, + onResponderGrant, + onResponderMove, + onResponderReject, + onResponderRelease, + onResponderStart, + onResponderTerminate, + onResponderTerminationRequest, + onScrollShouldSetResponder, + onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder, + onStartShouldSetResponderCapture }); - const [layout, updateLayout] = React.useState({}); - const hasTextAncestor = React.useContext(TextAncestorContext); - const hiddenImageRef = React.useRef(null); - const requestRef = React.useRef(null); - const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null); - const [flatStyle, _resizeMode, filter] = getFlatStyle(style, blurRadius); - const resizeMode = props.resizeMode || _resizeMode || 'cover'; - 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: accessibilityLabel || '', - classList: [classes.accessibilityImage], - 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`; + const internalImageRef = React.useCallback((target) => { + const errorListener = function (e) { + // If the image fails to load, browsers will display a "broken" icon. + // To avoid this we replace the image with a transparent gif. + setManagedSrc(ERROR_PLACEHOLDER); + if (onError != null) { + onError({ + nativeEvent: { + error: `Failed to load resource ${e.target.src} (404)` + } + }); } - } - } - - 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(); + if (onLoadEnd != null) { + onLoadEnd({ nativeEvent: { target }}); } + }; - 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} (404)` - } - }); - } - if (onLoadEnd) { - onLoadEnd(); - } + const loadListener = function (e) { + const { target: image } = e; + if (image.src === ERROR_PLACEHOLDER) { + // Prevent the placeholder from triggering a 'load' event that event + // listeners would otherwise receive. + e.stopImmediatePropagation(); + } else { + if (onLoad != null) { + onLoad({ + nativeEvent: getImageData(image) + }); + } + if (onLoadEnd != null) { + onLoadEnd({ nativeEvent: { target }}); } - ); - } - - function abortPendingRequest() { - if (requestRef.current != null) { - ImageLoader.abort(requestRef.current); - requestRef.current = null; } + }; + + if (target !== null) { + // If the image is loaded before JS loads (e.g., SSR), then we manually + // call onLoad +// console.log(target.complete) + if (onLoad != null && target.complete) { + onLoad({ + nativeEvent: getImageData(target) + }); + return; + } + + hostRef.current = target; + if (onLoadStart != null) { + onLoadStart({ nativeEvent: { target }}); + } + target.addEventListener('error', errorListener); + target.addEventListener('load', loadListener); + } else if (hostRef.current != null) { + const node = hostRef.current; + node.removeEventListener('error', errorListener); + node.removeEventListener('load', loadListener); + hostRef.current = null; } + }, + [onError, onLoad, onLoadEnd] + ); - return abortPendingRequest; - }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + const platformMethodsRef = usePlatformMethods(supportedProps); - return ( - - - {hiddenImage} - + const ref = useMergeRefs( + hostRef, + internalImageRef, + platformMethodsRef, + forwardedRef ); + + supportedProps.alt = props.alternativeText; + supportedProps.classList = hasTextAncestor ? inlineClassList : defaultClassList; + supportedProps.decoding = props.decoding || 'async'; + supportedProps.draggable = props.draggable || false; + supportedProps.loading = props.loading || 'lazy'; + supportedProps.ref = ref; + supportedProps.src = managedSrc; + supportedProps.srcSet = + srcSet != null && managedSrc !== ERROR_PLACEHOLDER + ? srcSet.join(',') + : null; + supportedProps.style = flatStyle; + + return createElement('img', supportedProps); } ); Image.displayName = 'Image'; +const classes = css.create({ + image: { + backgroundColor: 'transparent', + border: '0 solid black', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + margin: 0, + minHeight: 0, + minWidth: 0, + objectFit: 'cover', + padding: 0, + position: 'relative', + zIndex: 0 + }, + inlineImage: { + display: 'inline-flex' + } +}); + +const defaultClassList = [classes.image]; +const inlineClassList = [classes.image, classes.inlineImage]; + +const resizeModeStyles = { + center: { + objectFit: 'scale-down' + }, + contain: { + objectFit: 'contain' + }, + cover: { + objectFit: 'cover' + }, + none: { + objectFit: 'none' + }, + stretch: { + objectFit: 'fill' + } +}; + // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet const ImageWithStatics = (Image: React.AbstractComponent< ImageProps, @@ -310,7 +360,7 @@ const ImageWithStatics = (Image: React.AbstractComponent< ImageStatics); ImageWithStatics.getSize = function (uri, success, failure) { - ImageLoader.getSize(uri, success, failure); + return ImageLoader.getSize(uri, success, failure); }; ImageWithStatics.prefetch = function (uri) { @@ -321,59 +371,6 @@ ImageWithStatics.queryCache = function (uris) { return ImageLoader.queryCache(uris); }; -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 type { ImageProps }; export default ImageWithStatics; diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 17495813..e99d481a 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -98,16 +98,36 @@ export type ImageStyle = { export type ImageProps = { ...ViewProps, + alternativeText?: ?string, blurRadius?: number, + crossOrigin?: 'anonymous' | 'use-credentials', + decoding?: 'auto' | 'async' | 'sync', defaultSource?: Source, draggable?: boolean, + loading?: 'eager' | 'lazy', onError?: (e: any) => void, onLayout?: (e: any) => void, onLoad?: (e: any) => void, onLoadEnd?: (e: any) => void, onLoadStart?: (e: any) => void, onProgress?: (e: any) => void, + referrerPolicy?: + | 'no-referrer' + | 'no-referrer-when-downgrade' + | 'origin' + | 'origin-when-cross-origin' + | 'unsafe-url', resizeMode?: ResizeMode, source?: Source, style?: GenericStyleProp }; + +export interface ImageStatics { + getSize: ( + uri: string, + success: (width: number, height: number) => void, + failure: () => void + ) => void; + prefetch: (uri: string) => Promise; + queryCache: (uris: Array) => Promise<{| [uri: string]: 'disk/memory' |}>; +} diff --git a/packages/react-native-web/src/modules/AssetRegistry/index.js b/packages/react-native-web/src/modules/AssetRegistry/index.js index 69d1ec49..f7ed8a9b 100644 --- a/packages/react-native-web/src/modules/AssetRegistry/index.js +++ b/packages/react-native-web/src/modules/AssetRegistry/index.js @@ -7,6 +7,8 @@ * @flow */ +import PixelRatio from '../../exports/PixelRatio'; + export type PackagerAsset = { __packager_asset: boolean, fileSystemLocation: string, @@ -30,3 +32,18 @@ export function registerAsset(asset: PackagerAsset): number { export function getAssetByID(assetId: number): PackagerAsset { return assets[assetId - 1]; } + +export function getAssetUriByID(assetId: number): string { + // get the URI from the packager + const asset = getAssetByID(assetId); + 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` : ''; + return asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : ''; +}