mirror of
https://github.com/zoriya/react-native-web.git
synced 2026-05-17 20:39:14 +00:00
[change] modernize Image
Rewrite Image to use function components and hooks. Fix #1322
This commit is contained in:
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Nicolas Gallagher.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @flow
|
||||
*/
|
||||
|
||||
const dataUriPattern = /^data:/;
|
||||
|
||||
export default class ImageUriCache {
|
||||
static _maximumEntries: number = 256;
|
||||
static _entries = {};
|
||||
|
||||
static has(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
const isDataUri = dataUriPattern.test(uri);
|
||||
return isDataUri || Boolean(entries[uri]);
|
||||
}
|
||||
|
||||
static add(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
const lastUsedTimestamp = Date.now();
|
||||
if (entries[uri]) {
|
||||
entries[uri].lastUsedTimestamp = lastUsedTimestamp;
|
||||
entries[uri].refCount += 1;
|
||||
} else {
|
||||
entries[uri] = {
|
||||
lastUsedTimestamp,
|
||||
refCount: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static remove(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
if (entries[uri]) {
|
||||
entries[uri].refCount -= 1;
|
||||
}
|
||||
// Free up entries when the cache is "full"
|
||||
ImageUriCache._cleanUpIfNeeded();
|
||||
}
|
||||
|
||||
static _cleanUpIfNeeded() {
|
||||
const entries = ImageUriCache._entries;
|
||||
const imageUris = Object.keys(entries);
|
||||
|
||||
if (imageUris.length + 1 > ImageUriCache._maximumEntries) {
|
||||
let leastRecentlyUsedKey;
|
||||
let leastRecentlyUsedEntry;
|
||||
|
||||
imageUris.forEach(uri => {
|
||||
const entry = entries[uri];
|
||||
if (
|
||||
(!leastRecentlyUsedEntry ||
|
||||
entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) &&
|
||||
entry.refCount === 0
|
||||
) {
|
||||
leastRecentlyUsedKey = uri;
|
||||
leastRecentlyUsedEntry = entry;
|
||||
}
|
||||
});
|
||||
|
||||
if (leastRecentlyUsedKey) {
|
||||
delete entries[leastRecentlyUsedKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -317,14 +317,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`]
|
||||
>
|
||||
<div
|
||||
class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw"
|
||||
style="filter: url(#tint-33);"
|
||||
style="filter: url(#tint-53);"
|
||||
/>
|
||||
<svg
|
||||
style="position: absolute; height: 0px; visibility: hidden; width: 0px;"
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id="tint-33"
|
||||
id="tint-53"
|
||||
>
|
||||
<feflood
|
||||
flood-color="blue"
|
||||
@@ -366,7 +366,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
|
||||
>
|
||||
<div
|
||||
class="css-view-1dbjc4n r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-backgroundSize-4gszlv r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw"
|
||||
style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-32);"
|
||||
style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-52);"
|
||||
/>
|
||||
<img
|
||||
alt=""
|
||||
@@ -379,7 +379,7 @@ exports[`components/Image prop "style" supports "tintcolor" property (convert to
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id="tint-32"
|
||||
id="tint-52"
|
||||
>
|
||||
<feflood
|
||||
flood-color="red"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-env jasmine, jest */
|
||||
/* eslint-disable react/jsx-no-bind */
|
||||
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import * as AssetRegistry from '../../../modules/AssetRegistry';
|
||||
import Image from '../';
|
||||
import ImageLoader from '../../../modules/ImageLoader';
|
||||
import ImageUriCache from '../ImageUriCache';
|
||||
import ImageLoader, { ImageUriCache } from '../../../modules/ImageLoader';
|
||||
import PixelRatio from '../../PixelRatio';
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
@@ -89,10 +89,18 @@ describe('components/Image', () => {
|
||||
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
|
||||
onLoad();
|
||||
});
|
||||
const onLoadStartStub = jest.fn();
|
||||
const onLoadStub = jest.fn();
|
||||
render(<Image onLoad={onLoadStub} source="https://test.com/img.jpg" />);
|
||||
const onLoadEndStub = jest.fn();
|
||||
render(
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source="https://test.com/img.jpg"
|
||||
/>
|
||||
);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(ImageLoader.load).toBeCalled();
|
||||
expect(onLoadStub).toBeCalled();
|
||||
});
|
||||
|
||||
@@ -101,32 +109,74 @@ describe('components/Image', () => {
|
||||
ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => {
|
||||
onLoad();
|
||||
});
|
||||
const onLoadStartStub = jest.fn();
|
||||
const onLoadStub = jest.fn();
|
||||
const onLoadEndStub = jest.fn();
|
||||
const uri = 'https://test.com/img.jpg';
|
||||
ImageUriCache.add(uri);
|
||||
render(<Image onLoad={onLoadStub} source={uri} />);
|
||||
render(
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source={uri}
|
||||
/>
|
||||
);
|
||||
jest.runOnlyPendingTimers();
|
||||
expect(ImageLoader.load).not.toBeCalled();
|
||||
expect(onLoadStub).toBeCalled();
|
||||
ImageUriCache.remove(uri);
|
||||
});
|
||||
|
||||
test('is called on update if "uri" is different', () => {
|
||||
const onLoadStartStub = jest.fn();
|
||||
const onLoadStub = jest.fn();
|
||||
const onLoadEndStub = jest.fn();
|
||||
const { rerender } = render(
|
||||
<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} />
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source={'https://test.com/img.jpg'}
|
||||
/>
|
||||
);
|
||||
rerender(<Image onLoad={onLoadStub} source={'https://blah.com/img.png'} />);
|
||||
act(() => {
|
||||
rerender(
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source={'https://blah.com/img.png'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
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(
|
||||
<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} />
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source={'https://test.com/img.jpg'}
|
||||
/>
|
||||
);
|
||||
rerender(<Image onLoad={onLoadStub} source={'https://test.com/img.jpg'} />);
|
||||
act(() => {
|
||||
rerender(
|
||||
<Image
|
||||
onLoad={onLoadStub}
|
||||
onLoadEnd={onLoadEndStub}
|
||||
onLoadStart={onLoadStartStub}
|
||||
source={'https://test.com/img.jpg'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
expect(onLoadStub.mock.calls.length).toBe(1);
|
||||
expect(onLoadEndStub.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,15 +228,19 @@ describe('components/Image', () => {
|
||||
ImageUriCache.remove(uriOne);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
// props update
|
||||
rerender(<Image source={{ uri: uriTwo }} />);
|
||||
ImageUriCache.remove(uriTwo);
|
||||
act(() => {
|
||||
rerender(<Image source={{ uri: uriTwo }} />);
|
||||
ImageUriCache.remove(uriTwo);
|
||||
});
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('is correctly updated when missing in initial render', () => {
|
||||
const uri = 'https://testing.com/img.jpg';
|
||||
const { container, rerender } = render(<Image />);
|
||||
rerender(<Image source={{ uri }} />);
|
||||
act(() => {
|
||||
rerender(<Image source={{ uri }} />);
|
||||
});
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -199,7 +253,9 @@ describe('components/Image', () => {
|
||||
});
|
||||
const { container } = render(<Image defaultSource={{ uri: defaultUri }} source={{ uri }} />);
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
loadCallback();
|
||||
act(() => {
|
||||
loadCallback();
|
||||
});
|
||||
expect(container.firstChild).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -215,8 +271,10 @@ describe('components/Image', () => {
|
||||
let { container } = render(<Image source={1} />);
|
||||
expect(container.querySelector('img').src).toBe('http://localhost/static/img.png');
|
||||
|
||||
PixelRatio.get = jest.fn(() => 2.2);
|
||||
({ container } = render(<Image source={1} />));
|
||||
act(() => {
|
||||
PixelRatio.get = jest.fn(() => 2.2);
|
||||
({ container } = render(<Image source={1} />));
|
||||
});
|
||||
expect(container.querySelector('img').src).toBe('http://localhost/static/img@2x.png');
|
||||
});
|
||||
});
|
||||
|
||||
+236
-301
@@ -11,18 +11,16 @@
|
||||
import type { ViewProps } from '../View';
|
||||
import type { ResizeMode, Source, Style } from './types';
|
||||
|
||||
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 ImageUriCache from './ImageUriCache';
|
||||
import PixelRatio from '../PixelRatio';
|
||||
import StyleSheet from '../StyleSheet';
|
||||
import TextAncestorContext from '../Text/TextAncestorContext';
|
||||
import View from '../View';
|
||||
import React from 'react';
|
||||
import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type ImageProps = {
|
||||
...ViewProps,
|
||||
@@ -40,34 +38,84 @@ export type ImageProps = {
|
||||
style?: Style
|
||||
};
|
||||
|
||||
type State = {
|
||||
layout: Object,
|
||||
shouldDisplaySource: boolean
|
||||
};
|
||||
const ERRORED = 'ERRORED';
|
||||
const LOADED = 'LOADED';
|
||||
const LOADING = 'LOADING';
|
||||
const IDLE = 'IDLE';
|
||||
|
||||
const STATUS_ERRORED = 'ERRORED';
|
||||
const STATUS_LOADED = 'LOADED';
|
||||
const STATUS_LOADING = 'LOADING';
|
||||
const STATUS_PENDING = 'PENDING';
|
||||
const STATUS_IDLE = 'IDLE';
|
||||
let _filterId = 0;
|
||||
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
|
||||
|
||||
const getImageState = (uri, shouldDisplaySource) => {
|
||||
return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE;
|
||||
};
|
||||
function createTintColorSVG(tintColor, id) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
const resolveAssetDimensions = source => {
|
||||
function getFlatStyle(style, blurRadius, filterId) {
|
||||
const flatStyle = { ...StyleSheet.flatten(style) };
|
||||
|
||||
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);
|
||||
}
|
||||
//
|
||||
if (blurRadius) {
|
||||
filters.push(`blur(${blurRadius}px)`);
|
||||
}
|
||||
if (shadowOffset) {
|
||||
const shadowString = resolveShadowValue(flatStyle);
|
||||
if (shadowString) {
|
||||
filters.push(`drop-shadow(${shadowString})`);
|
||||
}
|
||||
}
|
||||
if (tintColor && filterId != null) {
|
||||
filters.push(`url(#tint-${filterId})`);
|
||||
}
|
||||
|
||||
if (filters.length > 0) {
|
||||
_filter = filters.join(' ');
|
||||
}
|
||||
|
||||
// These styles are converted to CSS filters applied to the
|
||||
// element displaying the background image.
|
||||
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;
|
||||
|
||||
return [flatStyle, resizeMode, _filter, tintColor];
|
||||
}
|
||||
|
||||
function resolveAssetDimensions(source) {
|
||||
if (typeof source === 'number') {
|
||||
const { height, width } = getAssetByID(source);
|
||||
return { height, width };
|
||||
} else if (typeof source === 'object') {
|
||||
} else if (source != null && typeof source === 'object') {
|
||||
const { height, width } = source;
|
||||
return { height, width };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
|
||||
const resolveAssetUri = source => {
|
||||
let uri = '';
|
||||
function resolveAssetUri(source): ?string {
|
||||
let uri = null;
|
||||
if (typeof source === 'number') {
|
||||
// get the URI from the packager
|
||||
const asset = getAssetByID(source);
|
||||
@@ -98,306 +146,193 @@ const resolveAssetUri = source => {
|
||||
}
|
||||
|
||||
return uri;
|
||||
};
|
||||
}
|
||||
|
||||
let filterId = 0;
|
||||
const Image = forwardRef<ImageProps, *>((props, ref) => {
|
||||
const {
|
||||
accessibilityLabel,
|
||||
accessibilityRelationship,
|
||||
accessibilityRole,
|
||||
accessibilityState,
|
||||
accessible,
|
||||
blurRadius,
|
||||
defaultSource,
|
||||
draggable,
|
||||
importantForAccessibility,
|
||||
nativeID,
|
||||
onError,
|
||||
onLayout,
|
||||
onLoad,
|
||||
onLoadEnd,
|
||||
onLoadStart,
|
||||
pointerEvents,
|
||||
source,
|
||||
testID
|
||||
} = props;
|
||||
|
||||
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;
|
||||
|
||||
class Image extends React.Component<ImageProps, State> {
|
||||
static displayName = 'Image';
|
||||
|
||||
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 });
|
||||
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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
renderImage(hasTextAncestor) {
|
||||
const { shouldDisplaySource } = this.state;
|
||||
const {
|
||||
accessibilityLabel,
|
||||
accessibilityRelationship,
|
||||
accessibilityRole,
|
||||
accessibilityState,
|
||||
accessible,
|
||||
blurRadius,
|
||||
defaultSource,
|
||||
draggable,
|
||||
importantForAccessibility,
|
||||
nativeID,
|
||||
pointerEvents,
|
||||
resizeMode,
|
||||
source,
|
||||
testID
|
||||
} = this.props;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
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 || '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
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRelationship={accessibilityRelationship}
|
||||
accessibilityRole={accessibilityRole}
|
||||
accessibilityState={accessibilityState}
|
||||
accessible={accessible}
|
||||
importantForAccessibility={importantForAccessibility}
|
||||
nativeID={nativeID}
|
||||
onLayout={this._createLayoutHandler(finalResizeMode)}
|
||||
pointerEvents={pointerEvents}
|
||||
style={[styles.root, hasTextAncestor && 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>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<TextAncestorContext.Consumer>
|
||||
{hasTextAncestor => this.renderImage(hasTextAncestor)}
|
||||
</TextAncestorContext.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
_createImageLoader() {
|
||||
const { source } = this.props;
|
||||
this._destroyImageLoader();
|
||||
const [state, updateState] = useState(() => {
|
||||
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;
|
||||
if (uri != null) {
|
||||
const isLoaded = ImageLoader.has(uri);
|
||||
if (isLoaded) {
|
||||
return LOADED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return IDLE;
|
||||
});
|
||||
|
||||
_createLayoutHandler = resizeMode => {
|
||||
const { onLayout } = this.props;
|
||||
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
|
||||
return e => {
|
||||
const { layout } = e.nativeEvent;
|
||||
onLayout && onLayout(e);
|
||||
this.setState(() => ({ layout }));
|
||||
};
|
||||
}
|
||||
};
|
||||
const [layout, updateLayout] = useState({});
|
||||
const hasTextAncestor = useContext(TextAncestorContext);
|
||||
const hiddenImageRef = useRef(null);
|
||||
const filterRef = useRef(_filterId++);
|
||||
const requestRef = useRef(null);
|
||||
const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null);
|
||||
const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle(
|
||||
props.style,
|
||||
blurRadius,
|
||||
filterRef.current
|
||||
);
|
||||
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();
|
||||
|
||||
_getBackgroundSize = resizeMode => {
|
||||
if (this._imageRef && (resizeMode === 'center' || resizeMode === 'repeat')) {
|
||||
const { naturalHeight, naturalWidth } = this._imageRef;
|
||||
const { height, width } = this.state.layout;
|
||||
// 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 {
|
||||
backgroundSize: `${x}px ${y}px`
|
||||
};
|
||||
return `${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)`
|
||||
function handleLayout(e) {
|
||||
if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) {
|
||||
const { layout } = e.nativeEvent;
|
||||
onLayout && onLayout(e);
|
||||
updateLayout(layout);
|
||||
}
|
||||
}
|
||||
|
||||
// Image loading
|
||||
useEffect(() => {
|
||||
abortPendingRequest();
|
||||
|
||||
const uri = resolveAssetUri(source);
|
||||
|
||||
if (uri != null) {
|
||||
updateState(LOADING);
|
||||
if (onLoadStart) {
|
||||
onLoadStart();
|
||||
}
|
||||
|
||||
requestRef.current = ImageLoader.load(
|
||||
uri,
|
||||
function load(e) {
|
||||
updateState(LOADED);
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
if (onLoadEnd) {
|
||||
onLoadEnd();
|
||||
}
|
||||
},
|
||||
function error() {
|
||||
updateState(ERRORED);
|
||||
if (onError) {
|
||||
onError({
|
||||
nativeEvent: {
|
||||
error: `Failed to load resource ${uri} (404)`
|
||||
}
|
||||
});
|
||||
}
|
||||
if (onLoadEnd) {
|
||||
onLoadEnd();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
}
|
||||
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 }));
|
||||
function abortPendingRequest() {
|
||||
if (requestRef.current != null) {
|
||||
ImageLoader.abort(requestRef.current);
|
||||
requestRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return abortPendingRequest;
|
||||
}, [source, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]);
|
||||
|
||||
return (
|
||||
<View
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRelationship={accessibilityRelationship}
|
||||
accessibilityRole={accessibilityRole}
|
||||
accessibilityState={accessibilityState}
|
||||
accessible={accessible}
|
||||
importantForAccessibility={importantForAccessibility}
|
||||
nativeID={nativeID}
|
||||
onLayout={handleLayout}
|
||||
pointerEvents={pointerEvents}
|
||||
ref={ref}
|
||||
style={[styles.root, hasTextAncestor && styles.inline, imageSizeStyle, flatStyle]}
|
||||
testID={testID}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.image,
|
||||
resizeModeStyles[resizeMode],
|
||||
{ backgroundImage, filter },
|
||||
backgroundSize != null && { backgroundSize }
|
||||
]}
|
||||
/>
|
||||
{hiddenImage}
|
||||
{createTintColorSVG(tintColor, filterRef.current)}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
Image.displayName = 'Image';
|
||||
|
||||
// $FlowFixMe
|
||||
Image.getSize = function(uri, success, failure) {
|
||||
ImageLoader.getSize(uri, success, failure);
|
||||
};
|
||||
|
||||
// $FlowFixMe
|
||||
Image.prefetch = function(uri) {
|
||||
return ImageLoader.prefetch(uri);
|
||||
};
|
||||
|
||||
// $FlowFixMe
|
||||
Image.queryCache = function(uris) {
|
||||
return ImageLoader.queryCache(uris);
|
||||
};
|
||||
|
||||
const classes = css.create({
|
||||
accessibilityImage: {
|
||||
@@ -454,4 +389,4 @@ const resizeModeStyles = StyleSheet.create({
|
||||
}
|
||||
});
|
||||
|
||||
export default applyNativeMethods(Image);
|
||||
export default Image;
|
||||
|
||||
+2
-2
@@ -33,14 +33,14 @@ export type ViewStyle = {
|
||||
backgroundBlendMode?: string,
|
||||
backgroundClip?: string,
|
||||
backgroundColor?: ColorValue,
|
||||
backgroundImage?: string,
|
||||
backgroundImage?: ?string,
|
||||
backgroundOrigin?: 'border-box' | 'content-box' | 'padding-box',
|
||||
backgroundPosition?: string,
|
||||
backgroundRepeat?: string,
|
||||
backgroundSize?: string,
|
||||
boxShadow?: string,
|
||||
clip?: string,
|
||||
filter?: string,
|
||||
filter?: ?string,
|
||||
opacity?: number,
|
||||
outlineColor?: ColorValue,
|
||||
outlineOffset?: string | number,
|
||||
|
||||
+93
-7
@@ -4,9 +4,71 @@
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @noflow
|
||||
* @flow
|
||||
*/
|
||||
|
||||
const dataUriPattern = /^data:/;
|
||||
|
||||
export class ImageUriCache {
|
||||
static _maximumEntries: number = 256;
|
||||
static _entries = {};
|
||||
|
||||
static has(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
const isDataUri = dataUriPattern.test(uri);
|
||||
return isDataUri || Boolean(entries[uri]);
|
||||
}
|
||||
|
||||
static add(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
const lastUsedTimestamp = Date.now();
|
||||
if (entries[uri]) {
|
||||
entries[uri].lastUsedTimestamp = lastUsedTimestamp;
|
||||
entries[uri].refCount += 1;
|
||||
} else {
|
||||
entries[uri] = {
|
||||
lastUsedTimestamp,
|
||||
refCount: 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static remove(uri: string) {
|
||||
const entries = ImageUriCache._entries;
|
||||
if (entries[uri]) {
|
||||
entries[uri].refCount -= 1;
|
||||
}
|
||||
// Free up entries when the cache is "full"
|
||||
ImageUriCache._cleanUpIfNeeded();
|
||||
}
|
||||
|
||||
static _cleanUpIfNeeded() {
|
||||
const entries = ImageUriCache._entries;
|
||||
const imageUris = Object.keys(entries);
|
||||
|
||||
if (imageUris.length + 1 > ImageUriCache._maximumEntries) {
|
||||
let leastRecentlyUsedKey;
|
||||
let leastRecentlyUsedEntry;
|
||||
|
||||
imageUris.forEach(uri => {
|
||||
const entry = entries[uri];
|
||||
if (
|
||||
(!leastRecentlyUsedEntry ||
|
||||
entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) &&
|
||||
entry.refCount === 0
|
||||
) {
|
||||
leastRecentlyUsedKey = uri;
|
||||
leastRecentlyUsedEntry = entry;
|
||||
}
|
||||
});
|
||||
|
||||
if (leastRecentlyUsedKey) {
|
||||
delete entries[leastRecentlyUsedKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = 0;
|
||||
const requests = {};
|
||||
|
||||
@@ -14,11 +76,13 @@ const ImageLoader = {
|
||||
abort(requestId: number) {
|
||||
let image = requests[`${requestId}`];
|
||||
if (image) {
|
||||
image.onerror = image.onload = image = null;
|
||||
image.onerror = null;
|
||||
image.onload = null;
|
||||
image = null;
|
||||
delete requests[`${requestId}`];
|
||||
}
|
||||
},
|
||||
getSize(uri, success, failure) {
|
||||
getSize(uri: string, success: Function, failure: Function) {
|
||||
let complete = false;
|
||||
const interval = setInterval(callback, 16);
|
||||
const requestId = ImageLoader.load(uri, callback, errorCallback);
|
||||
@@ -46,13 +110,16 @@ const ImageLoader = {
|
||||
clearInterval(interval);
|
||||
}
|
||||
},
|
||||
load(uri, onLoad, onError): number {
|
||||
has(uri: string) {
|
||||
return ImageUriCache.has(uri);
|
||||
},
|
||||
load(uri: string, onLoad: Function, onError: Function): number {
|
||||
id += 1;
|
||||
const image = new window.Image();
|
||||
image.onerror = onError;
|
||||
image.onload = e => {
|
||||
// avoid blocking the main thread
|
||||
const onDecode = () => onLoad(e);
|
||||
const onDecode = () => onLoad();
|
||||
if (typeof image.decode === 'function') {
|
||||
// Safari currently throws exceptions when decoding svgs.
|
||||
// We want to catch that error and allow the load handler
|
||||
@@ -66,10 +133,29 @@ const ImageLoader = {
|
||||
requests[`${id}`] = image;
|
||||
return id;
|
||||
},
|
||||
prefetch(uri): Promise {
|
||||
prefetch(uri: string): Promise<*> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ImageLoader.load(uri, resolve, reject);
|
||||
ImageLoader.load(
|
||||
uri,
|
||||
() => {
|
||||
// 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);
|
||||
resolve();
|
||||
},
|
||||
reject
|
||||
);
|
||||
});
|
||||
},
|
||||
queryCache(uris: Array<string>): Object {
|
||||
const result = {};
|
||||
uris.forEach(u => {
|
||||
if (ImageUriCache.has(u)) {
|
||||
result[u] = 'disk/memory';
|
||||
}
|
||||
});
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user