update: latest source changes
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
import { requireNativeViewManager } from 'expo-modules-core';
|
||||
import React from 'react';
|
||||
import { NativeSyntheticEvent, StyleSheet, Platform, processColor } from 'react-native';
|
||||
|
||||
import {
|
||||
ImageErrorEventData,
|
||||
ImageLoadEventData,
|
||||
ImageNativeProps,
|
||||
ImageProgressEventData,
|
||||
} from './Image.types';
|
||||
|
||||
const NativeExpoImage = requireNativeViewManager('ExpoImage');
|
||||
|
||||
function withDeprecatedNativeEvent<NativeEvent>(
|
||||
event: NativeSyntheticEvent<NativeEvent>
|
||||
): NativeEvent {
|
||||
Object.defineProperty(event.nativeEvent, 'nativeEvent', {
|
||||
get() {
|
||||
console.warn(
|
||||
'[expo-image]: Accessing event payload through "nativeEvent" is deprecated, it is now part of the event object itself'
|
||||
);
|
||||
return event.nativeEvent;
|
||||
},
|
||||
});
|
||||
return event.nativeEvent;
|
||||
}
|
||||
|
||||
class ExpoImage extends React.PureComponent<ImageNativeProps> {
|
||||
// NOTE(@kitten): native methods
|
||||
startAnimating!: () => Promise<unknown> | unknown;
|
||||
stopAnimating!: () => Promise<unknown> | unknown;
|
||||
lockResourceAsync!: () => Promise<void>;
|
||||
unlockResourceAsync!: () => Promise<void>;
|
||||
reloadAsync!: () => Promise<void>;
|
||||
|
||||
onLoadStart = () => {
|
||||
this.props.onLoadStart?.();
|
||||
};
|
||||
|
||||
onLoad = (event: NativeSyntheticEvent<ImageLoadEventData>) => {
|
||||
this.props.onLoad?.(withDeprecatedNativeEvent(event));
|
||||
this.onLoadEnd();
|
||||
};
|
||||
|
||||
onProgress = (event: NativeSyntheticEvent<ImageProgressEventData>) => {
|
||||
this.props.onProgress?.(withDeprecatedNativeEvent(event));
|
||||
};
|
||||
|
||||
onError = (event: NativeSyntheticEvent<ImageErrorEventData>) => {
|
||||
this.props.onError?.(withDeprecatedNativeEvent(event));
|
||||
this.onLoadEnd();
|
||||
};
|
||||
|
||||
onLoadEnd = () => {
|
||||
this.props.onLoadEnd?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { style, accessibilityLabel, alt, ...props } = this.props;
|
||||
const resolvedStyle = StyleSheet.flatten(style);
|
||||
|
||||
// Shadows behave different on iOS, Android & Web.
|
||||
// Android uses the `elevation` prop, whereas iOS
|
||||
// and web use the regular `shadow...` props.
|
||||
if (Platform.OS === 'android') {
|
||||
delete resolvedStyle.shadowColor;
|
||||
delete resolvedStyle.shadowOffset;
|
||||
delete resolvedStyle.shadowOpacity;
|
||||
delete resolvedStyle.shadowRadius;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
delete resolvedStyle.elevation;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const backgroundColor = processColor(resolvedStyle.backgroundColor);
|
||||
// On Android, we have to set the `backgroundColor` directly on the correct component.
|
||||
// So we have to remove it from styles. Otherwise, the background color won't take into consideration the border-radius.
|
||||
if (Platform.OS === 'android') {
|
||||
delete resolvedStyle.backgroundColor;
|
||||
}
|
||||
|
||||
const tintColor = processColor(props.tintColor || resolvedStyle.tintColor);
|
||||
|
||||
const borderColor = processColor(resolvedStyle.borderColor);
|
||||
// @ts-ignore
|
||||
const borderStartColor = processColor(resolvedStyle.borderStartColor);
|
||||
// @ts-ignore
|
||||
const borderEndColor = processColor(resolvedStyle.borderEndColor);
|
||||
// @ts-ignore
|
||||
const borderLeftColor = processColor(resolvedStyle.borderLeftColor);
|
||||
// @ts-ignore
|
||||
const borderRightColor = processColor(resolvedStyle.borderRightColor);
|
||||
// @ts-ignore
|
||||
const borderTopColor = processColor(resolvedStyle.borderTopColor);
|
||||
// @ts-ignore
|
||||
const borderBottomColor = processColor(resolvedStyle.borderBottomColor);
|
||||
|
||||
return (
|
||||
<NativeExpoImage
|
||||
{...props}
|
||||
{...resolvedStyle}
|
||||
accessibilityLabel={accessibilityLabel ?? alt}
|
||||
style={resolvedStyle}
|
||||
onLoadStart={this.onLoadStart}
|
||||
onLoad={this.onLoad}
|
||||
onProgress={this.onProgress}
|
||||
onError={this.onError}
|
||||
tintColor={tintColor}
|
||||
borderColor={borderColor}
|
||||
borderLeftColor={borderLeftColor}
|
||||
borderRightColor={borderRightColor}
|
||||
borderTopColor={borderTopColor}
|
||||
borderBottomColor={borderBottomColor}
|
||||
borderStartColor={borderStartColor}
|
||||
borderEndColor={borderEndColor}
|
||||
backgroundColor={backgroundColor}
|
||||
ref={props.nativeViewRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ExpoImage;
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
import React from 'react';
|
||||
// TODO(@kitten): We shouldn't be importing all of react-native-web or rely on it for a web module in this way optimally
|
||||
import { View } from 'react-native-web';
|
||||
|
||||
import type { ImageNativeProps, ImageSource, ImageLoadEventData, ImageRef } from './Image.types';
|
||||
import AnimationManager, { AnimationManagerNode } from './web/AnimationManager';
|
||||
import ImageWrapper from './web/ImageWrapper';
|
||||
import loadStyle from './web/imageStyles';
|
||||
import useSourceSelection from './web/useSourceSelection';
|
||||
|
||||
loadStyle();
|
||||
|
||||
function onLoadAdapter(onLoad?: (event: ImageLoadEventData) => void) {
|
||||
return (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
const target = event.target as HTMLImageElement;
|
||||
onLoad?.({
|
||||
source: {
|
||||
url: target.currentSrc,
|
||||
width: target.naturalWidth,
|
||||
height: target.naturalHeight,
|
||||
mediaType: null,
|
||||
},
|
||||
cacheType: 'none',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function onErrorAdapter(onError?: { (event: { error: string }): void }) {
|
||||
return ({ source }: { source?: ImageSource | null }) => {
|
||||
onError?.({
|
||||
error: `Failed to load image from url: ${source?.uri}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Used for flip transitions to mimic native animations
|
||||
function setCssVariablesForFlipTransitions(element: HTMLElement, size: DOMRect) {
|
||||
element?.style.setProperty('--expo-image-width', `${size.width}px`);
|
||||
element?.style.setProperty('--expo-image-height', `${size.height}px`);
|
||||
}
|
||||
|
||||
function isFlipTransition(transition: ImageNativeProps['transition']) {
|
||||
return (
|
||||
transition?.effect === 'flip-from-bottom' ||
|
||||
transition?.effect === 'flip-from-top' ||
|
||||
transition?.effect === 'flip-from-left' ||
|
||||
transition?.effect === 'flip-from-right'
|
||||
);
|
||||
}
|
||||
|
||||
function getAnimationKey(
|
||||
source: ImageSource | ImageRef | undefined,
|
||||
recyclingKey?: string | null
|
||||
): string {
|
||||
const uri = (source && 'uri' in source && source.uri) || '';
|
||||
return recyclingKey ? [recyclingKey, uri].join('-') : uri;
|
||||
}
|
||||
|
||||
export default function ExpoImage({
|
||||
source,
|
||||
placeholder,
|
||||
contentFit,
|
||||
contentPosition,
|
||||
placeholderContentFit,
|
||||
cachePolicy,
|
||||
onLoad,
|
||||
transition,
|
||||
onError,
|
||||
responsivePolicy,
|
||||
onLoadEnd,
|
||||
onDisplay,
|
||||
priority,
|
||||
blurRadius,
|
||||
recyclingKey,
|
||||
style,
|
||||
nativeViewRef,
|
||||
accessibilityLabel,
|
||||
alt,
|
||||
tintColor,
|
||||
containerViewRef,
|
||||
...props
|
||||
}: ImageNativeProps) {
|
||||
const imagePlaceholderContentFit = placeholderContentFit || 'scale-down';
|
||||
const imageHashStyle = {
|
||||
objectFit: placeholderContentFit || contentFit,
|
||||
};
|
||||
const selectedSource = useSourceSelection(
|
||||
source,
|
||||
responsivePolicy,
|
||||
// TODO(@vonovak): this cast is a workaround
|
||||
containerViewRef as React.RefObject<HTMLDivElement | null>,
|
||||
isFlipTransition(transition) ? setCssVariablesForFlipTransitions : null
|
||||
);
|
||||
|
||||
// TODO(@kitten): This should narrow before accessing `placeholder?.[0]`
|
||||
const firstPlaceholder = (placeholder as (typeof placeholder & ImageSource[]) | undefined)?.[0];
|
||||
const initialNodeAnimationKey = getAnimationKey(firstPlaceholder, recyclingKey);
|
||||
const initialNode: AnimationManagerNode | null = firstPlaceholder?.uri
|
||||
? [
|
||||
initialNodeAnimationKey,
|
||||
({ onAnimationFinished }) =>
|
||||
(className, style) => (
|
||||
<ImageWrapper
|
||||
ref={nativeViewRef as React.Ref<HTMLImageElement> | undefined}
|
||||
source={firstPlaceholder}
|
||||
style={{
|
||||
objectFit: imagePlaceholderContentFit,
|
||||
...(blurRadius ? { filter: `blur(${blurRadius}px)` } : {}),
|
||||
...style,
|
||||
}}
|
||||
className={className}
|
||||
events={{
|
||||
onTransitionEnd: [onAnimationFinished],
|
||||
}}
|
||||
contentPosition={{ left: '50%', top: '50%' }}
|
||||
hashPlaceholderContentPosition={contentPosition}
|
||||
hashPlaceholderStyle={imageHashStyle}
|
||||
accessibilityLabel={accessibilityLabel ?? alt}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
),
|
||||
]
|
||||
: null;
|
||||
|
||||
// @ts-expect-error: TODO(@kitten): This was implicitly cast to `any`, but with correct types this is now a mismatch
|
||||
const currentNodeAnimationKey = getAnimationKey(selectedSource ?? firstPlaceholder, recyclingKey);
|
||||
const currentNode: AnimationManagerNode = [
|
||||
currentNodeAnimationKey,
|
||||
({ onAnimationFinished, onReady, onMount, onError: onErrorInner }) =>
|
||||
(className, style) => (
|
||||
<ImageWrapper
|
||||
ref={nativeViewRef as React.Ref<HTMLImageElement> | undefined}
|
||||
// @ts-expect-error: TODO(@kitten): This was implicitly cast to `any`, but with correct types this is now a mismatch
|
||||
source={selectedSource || firstPlaceholder}
|
||||
events={{
|
||||
onError: [onErrorAdapter(onError), onLoadEnd, onErrorInner],
|
||||
onLoad: [onLoadAdapter(onLoad), onLoadEnd, onReady],
|
||||
onMount: [onMount],
|
||||
onTransitionEnd: [onAnimationFinished],
|
||||
onDisplay: [onDisplay],
|
||||
}}
|
||||
style={{
|
||||
objectFit: selectedSource ? contentFit : imagePlaceholderContentFit,
|
||||
...(blurRadius ? { filter: `blur(${blurRadius}px)` } : {}),
|
||||
...style,
|
||||
}}
|
||||
className={className}
|
||||
cachePolicy={cachePolicy}
|
||||
priority={priority}
|
||||
contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }}
|
||||
hashPlaceholderContentPosition={contentPosition}
|
||||
hashPlaceholderStyle={imageHashStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
tintColor={tintColor}
|
||||
/>
|
||||
),
|
||||
];
|
||||
return (
|
||||
<View
|
||||
ref={containerViewRef}
|
||||
// @ts-expect-error: TODO(@kitten): This is related to react-native-web presumably
|
||||
dataSet={{ expoimage: true }}
|
||||
style={[{ overflow: 'hidden' }, style]}
|
||||
{...props}>
|
||||
<AnimationManager transition={transition} recyclingKey={recyclingKey} initial={initialNode}>
|
||||
{currentNode}
|
||||
</AnimationManager>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { Platform, createSnapshotFriendlyRef } from 'expo-modules-core';
|
||||
import React from 'react';
|
||||
import { StyleSheet, type View } from 'react-native';
|
||||
|
||||
import ExpoImage from './ExpoImage';
|
||||
import {
|
||||
ImageLoadOptions,
|
||||
ImagePrefetchOptions,
|
||||
ImageProps,
|
||||
ImageRef,
|
||||
ImageSource,
|
||||
} from './Image.types';
|
||||
import ImageModule from './ImageModule';
|
||||
import { resolveContentFit, resolveContentPosition, resolveTransition } from './utils';
|
||||
import { resolveSource, resolveSources } from './utils/resolveSources';
|
||||
|
||||
let loggedDefaultSourceDeprecationWarning = false;
|
||||
let loggedRenderingChildrenWarning = false;
|
||||
|
||||
export class Image extends React.PureComponent<ImageProps> {
|
||||
nativeViewRef: React.RefObject<ExpoImage | null>;
|
||||
containerViewRef: React.RefObject<View | null>;
|
||||
|
||||
constructor(props: ImageProps) {
|
||||
super(props);
|
||||
this.nativeViewRef = createSnapshotFriendlyRef();
|
||||
this.containerViewRef = createSnapshotFriendlyRef();
|
||||
}
|
||||
|
||||
// Reanimated support on web
|
||||
getAnimatableRef = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
return this.containerViewRef.current;
|
||||
} else {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
static Image = ImageModule.Image;
|
||||
|
||||
/**
|
||||
* Preloads images at the given URLs that can be later used in the image view.
|
||||
* Preloaded images are cached to the memory and disk by default, so make sure
|
||||
* to use `disk` (default) or `memory-disk` [cache policy](#cachepolicy).
|
||||
* @param urls - A URL string or an array of URLs of images to prefetch.
|
||||
* @param {ImagePrefetchOptions['cachePolicy']} cachePolicy - The cache policy for prefetched images.
|
||||
* @return A promise resolving to `true` as soon as all images have been
|
||||
* successfully prefetched. If an image fails to be prefetched, the promise
|
||||
* will immediately resolve to `false` regardless of whether other images have
|
||||
* finished prefetching.
|
||||
*/
|
||||
static async prefetch(
|
||||
urls: string | string[],
|
||||
cachePolicy?: ImagePrefetchOptions['cachePolicy']
|
||||
): Promise<boolean>;
|
||||
/**
|
||||
* Preloads images at the given URLs that can be later used in the image view.
|
||||
* Preloaded images are cached to the memory and disk by default, so make sure
|
||||
* to use `disk` (default) or `memory-disk` [cache policy](#cachepolicy).
|
||||
* @param urls - A URL string or an array of URLs of images to prefetch.
|
||||
* @param options - Options for prefetching images.
|
||||
* @return A promise resolving to `true` as soon as all images have been
|
||||
* successfully prefetched. If an image fails to be prefetched, the promise
|
||||
* will immediately resolve to `false` regardless of whether other images have
|
||||
* finished prefetching.
|
||||
*/
|
||||
static async prefetch(urls: string | string[], options?: ImagePrefetchOptions): Promise<boolean>;
|
||||
static async prefetch(
|
||||
urls: string | string[],
|
||||
options?: ImagePrefetchOptions['cachePolicy'] | ImagePrefetchOptions
|
||||
): Promise<boolean> {
|
||||
let cachePolicy: ImagePrefetchOptions['cachePolicy'] = 'memory-disk';
|
||||
let headers: ImagePrefetchOptions['headers'];
|
||||
switch (typeof options) {
|
||||
case 'string':
|
||||
cachePolicy = options;
|
||||
break;
|
||||
case 'object':
|
||||
cachePolicy = options.cachePolicy ?? cachePolicy;
|
||||
headers = options.headers;
|
||||
break;
|
||||
}
|
||||
|
||||
return ImageModule.prefetch(Array.isArray(urls) ? urls : [urls], cachePolicy, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously clears all images stored in memory.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @return A promise resolving to `true` when the operation succeeds.
|
||||
* It may resolve to `false` on Android when the activity is no longer available.
|
||||
* Resolves to `false` on Web.
|
||||
*/
|
||||
static async clearMemoryCache(): Promise<boolean> {
|
||||
return await ImageModule.clearMemoryCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously clears all images from the disk cache.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @return A promise resolving to `true` when the operation succeeds.
|
||||
* It may resolve to `false` on Android when the activity is no longer available.
|
||||
* Resolves to `false` on Web.
|
||||
*/
|
||||
static async clearDiskCache(): Promise<boolean> {
|
||||
return await ImageModule.clearDiskCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously checks if an image exists in the disk cache and resolves to
|
||||
* the path of the cached image if it does.
|
||||
* @param cacheKey - The cache key for the requested image. Unless you have set
|
||||
* a custom cache key, this will be the source URL of the image.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @return A promise resolving to the path of the cached image. It will resolve
|
||||
* to `null` if the image does not exist in the cache.
|
||||
*/
|
||||
static async getCachePathAsync(cacheKey: string): Promise<string | null> {
|
||||
return await ImageModule.getCachePathAsync(cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously generates a [Blurhash](https://blurha.sh) from an image.
|
||||
* @param source - The image source, either a URL (string) or an ImageRef
|
||||
* @param numberOfComponents - The number of components to encode the blurhash with.
|
||||
* Must be between 1 and 9. Defaults to `[4, 3]`.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @return A promise resolving to the blurhash string.
|
||||
*/
|
||||
static async generateBlurhashAsync(
|
||||
source: string | ImageRef,
|
||||
numberOfComponents: [number, number] | { width: number; height: number }
|
||||
): Promise<string | null> {
|
||||
return ImageModule.generateBlurhashAsync(source, numberOfComponents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously generates a [Thumbhash](https://evanw.github.io/thumbhash/) from an image.
|
||||
* @param source - The image source, either a URL (string) or an ImageRef
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @return A promise resolving to the thumbhash string.
|
||||
*/
|
||||
static async generateThumbhashAsync(source: string | ImageRef): Promise<string> {
|
||||
return ImageModule.generateThumbhashAsync(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously starts playback of the view's image if it is animated.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
async startAnimating(): Promise<void> {
|
||||
await this.nativeViewRef.current?.startAnimating();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously stops the playback of the view's image if it is animated.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
async stopAnimating(): Promise<void> {
|
||||
await this.nativeViewRef.current?.stopAnimating();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the resource from being reloaded by locking it.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
async lockResourceAsync(): Promise<void> {
|
||||
await this.nativeViewRef.current?.lockResourceAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the lock on the resource, allowing it to be reloaded.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
async unlockResourceAsync(): Promise<void> {
|
||||
await this.nativeViewRef.current?.unlockResourceAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the resource, ignoring lock.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
async reloadAsync(): Promise<void> {
|
||||
await this.nativeViewRef.current?.reloadAsync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an image from the given source to memory and resolves to
|
||||
* an object that references the native image instance.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @platform web
|
||||
*/
|
||||
static async loadAsync(
|
||||
source: ImageSource | string | number,
|
||||
options?: ImageLoadOptions
|
||||
): Promise<ImageRef> {
|
||||
const resolvedSource = resolveSource(source) as ImageSource;
|
||||
return await ImageModule.loadAsync(resolvedSource, options);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
style,
|
||||
source,
|
||||
placeholder,
|
||||
contentFit,
|
||||
contentPosition,
|
||||
transition,
|
||||
fadeDuration,
|
||||
resizeMode: resizeModeProp,
|
||||
defaultSource,
|
||||
loadingIndicatorSource,
|
||||
...restProps
|
||||
} = this.props;
|
||||
|
||||
const { resizeMode: resizeModeStyle, ...restStyle } = StyleSheet.flatten(style) || {};
|
||||
const resizeMode = resizeModeProp ?? resizeModeStyle;
|
||||
|
||||
if ((defaultSource || loadingIndicatorSource) && !loggedDefaultSourceDeprecationWarning) {
|
||||
console.warn(
|
||||
'[expo-image]: `defaultSource` and `loadingIndicatorSource` props are deprecated, use `placeholder` instead'
|
||||
);
|
||||
loggedDefaultSourceDeprecationWarning = true;
|
||||
}
|
||||
// @ts-expect-error
|
||||
if (restProps.children && !loggedRenderingChildrenWarning) {
|
||||
console.warn(
|
||||
'The <Image> component does not support children. If you want to render content on top of the image, consider using the <ImageBackground> component or absolute positioning.'
|
||||
);
|
||||
loggedRenderingChildrenWarning = true;
|
||||
}
|
||||
return (
|
||||
<ExpoImage
|
||||
{...restProps}
|
||||
style={restStyle}
|
||||
source={resolveSources(source)}
|
||||
placeholder={resolveSources(placeholder ?? defaultSource ?? loadingIndicatorSource)}
|
||||
contentFit={resolveContentFit(contentFit, resizeMode)}
|
||||
contentPosition={resolveContentPosition(contentPosition)}
|
||||
transition={resolveTransition(transition, fadeDuration)}
|
||||
nativeViewRef={this.nativeViewRef}
|
||||
containerViewRef={this.containerViewRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
import type { NativeModule, SharedRef, SharedRefType } from 'expo';
|
||||
import { ImageStyle as RNImageStyle, StyleProp, View, ViewProps, ViewStyle } from 'react-native';
|
||||
|
||||
import ExpoImage from './ExpoImage';
|
||||
|
||||
export type ImageSource = {
|
||||
/**
|
||||
* A string representing the resource identifier for the image,
|
||||
* which could be an HTTPS address, a local file path, or the name of a static image resource.
|
||||
*/
|
||||
uri?: string;
|
||||
/**
|
||||
* An object representing the HTTP headers to send along with the request for a remote image.
|
||||
* On web requires the `Access-Control-Allow-Origin` header returned by the server to include the current domain.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* Can be specified if known at build time, in which case the value
|
||||
* will be used to set the default `<Image/>` component dimension.
|
||||
*/
|
||||
width?: number | null;
|
||||
/**
|
||||
* Can be specified if known at build time, in which case the value
|
||||
* will be used to set the default `<Image/>` component dimension.
|
||||
*/
|
||||
height?: number | null;
|
||||
|
||||
/**
|
||||
* A string used to generate the image [`placeholder`](#placeholder). For example,
|
||||
* `placeholder={blurhash}`. If `uri` is provided as the value of the `source` prop,
|
||||
* this is ignored since the `source` can only have `blurhash` or `uri`.
|
||||
*
|
||||
* When using the blurhash, you should also provide `width` and `height` (higher values reduce performance),
|
||||
* otherwise their default value is `16`.
|
||||
* For more information, see [`woltapp/blurhash`](https://github.com/woltapp/blurhash) repository.
|
||||
*/
|
||||
blurhash?: string;
|
||||
|
||||
/**
|
||||
* A string used to generate the image [`placeholder`](#placeholder). For example,
|
||||
* `placeholder={thumbhash}`. If `uri` is provided as the value of the `source` prop,
|
||||
* this is ignored since the `source` can only have `thumbhash` or `uri`.
|
||||
*
|
||||
* For more information, see [`thumbhash website`](https://evanw.github.io/thumbhash/).
|
||||
*/
|
||||
thumbhash?: string;
|
||||
|
||||
/**
|
||||
* The cache key used to query and store this specific image.
|
||||
* If not provided, the `uri` is used also as the cache key.
|
||||
*/
|
||||
cacheKey?: string;
|
||||
/**
|
||||
* The max width of the viewport for which this source should be selected.
|
||||
* Has no effect if `source` prop is not an array or has only 1 element.
|
||||
* Has no effect if `responsivePolicy` is not set to `static`.
|
||||
* Ignored if `blurhash` or `thumbhash` is provided (image hashes are never selected if passed in an array).
|
||||
* @platform web
|
||||
*/
|
||||
webMaxViewportWidth?: number;
|
||||
/**
|
||||
* Whether the image is animated (an animated GIF or WebP for example).
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
isAnimated?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
export type ImageStyle = RNImageStyle;
|
||||
|
||||
/**
|
||||
* Determines how the image should be resized to fit its container.
|
||||
* @hidden Described in the {@link ImageProps['contentFit']}
|
||||
*/
|
||||
export type ImageContentFit = 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
||||
|
||||
/**
|
||||
* Determines which format should be used to decode the image.
|
||||
* It's suggestion for the platform to use the specified format, but it's not guaranteed.
|
||||
* @hidden Described in the {@link ImageProps['decodeFormat']}
|
||||
*/
|
||||
export type ImageDecodeFormat = 'argb' | 'rgb';
|
||||
|
||||
/**
|
||||
* Some props are from React Native Image that Expo Image supports (more or less) for easier migration,
|
||||
* but all of them are deprecated and might be removed in the future.
|
||||
*/
|
||||
export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> {
|
||||
/** @hidden */
|
||||
style?: StyleProp<RNImageStyle>;
|
||||
|
||||
/**
|
||||
* The image source, either a remote URL, a local file resource or a number that is the result of the `require()` function.
|
||||
* When provided as an array of sources, the source that fits best into the container size and is closest to the screen scale
|
||||
* will be chosen. In this case it is important to provide `width`, `height` and `scale` properties.
|
||||
*/
|
||||
source?: ImageSource | string | number | ImageSource[] | string[] | SharedRefType<'image'> | null;
|
||||
|
||||
/**
|
||||
* An image to display while loading the proper image and no image has been displayed yet or the source is unset.
|
||||
*
|
||||
* > **Note**: The default value for placeholder's content fit is 'scale-down', which differs from the source image's default value.
|
||||
* > Using a lower-resolution placeholder may cause flickering due to scaling differences between it and the final image.
|
||||
* > To prevent this, you can set the [`placeholderContentFit`](#placeholdercontentfit) to match the [`contentFit`](#contentfit) value.
|
||||
*/
|
||||
placeholder?:
|
||||
| ImageSource
|
||||
| string
|
||||
| number
|
||||
| ImageSource[]
|
||||
| string[]
|
||||
| SharedRefType<'image'>
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Determines how the image should be resized to fit its container. This property tells the image to fill the container
|
||||
* in a variety of ways; such as "preserve that aspect ratio" or "stretch up and take up as much space as possible".
|
||||
* It mirrors the CSS [`object-fit`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) property.
|
||||
*
|
||||
* - `'cover'` - The image is sized to maintain its aspect ratio while filling the container box.
|
||||
* If the image's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
|
||||
*
|
||||
* - `'contain'` - The image is scaled down or up to maintain its aspect ratio while fitting within the container box.
|
||||
*
|
||||
* - `'fill'` - The image is sized to entirely fill the container box. If necessary, the image will be stretched or squished to fit.
|
||||
*
|
||||
* - `'none'` - The image is not resized and is centered by default.
|
||||
* When specified, the exact position can be controlled with [`contentPosition`](#contentposition) prop.
|
||||
*
|
||||
* - `'scale-down'` - The image is sized as if `none` or `contain` were specified, whichever would result in a smaller concrete image size.
|
||||
*
|
||||
* @default 'cover'
|
||||
*/
|
||||
contentFit?: ImageContentFit;
|
||||
|
||||
/**
|
||||
* Determines how the placeholder should be resized to fit its container. Available resize modes are the same as for the [`contentFit`](#contentfit) prop.
|
||||
* @default 'scale-down'
|
||||
*/
|
||||
placeholderContentFit?: ImageContentFit;
|
||||
|
||||
/**
|
||||
* It is used together with [`contentFit`](#contentfit) to specify how the image should be positioned with x/y coordinates inside its own container.
|
||||
* An equivalent of the CSS [`object-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) property.
|
||||
* @default 'center'
|
||||
*/
|
||||
contentPosition?: ImageContentPosition;
|
||||
|
||||
/**
|
||||
* Describes how the image view should transition the contents when switching the image source.\
|
||||
* If provided as a number, it is the duration in milliseconds of the `'cross-dissolve'` effect.
|
||||
*/
|
||||
transition?: ImageTransition | number | null;
|
||||
|
||||
/**
|
||||
* The radius of the blur in points, `0` means no blur effect.
|
||||
* This effect is not applied to placeholders.
|
||||
* @default 0
|
||||
*/
|
||||
blurRadius?: number;
|
||||
|
||||
/**
|
||||
* A color used to tint template images (a bitmap image where only the opacity matters).
|
||||
* The color is applied to every non-transparent pixel, causing the image's shape to adopt that color.
|
||||
* This effect is not applied to placeholders.
|
||||
* @default null
|
||||
*/
|
||||
tintColor?: string | null;
|
||||
|
||||
/**
|
||||
* Priorities for completing loads. If more than one load is queued at a time,
|
||||
* the load with the higher priority will be started first.
|
||||
* Priorities are considered best effort, there are no guarantees about the order in which loads will start or finish.
|
||||
* @default 'normal'
|
||||
*/
|
||||
priority?: 'low' | 'normal' | 'high' | null;
|
||||
|
||||
/**
|
||||
* Determines whether to cache the image and where: on the disk, in the memory or both.
|
||||
*
|
||||
* - `'none'` - Image is not cached at all.
|
||||
*
|
||||
* - `'disk'` - Image is queried from the disk cache if exists, otherwise it's downloaded and then stored on the disk.
|
||||
*
|
||||
* - `'memory'` - Image is cached in memory. Might be useful when you render a high-resolution picture many times.
|
||||
* Memory cache may be purged very quickly to prevent high memory usage and the risk of out of memory exceptions.
|
||||
*
|
||||
* - `'memory-disk'` - Image is cached in memory, but with a fallback to the disk cache.
|
||||
*
|
||||
* @default 'disk'
|
||||
*/
|
||||
cachePolicy?: 'none' | 'disk' | 'memory' | 'memory-disk' | /** @hidden */ null;
|
||||
|
||||
/**
|
||||
* Controls the selection of the image source based on the container or viewport size on the web.
|
||||
*
|
||||
* If set to `'static'`, the browser selects the correct source based on user's viewport width. Works with static rendering.
|
||||
* Make sure to set the `'webMaxViewportWidth'` property on each source for best results.
|
||||
* For example, if an image occupies 1/3 of the screen width, set the `'webMaxViewportWidth'` to 3x the image width.
|
||||
* The source with the largest `'webMaxViewportWidth'` is used even for larger viewports.
|
||||
*
|
||||
* If set to `'initial'`, the component will select the correct source during mount based on container size. Does not work with static rendering.
|
||||
*
|
||||
* If set to `'live'`, the component will select the correct source on every resize based on container size. Does not work with static rendering.
|
||||
*
|
||||
* @default 'static'
|
||||
* @platform web
|
||||
*/
|
||||
responsivePolicy?: 'live' | 'initial' | 'static';
|
||||
|
||||
/**
|
||||
* Changing this prop resets the image view content to blank or a placeholder before loading and rendering the final image.
|
||||
* This is especially useful for any kinds of recycling views like [FlashList](https://github.com/shopify/flash-list)
|
||||
* to prevent showing the previous source before the new one fully loads.
|
||||
* @default null
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
recyclingKey?: string | null;
|
||||
|
||||
/**
|
||||
* Determines if an image should automatically begin playing if it is an
|
||||
* animated image.
|
||||
* @default true
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
autoplay?: boolean;
|
||||
|
||||
/**
|
||||
* Called when the image starts to load.
|
||||
*/
|
||||
onLoadStart?: () => void;
|
||||
|
||||
/**
|
||||
* Called when the image load completes successfully.
|
||||
*/
|
||||
onLoad?: (event: ImageLoadEventData) => void;
|
||||
|
||||
/**
|
||||
* Called when the image is loading. Can be called multiple times before the image has finished loading.
|
||||
* The event object provides details on how many bytes were loaded so far and what's the expected total size.
|
||||
*/
|
||||
onProgress?: (event: ImageProgressEventData) => void;
|
||||
|
||||
/**
|
||||
* Called on an image fetching error.
|
||||
*/
|
||||
onError?: (event: ImageErrorEventData) => void;
|
||||
|
||||
/**
|
||||
* Called when the image load either succeeds or fails.
|
||||
*/
|
||||
onLoadEnd?: () => void;
|
||||
|
||||
/**
|
||||
* Called when the image view successfully rendered the source image.
|
||||
*/
|
||||
onDisplay?: () => void;
|
||||
|
||||
// DEPRECATED
|
||||
|
||||
/**
|
||||
* @deprecated Provides compatibility for [`defaultSource` from React Native Image](https://reactnative.dev/docs/image#defaultsource).
|
||||
* Use [`placeholder`](#placeholder) prop instead.
|
||||
*/
|
||||
defaultSource?: ImageSource | null;
|
||||
|
||||
/**
|
||||
* @deprecated Provides compatibility for [`loadingIndicatorSource` from React Native Image](https://reactnative.dev/docs/image#loadingindicatorsource).
|
||||
* Use [`placeholder`](#placeholder) prop instead.
|
||||
*/
|
||||
loadingIndicatorSource?: ImageSource | null;
|
||||
|
||||
/**
|
||||
* @deprecated Provides compatibility for [`resizeMode` from React Native Image](https://reactnative.dev/docs/image#resizemode).
|
||||
* Note that `"repeat"` option is not supported at all.
|
||||
* Use the more powerful [`contentFit`](#contentfit) and [`contentPosition`](#contentposition) props instead.
|
||||
*/
|
||||
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
|
||||
|
||||
/**
|
||||
* @deprecated Provides compatibility for [`fadeDuration` from React Native Image](https://reactnative.dev/docs/image#fadeduration-android).
|
||||
* Instead use [`transition`](#transition) with the provided duration.
|
||||
*/
|
||||
fadeDuration?: number;
|
||||
|
||||
/**
|
||||
* Whether this View should be focusable with a non-touch input device and receive focus with a hardware keyboard.
|
||||
* @default false
|
||||
* @platform android
|
||||
*/
|
||||
focusable?: boolean;
|
||||
|
||||
/**
|
||||
* When true, indicates that the view is an accessibility element.
|
||||
* When a view is an accessibility element, it groups its children into a single selectable component.
|
||||
*
|
||||
* On Android, the `accessible` property will be translated into the native `isScreenReaderFocusable`,
|
||||
* so it's only affecting the screen readers behaviour.
|
||||
* @default false
|
||||
* @platform android
|
||||
* @platform ios
|
||||
*/
|
||||
accessible?: boolean;
|
||||
|
||||
/**
|
||||
* The text that's read by the screen reader when the user interacts with the image. Sets the `alt` tag on web which is used for web crawlers and link traversal.
|
||||
* @default undefined
|
||||
*/
|
||||
accessibilityLabel?: string;
|
||||
|
||||
/**
|
||||
* The text that's read by the screen reader when the user interacts with the image. Sets the `alt` tag on web which is used for web crawlers and link traversal. Is an alias for `accessibilityLabel`.
|
||||
*
|
||||
* @alias accessibilityLabel
|
||||
* @default undefined
|
||||
*/
|
||||
alt?: string;
|
||||
|
||||
/**
|
||||
* Enables Live Text interaction with the image. Check official [Apple documentation](https://developer.apple.com/documentation/visionkit/enabling_live_text_interactions_with_images) for more details.
|
||||
* @default false
|
||||
* @platform ios 16.0+
|
||||
*/
|
||||
enableLiveTextInteraction?: boolean;
|
||||
|
||||
/**
|
||||
* Whether the image should be downscaled to match the size of the view container.
|
||||
* Turning off this functionality could negatively impact the application's performance, particularly when working with large assets.
|
||||
* However, it would result in smoother image resizing, and end-users would always have access to the highest possible asset quality.
|
||||
*
|
||||
* Downscaling is never used when the `contentFit` prop is set to `none` or `fill`.
|
||||
* @default true
|
||||
*/
|
||||
allowDownscaling?: boolean;
|
||||
|
||||
/**
|
||||
* The format in which the image data should be decoded.
|
||||
* It's not guaranteed that the platform will use the specified format.
|
||||
*
|
||||
* - `'argb'` - The image is decoded into a 32-bit color space with alpha channel (https://developer.android.com/reference/android/graphics/Bitmap.Config#ARGB_8888).
|
||||
*
|
||||
* - `'rgb'` - The image is decoded into a 16-bit color space without alpha channel (https://developer.android.com/reference/android/graphics/Bitmap.Config#RGB_565).
|
||||
*
|
||||
* @default 'argb'
|
||||
* @platform android
|
||||
*/
|
||||
decodeFormat?: ImageDecodeFormat;
|
||||
|
||||
/**
|
||||
* Whether to use the Apple's default WebP codec.
|
||||
*
|
||||
* Set this prop to `false` to use the official standard-compliant [libwebp](https://github.com/webmproject/libwebp) codec for WebP images.
|
||||
* The default implementation from Apple is faster and uses less memory but may render animated images with incorrect blending or play them at the wrong framerate.
|
||||
* @see https://github.com/SDWebImage/SDWebImage/wiki/Advanced-Usage#awebp-coder
|
||||
*
|
||||
* @default true
|
||||
* @platform ios
|
||||
*/
|
||||
useAppleWebpCodec?: boolean;
|
||||
|
||||
/**
|
||||
* Force early resizing of the image to match the container size.
|
||||
* This option helps to reduce the memory usage of the image view, especially when the image is larger than the container.
|
||||
* It may affect the `resizeType` and `contentPosition` properties when the image view is resized dynamically.
|
||||
*
|
||||
* @default false
|
||||
* @platform ios
|
||||
*/
|
||||
enforceEarlyResizing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* It narrows down some props to types expected by the native/web side.
|
||||
* @hidden
|
||||
*/
|
||||
export interface ImageNativeProps extends ImageProps {
|
||||
style?: RNImageStyle;
|
||||
source?: ImageSource[] | SharedRefType<'image'>;
|
||||
placeholder?: ImageSource[] | SharedRefType<'image'>;
|
||||
contentPosition?: ImageContentPositionObject;
|
||||
transition?: ImageTransition | null;
|
||||
autoplay?: boolean;
|
||||
nativeViewRef?: React.RefObject<ExpoImage | null>;
|
||||
containerViewRef?: React.RefObject<View | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A value that represents the relative position of a single axis.
|
||||
*
|
||||
* If `number`, it is a distance in points (logical pixels) from the respective edge.\
|
||||
* If `string`, it must be a percentage value where `'100%'` is the difference in size between the container and the image along the respective axis,
|
||||
* or `'center'` which is an alias for `'50%'` that is the default value. You can read more regarding percentages on the MDN docs for
|
||||
* [`background-position`](https://developer.mozilla.org/en-US/docs/Web/CSS/background-position#regarding_percentages) that describes this concept well.
|
||||
*/
|
||||
export type ImageContentPositionValue = number | string | `${number}%` | `${number}` | 'center';
|
||||
|
||||
/**
|
||||
* Specifies the position of the image inside its container. One value controls the x-axis and the second value controls the y-axis.
|
||||
*
|
||||
* Additionally, it supports stringified shorthand form that specifies the edges to which to align the image content:\
|
||||
* `'center'`, `'top'`, `'right'`, `'bottom'`, `'left'`, `'top center'`, `'top right'`, `'top left'`, `'right center'`, `'right top'`,
|
||||
* `'right bottom'`, `'bottom center'`, `'bottom right'`, `'bottom left'`, `'left center'`, `'left top'`, `'left bottom'`.\
|
||||
* If only one keyword is provided, then the other dimension is set to `'center'` (`'50%'`), so the image is placed in the middle of the specified edge.\
|
||||
* As an example, `'top right'` is the same as `{ top: 0, right: 0 }` and `'bottom'` is the same as `{ bottom: 0, left: '50%' }`.
|
||||
*/
|
||||
export type ImageContentPosition =
|
||||
/**
|
||||
* An object that positions the image relatively to the top-right corner.
|
||||
*/
|
||||
| {
|
||||
top?: ImageContentPositionValue;
|
||||
right?: ImageContentPositionValue;
|
||||
}
|
||||
/**
|
||||
* An object that positions the image relatively to the top-left corner.
|
||||
*/
|
||||
| {
|
||||
top?: ImageContentPositionValue;
|
||||
left?: ImageContentPositionValue;
|
||||
}
|
||||
/**
|
||||
* An object that positions the image relatively to the bottom-right corner.
|
||||
*/
|
||||
| {
|
||||
bottom?: ImageContentPositionValue;
|
||||
right?: ImageContentPositionValue;
|
||||
}
|
||||
/**
|
||||
* An object that positions the image relatively to the bottom-left corner.
|
||||
*/
|
||||
| {
|
||||
bottom?: ImageContentPositionValue;
|
||||
left?: ImageContentPositionValue;
|
||||
}
|
||||
| ImageContentPositionString;
|
||||
|
||||
/**
|
||||
* It allows you to use an image as a background while rendering other content on top of it.
|
||||
* It extends all `Image` props but provides separate styling controls for the container and the background image itself.
|
||||
*/
|
||||
export interface ImageBackgroundProps extends Omit<ImageProps, 'style'> {
|
||||
/** The style of the image container. */
|
||||
style?: StyleProp<ViewStyle> | undefined;
|
||||
/** Style object for the image. */
|
||||
imageStyle?: StyleProp<RNImageStyle> | undefined;
|
||||
/** @hidden */
|
||||
children?: React.ReactNode | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @hidden It's described as part of {@link ImageContentPosition}.
|
||||
*/
|
||||
export type ImageContentPositionString =
|
||||
| 'center'
|
||||
| 'top'
|
||||
| 'right'
|
||||
| 'bottom'
|
||||
| 'left'
|
||||
| 'top center'
|
||||
| 'top right'
|
||||
| 'top left'
|
||||
| 'right center'
|
||||
| 'right top'
|
||||
| 'right bottom'
|
||||
| 'bottom center'
|
||||
| 'bottom right'
|
||||
| 'bottom left'
|
||||
| 'left center'
|
||||
| 'left top'
|
||||
| 'left bottom';
|
||||
|
||||
type OnlyObject<T> = T extends object ? T : never;
|
||||
|
||||
/**
|
||||
* @hidden It's a conditional type that matches only objects of {@link ImageContentPosition}.
|
||||
*/
|
||||
export type ImageContentPositionObject = OnlyObject<ImageContentPosition>;
|
||||
|
||||
/**
|
||||
* An object that describes the smooth transition when switching the image source.
|
||||
*/
|
||||
export type ImageTransition = {
|
||||
/**
|
||||
* The duration of the transition in milliseconds.
|
||||
* @default 0
|
||||
*/
|
||||
duration?: number;
|
||||
|
||||
/**
|
||||
* Specifies the speed curve of the transition effect and how intermediate values are calculated.
|
||||
* @default 'ease-in-out'
|
||||
*/
|
||||
timing?: 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||
|
||||
/**
|
||||
* An animation effect used for transition.
|
||||
* @default 'cross-dissolve'
|
||||
*
|
||||
* On Android, only `'cross-dissolve'` is supported.
|
||||
* On Web, `'curl-up'` and `'curl-down'` effects are not supported.
|
||||
*/
|
||||
effect?:
|
||||
| 'cross-dissolve'
|
||||
| 'flip-from-top'
|
||||
| 'flip-from-right'
|
||||
| 'flip-from-bottom'
|
||||
| 'flip-from-left'
|
||||
| 'curl-up'
|
||||
| 'curl-down'
|
||||
| null;
|
||||
};
|
||||
|
||||
export type ImageLoadEventData = {
|
||||
cacheType: 'none' | 'disk' | 'memory';
|
||||
source: {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
mediaType: string | null;
|
||||
isAnimated?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ImageProgressEventData = {
|
||||
loaded: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ImageErrorEventData = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
export type ImagePrefetchOptions = {
|
||||
/**
|
||||
* The cache policy for prefetched images.
|
||||
* @default 'memory-disk'
|
||||
*/
|
||||
cachePolicy?: 'disk' | 'memory-disk' | 'memory';
|
||||
|
||||
/**
|
||||
* A map of headers to use when prefetching the images.
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* An object that is a reference to a native image instance – [Drawable](https://developer.android.com/reference/android/graphics/drawable/Drawable)
|
||||
* on Android and [UIImage](https://developer.apple.com/documentation/uikit/uiimage) on iOS.
|
||||
* Instances of this class can be passed as a source to the [Image](#image) component in which case the image is rendered immediately
|
||||
* since its native representation is already available in the memory.
|
||||
*/
|
||||
export declare class ImageRef extends SharedRef<'image'> {
|
||||
/**
|
||||
* Logical width of the image. Multiply it by the value in the `scale` property to get the width in pixels.
|
||||
*/
|
||||
readonly width: number;
|
||||
/**
|
||||
* Logical height of the image. Multiply it by the value in the `scale` property to get the height in pixels.
|
||||
*/
|
||||
readonly height: number;
|
||||
/**
|
||||
* On iOS, if you load an image from a file whose name includes the `@2x` modifier, the scale is set to **2.0**. All other images are assumed to have a scale factor of **1.0**.
|
||||
* On Android, it calculates the scale based on the bitmap density divided by screen density.
|
||||
*
|
||||
* On all platforms, if you multiply the logical size of the image by this value, you get the dimensions of the image in pixels.
|
||||
*/
|
||||
readonly scale: number;
|
||||
/**
|
||||
* Media type (also known as MIME type) of the image, based on its format.
|
||||
* Returns `null` when the format is unknown or not supported.
|
||||
* @platform ios
|
||||
*/
|
||||
readonly mediaType: string | null;
|
||||
/**
|
||||
* Whether the referenced image is an animated image.
|
||||
*/
|
||||
readonly isAnimated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @hidden
|
||||
*/
|
||||
export declare class ImageNativeModule extends NativeModule {
|
||||
// TODO: Add missing function declarations
|
||||
Image: typeof ImageRef;
|
||||
|
||||
loadAsync(source: ImageSource, options?: ImageLoadOptions): Promise<ImageRef>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An object with options for the [`useImage`](#useimage) hook.
|
||||
*/
|
||||
export type ImageLoadOptions = {
|
||||
/**
|
||||
* If provided, the image will be automatically resized to not exceed this width in pixels, preserving its aspect ratio.
|
||||
*/
|
||||
maxWidth?: number;
|
||||
|
||||
/**
|
||||
* If provided, the image will be automatically resized to not exceed this height in pixels, preserving its aspect ratio.
|
||||
*/
|
||||
maxHeight?: number;
|
||||
|
||||
/**
|
||||
* Function to call when the image has failed to load. In addition to the error, it also provides a function that retries loading the image.
|
||||
*/
|
||||
onError?(error: Error, retry: () => void): void;
|
||||
};
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
|
||||
import { Image } from './Image';
|
||||
import { ImageBackgroundProps } from './Image.types';
|
||||
|
||||
export function ImageBackground({ style, imageStyle, children, ...props }: ImageBackgroundProps) {
|
||||
return (
|
||||
<View style={style}>
|
||||
<Image {...props} style={[StyleSheet.absoluteFill, imageStyle]} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { requireNativeModule } from 'expo';
|
||||
|
||||
import type { ImageNativeModule } from './Image.types';
|
||||
|
||||
export default requireNativeModule<ImageNativeModule>('ExpoImage');
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { NativeModule, registerWebModule } from 'expo-modules-core';
|
||||
|
||||
import type { ImageNativeModule, ImageRef, ImageSource } from './Image.types';
|
||||
import ImageRefWeb from './web/ImageRef';
|
||||
|
||||
class ImageModule extends NativeModule implements ImageNativeModule {
|
||||
Image: typeof ImageRef = ImageRefWeb;
|
||||
|
||||
async prefetch(urls: string | string[], _: unknown, __: unknown): Promise<boolean> {
|
||||
const urlsArray = Array.isArray(urls) ? urls : [urls];
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let imagesLoaded = 0;
|
||||
|
||||
urlsArray.forEach((url) => {
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
imagesLoaded++;
|
||||
|
||||
if (imagesLoaded === urlsArray.length) {
|
||||
resolve(true);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async clearMemoryCache(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async clearDiskCache(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async loadAsync(source: ImageSource): Promise<ImageRef> {
|
||||
if (!source.uri) {
|
||||
// TODO: Add support for sources without the uri, e.g. blurhash and thumbhash.
|
||||
throw new Error('The image source must have the "uri" property defined');
|
||||
}
|
||||
const response = await fetch(source.uri, {
|
||||
headers: source.headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image request failed with the status code: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const imageObjectUrl = URL.createObjectURL(blob);
|
||||
const image = await loadImageElementAsync(imageObjectUrl);
|
||||
|
||||
return ImageRefWeb.init(
|
||||
imageObjectUrl,
|
||||
image.width,
|
||||
image.height,
|
||||
response.headers.get('Content-Type')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper that resolves to an `<img />` element once it finishes loading the given source.
|
||||
*/
|
||||
async function loadImageElementAsync(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Unable to load the image from '${src}'`));
|
||||
image.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export default registerWebModule(ImageModule, 'ExpoImage');
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export * from './Image.types';
|
||||
export { Image } from './Image';
|
||||
export { ImageBackground } from './ImageBackground';
|
||||
export { useImage } from './useImage';
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference path="../../../expo-asset/src/ts-declarations/react-native-assets.d.ts" />
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
declare module 'react-native-web' {
|
||||
export { View } from 'react-native';
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { DependencyList, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Image } from './Image';
|
||||
import type { ImageLoadOptions, ImageRef, ImageSource } from './Image.types';
|
||||
import { resolveSource } from './utils/resolveSources';
|
||||
|
||||
/**
|
||||
* A hook that loads an image from the given source and returns a reference
|
||||
* to the native image instance, or `null` until the first image is successfully loaded.
|
||||
*
|
||||
* It loads a new image every time the `uri` of the provided source changes.
|
||||
* To trigger reloads in some other scenarios, you can provide an additional dependency list.
|
||||
* @platform android
|
||||
* @platform ios
|
||||
* @platform web
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { useImage, Image } from 'expo-image';
|
||||
* import { Text } from 'react-native';
|
||||
*
|
||||
* export default function MyImage() {
|
||||
* const image = useImage('https://picsum.photos/1000/800', {
|
||||
* maxWidth: 800,
|
||||
* onError(error, retry) {
|
||||
* console.error('Loading failed:', error.message);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* if (!image) {
|
||||
* return <Text>Image is loading...</Text>;
|
||||
* }
|
||||
*
|
||||
* return <Image source={image} style={{ width: image.width / 2, height: image.height / 2 }} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useImage(
|
||||
source: ImageSource | string | number,
|
||||
options: ImageLoadOptions = {},
|
||||
dependencies: DependencyList = []
|
||||
): ImageRef | null {
|
||||
const resolvedSource = resolveSource(source) as ImageSource;
|
||||
const [image, setImage] = useState<ImageRef | null>(null);
|
||||
|
||||
// Since options are not dependencies of the below effect, we store them in a ref.
|
||||
// Once the image is asynchronously loaded, the effect will use the most recent options,
|
||||
// instead of the captured ones (especially important for callbacks that may change in subsequent renders).
|
||||
const optionsRef = useRef<ImageLoadOptions>(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
useEffect(() => {
|
||||
// We're doing some asynchronous action in this effect, so we should keep track
|
||||
// if the effect was already cleaned up. In that case, the async action shouldn't change the state.
|
||||
let isEffectValid = true;
|
||||
|
||||
function loadImage() {
|
||||
Image.loadAsync(resolvedSource, options)
|
||||
.then((image) => {
|
||||
if (isEffectValid) {
|
||||
setImage(image);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isEffectValid) {
|
||||
return;
|
||||
}
|
||||
if (optionsRef.current.onError) {
|
||||
optionsRef.current.onError(error, loadImage);
|
||||
} else {
|
||||
// Print unhandled errors to the console.
|
||||
console.error(
|
||||
`Loading an image from '${resolvedSource.uri}' failed, use 'onError' option to handle errors and suppress this message`
|
||||
);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadImage();
|
||||
|
||||
return () => {
|
||||
// Invalidate the effect and release the shared object to free up memory.
|
||||
isEffectValid = false;
|
||||
image?.release();
|
||||
};
|
||||
}, [resolvedSource.uri, ...dependencies]);
|
||||
|
||||
return image;
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { SharedRef } from 'expo';
|
||||
import type { SharedRefType } from 'expo';
|
||||
import { type ImageResizeMode } from 'react-native';
|
||||
|
||||
import {
|
||||
ImageContentFit,
|
||||
ImageContentPosition,
|
||||
ImageContentPositionObject,
|
||||
ImageContentPositionString,
|
||||
ImageProps,
|
||||
ImageTransition,
|
||||
} from './Image.types';
|
||||
|
||||
let loggedResizeModeDeprecationWarning = false;
|
||||
let loggedRepeatDeprecationWarning = false;
|
||||
let loggedFadeDurationDeprecationWarning = false;
|
||||
|
||||
/**
|
||||
* If the `contentFit` is not provided, it's resolved from the equivalent `resizeMode` prop
|
||||
* that we support to provide compatibility with React Native Image.
|
||||
*/
|
||||
export function resolveContentFit(
|
||||
contentFit?: ImageContentFit,
|
||||
resizeMode?: ImageResizeMode
|
||||
): ImageContentFit {
|
||||
if (contentFit) {
|
||||
return contentFit;
|
||||
}
|
||||
if (resizeMode) {
|
||||
if (!loggedResizeModeDeprecationWarning) {
|
||||
console.log('[expo-image]: Prop "resizeMode" is deprecated, use "contentFit" instead');
|
||||
loggedResizeModeDeprecationWarning = true;
|
||||
}
|
||||
|
||||
switch (resizeMode) {
|
||||
case 'contain':
|
||||
case 'cover':
|
||||
case 'none':
|
||||
return resizeMode;
|
||||
case 'stretch':
|
||||
return 'fill';
|
||||
case 'center':
|
||||
return 'scale-down';
|
||||
case 'repeat':
|
||||
if (!loggedRepeatDeprecationWarning) {
|
||||
console.log('[expo-image]: Resize mode "repeat" is no longer supported');
|
||||
loggedRepeatDeprecationWarning = true;
|
||||
}
|
||||
return 'cover';
|
||||
default: {
|
||||
const exhaustiveCheck: never = resizeMode;
|
||||
throw new Error(`Unhandled resizeMode case: ${exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'cover';
|
||||
}
|
||||
|
||||
/**
|
||||
* It resolves a stringified form of the `contentPosition` prop to an object,
|
||||
* which is the only form supported in the native code.
|
||||
*/
|
||||
export function resolveContentPosition(
|
||||
contentPosition?: ImageContentPosition
|
||||
): ImageContentPositionObject {
|
||||
if (typeof contentPosition === 'string') {
|
||||
const contentPositionStringMappings: Record<
|
||||
ImageContentPositionString,
|
||||
ImageContentPositionObject
|
||||
> = {
|
||||
center: { top: '50%', left: '50%' },
|
||||
top: { top: 0, left: '50%' },
|
||||
right: { top: '50%', right: 0 },
|
||||
bottom: { bottom: 0, left: '50%' },
|
||||
left: { top: '50%', left: 0 },
|
||||
'top center': { top: 0, left: '50%' },
|
||||
'top right': { top: 0, right: 0 },
|
||||
'top left': { top: 0, left: 0 },
|
||||
'right center': { top: '50%', right: 0 },
|
||||
'right top': { top: 0, right: 0 },
|
||||
'right bottom': { bottom: 0, right: 0 },
|
||||
'bottom center': { bottom: 0, left: '50%' },
|
||||
'bottom right': { bottom: 0, right: 0 },
|
||||
'bottom left': { bottom: 0, left: 0 },
|
||||
'left center': { top: '50%', left: 0 },
|
||||
'left top': { top: 0, left: 0 },
|
||||
'left bottom': { bottom: 0, left: 0 },
|
||||
};
|
||||
const contentPositionObject = contentPositionStringMappings[contentPosition];
|
||||
|
||||
if (!contentPositionObject) {
|
||||
console.warn(`[expo-image]: Content position "${contentPosition}" is invalid`);
|
||||
return contentPositionStringMappings.center;
|
||||
}
|
||||
return contentPositionObject;
|
||||
}
|
||||
return contentPosition ?? { top: '50%', left: '50%' };
|
||||
}
|
||||
|
||||
/**
|
||||
* If `transition` or `fadeDuration` is a number, it's resolved to a cross dissolve transition with the given duration.
|
||||
* When `fadeDuration` is used, it logs an appropriate deprecation warning.
|
||||
*/
|
||||
export function resolveTransition(
|
||||
transition?: ImageProps['transition'],
|
||||
fadeDuration?: ImageProps['fadeDuration']
|
||||
): ImageTransition | null {
|
||||
if (typeof transition === 'number') {
|
||||
return { duration: transition };
|
||||
}
|
||||
if (!transition && typeof fadeDuration === 'number') {
|
||||
if (!loggedFadeDurationDeprecationWarning) {
|
||||
console.warn('[expo-image]: Prop "fadeDuration" is deprecated, use "transition" instead');
|
||||
loggedFadeDurationDeprecationWarning = true;
|
||||
}
|
||||
return { duration: fadeDuration };
|
||||
}
|
||||
return transition ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the given value is an instance of the `SharedRef<'image'>` class.
|
||||
*/
|
||||
export function isImageRef(value: any): value is SharedRefType<'image'> {
|
||||
return value instanceof SharedRef && value.nativeRefType === 'image';
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { PackagerAsset } from '@react-native/assets-registry/registry';
|
||||
import { Platform } from 'expo-modules-core';
|
||||
import { PixelRatio } from 'react-native';
|
||||
|
||||
export type ResolvedAssetSource = {
|
||||
__packager_asset: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
uri: string;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
// Returns the Metro dev server-specific asset location.
|
||||
function getScaledAssetPath(asset: PackagerAsset): string {
|
||||
const scale = AssetSourceResolver.pickScale(asset.scales, PixelRatio.get());
|
||||
const scaleSuffix = scale === 1 ? '' : '@' + scale + 'x';
|
||||
const type = !asset.type ? '' : `.${asset.type}`;
|
||||
if (__DEV__) {
|
||||
return asset.httpServerLocation + '/' + asset.name + scaleSuffix + type;
|
||||
} else {
|
||||
return asset.httpServerLocation.replace(/\.\.\//g, '_') + '/' + asset.name + scaleSuffix + type;
|
||||
}
|
||||
}
|
||||
|
||||
export default class AssetSourceResolver {
|
||||
serverUrl: string;
|
||||
// where the jsbundle is being run from
|
||||
// NOTE(EvanBacon): Never defined on web.
|
||||
jsbundleUrl?: string | null;
|
||||
// the asset to resolve
|
||||
asset: PackagerAsset;
|
||||
|
||||
constructor(
|
||||
serverUrl: string | undefined | null,
|
||||
jsbundleUrl: string | undefined | null,
|
||||
asset: PackagerAsset
|
||||
) {
|
||||
this.serverUrl = serverUrl || 'https://expo.dev';
|
||||
this.jsbundleUrl = null;
|
||||
this.asset = asset;
|
||||
}
|
||||
|
||||
// Always true for web runtimes
|
||||
isLoadedFromServer(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Always false for web runtimes
|
||||
isLoadedFromFileSystem(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
defaultAsset(): ResolvedAssetSource {
|
||||
return this.assetServerURL();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns absolute remote URL for the hosted asset.
|
||||
*/
|
||||
assetServerURL(): ResolvedAssetSource {
|
||||
const fromUrl = new URL(getScaledAssetPath(this.asset), this.serverUrl);
|
||||
fromUrl.searchParams.set('platform', Platform.OS);
|
||||
fromUrl.searchParams.set('hash', this.asset.hash);
|
||||
return this.fromSource(
|
||||
// Relative on web
|
||||
fromUrl.toString().replace(fromUrl.origin, '')
|
||||
);
|
||||
}
|
||||
|
||||
fromSource(source: string): ResolvedAssetSource {
|
||||
return {
|
||||
__packager_asset: true,
|
||||
width: this.asset.width ?? undefined,
|
||||
height: this.asset.height ?? undefined,
|
||||
uri: source,
|
||||
scale: AssetSourceResolver.pickScale(this.asset.scales, PixelRatio.get()),
|
||||
};
|
||||
}
|
||||
|
||||
static pickScale(scales: number[], deviceScale: number): number {
|
||||
for (let i = 0; i < scales.length; i++) {
|
||||
if (scales[i] >= deviceScale) {
|
||||
return scales[i];
|
||||
}
|
||||
}
|
||||
return scales[scales.length - 1] || 1;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
const digitCharacters = [
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
'G',
|
||||
'H',
|
||||
'I',
|
||||
'J',
|
||||
'K',
|
||||
'L',
|
||||
'M',
|
||||
'N',
|
||||
'O',
|
||||
'P',
|
||||
'Q',
|
||||
'R',
|
||||
'S',
|
||||
'T',
|
||||
'U',
|
||||
'V',
|
||||
'W',
|
||||
'X',
|
||||
'Y',
|
||||
'Z',
|
||||
'a',
|
||||
'b',
|
||||
'c',
|
||||
'd',
|
||||
'e',
|
||||
'f',
|
||||
'g',
|
||||
'h',
|
||||
'i',
|
||||
'j',
|
||||
'k',
|
||||
'l',
|
||||
'm',
|
||||
'n',
|
||||
'o',
|
||||
'p',
|
||||
'q',
|
||||
'r',
|
||||
's',
|
||||
't',
|
||||
'u',
|
||||
'v',
|
||||
'w',
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
'#',
|
||||
'$',
|
||||
'%',
|
||||
'*',
|
||||
'+',
|
||||
',',
|
||||
'-',
|
||||
'.',
|
||||
':',
|
||||
';',
|
||||
'=',
|
||||
'?',
|
||||
'@',
|
||||
'[',
|
||||
']',
|
||||
'^',
|
||||
'_',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
'~',
|
||||
];
|
||||
|
||||
export const decode83 = (str: string) => {
|
||||
let value = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const c = str[i];
|
||||
const digit = digitCharacters.indexOf(c);
|
||||
value = value * 83 + digit;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const encode83 = (n: number, length: number): string => {
|
||||
let result = '';
|
||||
for (let i = 1; i <= length; i++) {
|
||||
const digit = (Math.floor(n) / Math.pow(83, length - i)) % 83;
|
||||
result += digitCharacters[Math.floor(digit)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { decode83 } from './base83';
|
||||
import { ValidationError } from './error';
|
||||
import { sRGBToLinear, signPow, linearTosRGB } from './utils';
|
||||
|
||||
/**
|
||||
* Returns an error message if invalid or undefined if valid
|
||||
* @param blurhash
|
||||
*/
|
||||
const validateBlurhash = (blurhash: string) => {
|
||||
if (!blurhash || blurhash.length < 6) {
|
||||
throw new ValidationError('The blurhash string must be at least 6 characters');
|
||||
}
|
||||
|
||||
const sizeFlag = decode83(blurhash[0]);
|
||||
const numY = Math.floor(sizeFlag / 9) + 1;
|
||||
const numX = (sizeFlag % 9) + 1;
|
||||
|
||||
if (blurhash.length !== 4 + 2 * numX * numY) {
|
||||
throw new ValidationError(
|
||||
`blurhash length mismatch: length is ${blurhash.length} but it should be ${
|
||||
4 + 2 * numX * numY
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const isBlurhashValid = (blurhash: string): { result: boolean; errorReason?: string } => {
|
||||
try {
|
||||
validateBlurhash(blurhash);
|
||||
} catch (error: any) {
|
||||
return { result: false, errorReason: error.message };
|
||||
}
|
||||
|
||||
return { result: true };
|
||||
};
|
||||
|
||||
const decodeDC = (value: number) => {
|
||||
const intR = value >> 16;
|
||||
const intG = (value >> 8) & 255;
|
||||
const intB = value & 255;
|
||||
return [sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)];
|
||||
};
|
||||
|
||||
const decodeAC = (value: number, maximumValue: number) => {
|
||||
const quantR = Math.floor(value / (19 * 19));
|
||||
const quantG = Math.floor(value / 19) % 19;
|
||||
const quantB = value % 19;
|
||||
|
||||
const rgb = [
|
||||
signPow((quantR - 9) / 9, 2.0) * maximumValue,
|
||||
signPow((quantG - 9) / 9, 2.0) * maximumValue,
|
||||
signPow((quantB - 9) / 9, 2.0) * maximumValue,
|
||||
];
|
||||
|
||||
return rgb;
|
||||
};
|
||||
|
||||
const decode = (blurhash: string, width: number, height: number, punch?: number) => {
|
||||
validateBlurhash(blurhash);
|
||||
|
||||
punch = (punch || 1) | 1;
|
||||
|
||||
const sizeFlag = decode83(blurhash[0]);
|
||||
const numY = Math.floor(sizeFlag / 9) + 1;
|
||||
const numX = (sizeFlag % 9) + 1;
|
||||
|
||||
const quantisedMaximumValue = decode83(blurhash[1]);
|
||||
const maximumValue = (quantisedMaximumValue + 1) / 166;
|
||||
|
||||
const colors = new Array(numX * numY);
|
||||
|
||||
for (let i = 0; i < colors.length; i++) {
|
||||
if (i === 0) {
|
||||
const value = decode83(blurhash.substring(2, 6));
|
||||
colors[i] = decodeDC(value);
|
||||
} else {
|
||||
const value = decode83(blurhash.substring(4 + i * 2, 6 + i * 2));
|
||||
colors[i] = decodeAC(value, maximumValue * punch);
|
||||
}
|
||||
}
|
||||
|
||||
const bytesPerRow = width * 4;
|
||||
const pixels = new Uint8ClampedArray(bytesPerRow * height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
for (let j = 0; j < numY; j++) {
|
||||
for (let i = 0; i < numX; i++) {
|
||||
const basis = Math.cos((Math.PI * x * i) / width) * Math.cos((Math.PI * y * j) / height);
|
||||
const color = colors[i + j * numX];
|
||||
r += color[0] * basis;
|
||||
g += color[1] * basis;
|
||||
b += color[2] * basis;
|
||||
}
|
||||
}
|
||||
|
||||
const intR = linearTosRGB(r);
|
||||
const intG = linearTosRGB(g);
|
||||
const intB = linearTosRGB(b);
|
||||
|
||||
pixels[4 * x + 0 + y * bytesPerRow] = intR;
|
||||
pixels[4 * x + 1 + y * bytesPerRow] = intG;
|
||||
pixels[4 * x + 2 + y * bytesPerRow] = intB;
|
||||
pixels[4 * x + 3 + y * bytesPerRow] = 255; // alpha
|
||||
}
|
||||
}
|
||||
return pixels;
|
||||
};
|
||||
|
||||
export default decode;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// adapted from https://gist.github.com/ngbrown/d62eb518753378eb0a9bf02bb4723235
|
||||
// modified from https://gist.github.com/WorldMaker/a3cbe0059acd827edee568198376b95a
|
||||
// https://github.com/woltapp/react-blurhash/issues/3
|
||||
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
import decode from './decode';
|
||||
import { isBlurhashString } from '../resolveSources';
|
||||
|
||||
const DEFAULT_SIZE = {
|
||||
width: 32,
|
||||
height: 32,
|
||||
};
|
||||
|
||||
// We scale up the canvas to avoid an irritating visual glitch when animating in Chrome.
|
||||
const scaleRatio = 10;
|
||||
|
||||
export function useBlurhash(
|
||||
blurhash: { uri?: string; width?: number | null; height?: number | null } | undefined | null,
|
||||
punch: number = 1
|
||||
) {
|
||||
punch = punch || 1;
|
||||
|
||||
const [uri, setUri] = useState<string | null>(null);
|
||||
const isBlurhash = (blurhash?.uri && isBlurhashString(blurhash.uri)) ?? false;
|
||||
useEffect(() => {
|
||||
let isCanceled = false;
|
||||
|
||||
if (!blurhash || !blurhash.uri || !isBlurhash) {
|
||||
return;
|
||||
}
|
||||
const strippedBlurhashString = blurhash.uri.replace(/blurhash:\//, '');
|
||||
|
||||
const pixels = decode(
|
||||
strippedBlurhashString,
|
||||
blurhash.width ?? DEFAULT_SIZE.width,
|
||||
blurhash.height ?? DEFAULT_SIZE.height,
|
||||
punch
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const upscaledCanvas = document.createElement('canvas');
|
||||
canvas.width = blurhash.width ?? DEFAULT_SIZE.width;
|
||||
canvas.height = blurhash.height ?? DEFAULT_SIZE.height;
|
||||
upscaledCanvas.width = (blurhash.width ?? DEFAULT_SIZE.width) * scaleRatio;
|
||||
upscaledCanvas.height = (blurhash.height ?? DEFAULT_SIZE.height) * scaleRatio;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
console.warn('Failed to decode blurhash');
|
||||
return;
|
||||
}
|
||||
const imageData = context.createImageData(canvas.width, canvas.height);
|
||||
imageData.data.set(pixels);
|
||||
context.putImageData(imageData, 0, 0);
|
||||
const upscaledContext = upscaledCanvas.getContext('2d');
|
||||
if (!upscaledContext) {
|
||||
console.warn('Failed to decode blurhash');
|
||||
return;
|
||||
}
|
||||
upscaledContext.scale(scaleRatio, scaleRatio);
|
||||
upscaledContext.drawImage(canvas, 0, 0);
|
||||
upscaledCanvas.toBlob((blob) => {
|
||||
if (!isCanceled) {
|
||||
setUri((oldUrl) => {
|
||||
if (oldUrl) {
|
||||
URL.revokeObjectURL(oldUrl);
|
||||
}
|
||||
return blob ? URL.createObjectURL(blob) : oldUrl;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return function cleanupBlurhash() {
|
||||
isCanceled = true;
|
||||
setUri((oldUrl) => {
|
||||
if (oldUrl) {
|
||||
URL.revokeObjectURL(oldUrl);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [blurhash?.uri, blurhash?.height, blurhash?.width, punch, isBlurhash]);
|
||||
const source = useMemo(() => (uri ? { uri } : null), [uri]);
|
||||
return [source, isBlurhash] as const;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
export const sRGBToLinear = (value: number) => {
|
||||
const v = value / 255;
|
||||
if (v <= 0.04045) {
|
||||
return v / 12.92;
|
||||
} else {
|
||||
return Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
};
|
||||
|
||||
export const linearTosRGB = (value: number) => {
|
||||
const v = Math.max(0, Math.min(1, value));
|
||||
if (v <= 0.0031308) {
|
||||
return Math.trunc(v * 12.92 * 255 + 0.5);
|
||||
} else {
|
||||
return Math.trunc((1.055 * Math.pow(v, 1 / 2.4) - 0.055) * 255 + 0.5);
|
||||
}
|
||||
};
|
||||
|
||||
export const sign = (n: number) => (n < 0 ? -1 : 1);
|
||||
|
||||
export const signPow = (val: number, exp: number) => sign(val) * Math.pow(Math.abs(val), exp);
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';
|
||||
|
||||
export default resolveAssetSource;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { getAssetByID } from '@react-native/assets-registry/registry';
|
||||
|
||||
import AssetSourceResolver, { ResolvedAssetSource } from './AssetSourceResolver.web';
|
||||
|
||||
let _customSourceTransformer: undefined | ((resolver: AssetSourceResolver) => ResolvedAssetSource);
|
||||
|
||||
export function setCustomSourceTransformer(
|
||||
transformer: (resolver: AssetSourceResolver) => ResolvedAssetSource
|
||||
): void {
|
||||
_customSourceTransformer = transformer;
|
||||
}
|
||||
|
||||
/**
|
||||
* `source` is either a number (opaque type returned by require('./foo.png'))
|
||||
* or an `ImageSource` like { uri: '<http location || file path>' }
|
||||
*/
|
||||
export default function resolveAssetSource(source: any): ResolvedAssetSource | undefined {
|
||||
if (typeof source === 'object') {
|
||||
return source;
|
||||
}
|
||||
|
||||
const asset = getAssetByID(source);
|
||||
if (!asset) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolver = new AssetSourceResolver('https://expo.dev', null, asset);
|
||||
if (_customSourceTransformer) {
|
||||
return _customSourceTransformer(resolver);
|
||||
}
|
||||
return resolver.defaultAsset();
|
||||
}
|
||||
|
||||
Object.defineProperty(resolveAssetSource, 'setCustomSourceTransformer', {
|
||||
get() {
|
||||
return setCustomSourceTransformer;
|
||||
},
|
||||
});
|
||||
|
||||
export const { pickScale } = AssetSourceResolver;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { ImageSource } from '../Image.types';
|
||||
|
||||
type ImageHashType = 'blurhash' | 'thumbhash';
|
||||
|
||||
function hashToUri(type: ImageHashType, hash: string): string {
|
||||
const encodedBlurhash = encodeURI(hash).replace(/#/g, '%23').replace(/\?/g, '%3F');
|
||||
return `${type}:/${encodedBlurhash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a blurhash string (`blurhash:/<hash>/<width>/<height>` or <hash>/<width>/<height>) into an `ImageSource`.
|
||||
*
|
||||
* @return An ImageSource representing the provided blurhash.
|
||||
* */
|
||||
export function resolveBlurhashString(str: string): ImageSource {
|
||||
const [blurhash, width, height] = str.replace(/^blurhash:\//, '').split('/');
|
||||
return {
|
||||
uri: hashToUri('blurhash', blurhash),
|
||||
width: parseInt(width, 10) || 16,
|
||||
height: parseInt(height, 10) || 16,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a thumbhash string (`thumbhash:/<hash>` or `<hash>`) into an `ImageSource`.
|
||||
*
|
||||
* @return An ImageSource representing the provided thumbhash.
|
||||
* */
|
||||
export function resolveThumbhashString(str: string): ImageSource {
|
||||
// ThumbHash may contain slashes that could break the url when the slash is at the beginning.
|
||||
// We replace slashes with backslashes to make sure we don't break the url's path.
|
||||
const thumbhash = str.replace(/^thumbhash:\//, '').replace(/\//g, '\\');
|
||||
return {
|
||||
uri: hashToUri('thumbhash', thumbhash),
|
||||
};
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { ImageSource } from '../Image.types';
|
||||
|
||||
/**
|
||||
* Converts a string in blurhash format (`blurhash:/<hash>/<width>/<height>`
|
||||
* or <hash>/<width>/<height>) into an `ImageSource`.
|
||||
*
|
||||
* @return An ImageSource representing the provided blurhash.
|
||||
* */
|
||||
export function resolveBlurhashString(str: string): ImageSource {
|
||||
const [hash, width, height] = str.replace(/^blurhash:\//, '').split('/');
|
||||
return {
|
||||
uri: 'blurhash:/' + hash,
|
||||
width: parseInt(width, 10) || 16,
|
||||
height: parseInt(height, 10) || 16,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string in thumbhash format (`thumbhash:/<hash>` or `<hash>`)
|
||||
* into an `ImageSource`.
|
||||
* Note: Unlike the `resolveBlurhashString` the `thumbhash:/` scheme has to be present,
|
||||
* as the scheme has to be explicitly stated to be interpreted a `thumbhash` source.
|
||||
*
|
||||
* @return An ImageSource representing the provided thumbhash.
|
||||
* */
|
||||
export function resolveThumbhashString(str: string): ImageSource {
|
||||
const hash = str.replace(/^thumbhash:\//, '');
|
||||
return {
|
||||
uri: 'thumbhash:/' + hash,
|
||||
};
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Platform } from 'expo-modules-core';
|
||||
|
||||
import resolveAssetSource from './resolveAssetSource';
|
||||
import { resolveBlurhashString, resolveThumbhashString } from './resolveHashString';
|
||||
import { ImageNativeProps, ImageProps, ImageSource } from '../Image.types';
|
||||
import { isImageRef } from '../utils';
|
||||
|
||||
export function isBlurhashString(str: string): boolean {
|
||||
return /^(blurhash:\/)+[\w#$%*+,\-.:;=?@[\]^_{}|~]+(\/[\d.]+)*$/.test(str);
|
||||
}
|
||||
|
||||
// Base64 strings will be recognized as blurhash by default (to keep compatibility),
|
||||
// interpret as thumbhash only if correct uri scheme is provided
|
||||
export function isThumbhashString(str: string): boolean {
|
||||
return str.startsWith('thumbhash:/');
|
||||
}
|
||||
|
||||
export function resolveSource(source?: ImageSource | string | number | null): ImageSource | null {
|
||||
if (typeof source === 'string') {
|
||||
if (isBlurhashString(source)) {
|
||||
return resolveBlurhashString(source);
|
||||
} else if (isThumbhashString(source)) {
|
||||
return resolveThumbhashString(source);
|
||||
}
|
||||
return { uri: source };
|
||||
}
|
||||
if (typeof source === 'number') {
|
||||
return resolveAssetSource(source);
|
||||
}
|
||||
if (typeof source === 'object' && (source?.blurhash || source?.thumbhash)) {
|
||||
const { blurhash, thumbhash, ...restSource } = source;
|
||||
const resolved = thumbhash
|
||||
? resolveThumbhashString(thumbhash)
|
||||
: resolveBlurhashString(blurhash as string);
|
||||
return {
|
||||
...resolved,
|
||||
...restSource,
|
||||
};
|
||||
}
|
||||
return source ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves provided `source` prop to an array of objects expected by the native implementation.
|
||||
*/
|
||||
export function resolveSources(sources?: ImageProps['source']): ImageNativeProps['source'] {
|
||||
if (Array.isArray(sources)) {
|
||||
return sources.map(resolveSource).filter(Boolean) as ImageSource[];
|
||||
}
|
||||
if (isImageRef(sources)) {
|
||||
if (Platform.OS === 'web') {
|
||||
return sources;
|
||||
}
|
||||
// @ts-expect-error
|
||||
return sources.__expo_shared_object_id__;
|
||||
}
|
||||
return [resolveSource(sources)].filter(Boolean) as ImageSource[];
|
||||
}
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
// Code copied and slightly modified from https://github.com/evanw/thumbhash
|
||||
|
||||
/**
|
||||
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns The ThumbHash as a Uint8Array.
|
||||
*/
|
||||
|
||||
export function rgbaToThumbHash(w: number, h: number, rgba: Uint8Array) {
|
||||
// Encoding an image larger than 100x100 is slow with no benefit
|
||||
if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`);
|
||||
const { PI, round, max, cos, abs } = Math;
|
||||
|
||||
// Determine the average color
|
||||
let avg_r = 0,
|
||||
avg_g = 0,
|
||||
avg_b = 0,
|
||||
avg_a = 0;
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
const alpha = rgba[j + 3] / 255;
|
||||
avg_r += (alpha / 255) * rgba[j];
|
||||
avg_g += (alpha / 255) * rgba[j + 1];
|
||||
avg_b += (alpha / 255) * rgba[j + 2];
|
||||
avg_a += alpha;
|
||||
}
|
||||
if (avg_a) {
|
||||
avg_r /= avg_a;
|
||||
avg_g /= avg_a;
|
||||
avg_b /= avg_a;
|
||||
}
|
||||
|
||||
const hasAlpha = avg_a < w * h;
|
||||
const l_limit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's alpha
|
||||
const lx = max(1, round((l_limit * w) / max(w, h)));
|
||||
const ly = max(1, round((l_limit * h) / max(w, h)));
|
||||
const l: number[] = []; // luminance
|
||||
const p: number[] = []; // yellow - blue
|
||||
const q: number[] = []; // red - green
|
||||
const a: number[] = []; // alpha
|
||||
|
||||
// Convert the image from RGBA to LPQA (composite atop the average color)
|
||||
for (let i = 0, j = 0; i < w * h; i++, j += 4) {
|
||||
const alpha = rgba[j + 3] / 255;
|
||||
const r = avg_r * (1 - alpha) + (alpha / 255) * rgba[j];
|
||||
const g = avg_g * (1 - alpha) + (alpha / 255) * rgba[j + 1];
|
||||
const b = avg_b * (1 - alpha) + (alpha / 255) * rgba[j + 2];
|
||||
l[i] = (r + g + b) / 3;
|
||||
p[i] = (r + g) / 2 - b;
|
||||
q[i] = r - g;
|
||||
a[i] = alpha;
|
||||
}
|
||||
|
||||
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
|
||||
const encodeChannel = (channel: number[], nx: number, ny: number) => {
|
||||
let dc = 0;
|
||||
const ac: number[] = [];
|
||||
let scale = 0;
|
||||
const fx: number[] = [];
|
||||
for (let cy = 0; cy < ny; cy++) {
|
||||
for (let cx = 0; cx * ny < nx * (ny - cy); cx++) {
|
||||
let f = 0;
|
||||
for (let x = 0; x < w; x++) fx[x] = cos((PI / w) * cx * (x + 0.5));
|
||||
for (let y = 0; y < h; y++)
|
||||
for (let x = 0, fy = cos((PI / h) * cy * (y + 0.5)); x < w; x++)
|
||||
f += channel[x + y * w] * fx[x] * fy;
|
||||
f /= w * h;
|
||||
if (cx || cy) {
|
||||
ac.push(f);
|
||||
scale = max(scale, abs(f));
|
||||
} else {
|
||||
dc = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scale) for (let i = 0; i < ac.length; i++) ac[i] = 0.5 + (0.5 / scale) * ac[i];
|
||||
return [dc, ac, scale];
|
||||
};
|
||||
const [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly));
|
||||
const [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3);
|
||||
const [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3);
|
||||
const [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [];
|
||||
|
||||
// Write the constants
|
||||
const isLandscape = w > h;
|
||||
const header24 =
|
||||
round(63 * (l_dc as number)) |
|
||||
(round(31.5 + 31.5 * (p_dc as number)) << 6) |
|
||||
(round(31.5 + 31.5 * (q_dc as number)) << 12) |
|
||||
(round(31 * (l_scale as number)) << 18) |
|
||||
((hasAlpha ? 1 : 0) << 23);
|
||||
const header16 =
|
||||
(isLandscape ? ly : lx) |
|
||||
(round(63 * (p_scale as number)) << 3) |
|
||||
(round(63 * (q_scale as number)) << 9) |
|
||||
((isLandscape ? 1 : 0) << 15);
|
||||
const hash = [
|
||||
header24 & 255,
|
||||
(header24 >> 8) & 255,
|
||||
header24 >> 16,
|
||||
header16 & 255,
|
||||
header16 >> 8,
|
||||
];
|
||||
const ac_start = hasAlpha ? 6 : 5;
|
||||
let ac_index = 0;
|
||||
if (hasAlpha) hash.push(round(15 * (a_dc as number)) | (round(15 * (a_scale as number)) << 4));
|
||||
|
||||
// Write the varying factors
|
||||
for (const ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac])
|
||||
for (const f of ac as number[])
|
||||
hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2);
|
||||
return new Uint8Array(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The width, height, and pixels of the rendered placeholder image.
|
||||
*/
|
||||
export function thumbHashToRGBA(hash: Uint8Array) {
|
||||
const { PI, min, max, cos, round } = Math;
|
||||
|
||||
// Read the constants
|
||||
const header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16);
|
||||
const header16 = hash[3] | (hash[4] << 8);
|
||||
const l_dc = (header24 & 63) / 63;
|
||||
const p_dc = ((header24 >> 6) & 63) / 31.5 - 1;
|
||||
const q_dc = ((header24 >> 12) & 63) / 31.5 - 1;
|
||||
const l_scale = ((header24 >> 18) & 31) / 31;
|
||||
const hasAlpha = header24 >> 23;
|
||||
const p_scale = ((header16 >> 3) & 63) / 63;
|
||||
const q_scale = ((header16 >> 9) & 63) / 63;
|
||||
const isLandscape = header16 >> 15;
|
||||
const lx = max(3, isLandscape ? (hasAlpha ? 5 : 7) : header16 & 7);
|
||||
const ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
|
||||
const a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1;
|
||||
const a_scale = (hash[5] >> 4) / 15;
|
||||
|
||||
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||
const ac_start = hasAlpha ? 6 : 5;
|
||||
let ac_index = 0;
|
||||
const decodeChannel = (nx: number, ny: number, scale: number) => {
|
||||
const ac: number[] = [];
|
||||
for (let cy = 0; cy < ny; cy++)
|
||||
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
|
||||
ac.push(
|
||||
(((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale
|
||||
);
|
||||
return ac;
|
||||
};
|
||||
const l_ac = decodeChannel(lx, ly, l_scale);
|
||||
const p_ac = decodeChannel(3, 3, p_scale * 1.25);
|
||||
const q_ac = decodeChannel(3, 3, q_scale * 1.25);
|
||||
const a_ac = hasAlpha ? decodeChannel(5, 5, a_scale) : null;
|
||||
|
||||
// Decode using the DCT into RGB
|
||||
const ratio = thumbHashToApproximateAspectRatio(hash);
|
||||
const w = round(ratio > 1 ? 32 : 32 * ratio);
|
||||
const h = round(ratio > 1 ? 32 / ratio : 32);
|
||||
const rgba = new Uint8Array(w * h * 4),
|
||||
fx: number[] = [],
|
||||
fy: number[] = [];
|
||||
for (let y = 0, i = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++, i += 4) {
|
||||
let l = l_dc,
|
||||
p = p_dc,
|
||||
q = q_dc,
|
||||
a = a_dc;
|
||||
|
||||
// Precompute the coefficients
|
||||
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++)
|
||||
fx[cx] = cos((PI / w) * (x + 0.5) * cx);
|
||||
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++)
|
||||
fy[cy] = cos((PI / h) * (y + 0.5) * cy);
|
||||
|
||||
// Decode L
|
||||
for (let cy = 0, j = 0; cy < ly; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++)
|
||||
l += l_ac[j] * fx[cx] * fy2;
|
||||
|
||||
// Decode P and Q
|
||||
for (let cy = 0, j = 0; cy < 3; cy++) {
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
||||
const f = fx[cx] * fy2;
|
||||
p += p_ac[j] * f;
|
||||
q += q_ac[j] * f;
|
||||
}
|
||||
}
|
||||
|
||||
// Decode A
|
||||
if (hasAlpha)
|
||||
for (let cy = 0, j = 0; cy < 5; cy++)
|
||||
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++)
|
||||
a += a_ac![j] * fx[cx] * fy2;
|
||||
|
||||
// Convert to RGB
|
||||
const b = l - (2 / 3) * p;
|
||||
const r = (3 * l - b + q) / 2;
|
||||
const g = r - q;
|
||||
rgba[i] = max(0, 255 * min(1, r));
|
||||
rgba[i + 1] = max(0, 255 * min(1, g));
|
||||
rgba[i + 2] = max(0, 255 * min(1, b));
|
||||
rgba[i + 3] = max(0, 255 * min(1, a));
|
||||
}
|
||||
}
|
||||
return { w, h, rgba };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The RGBA values for the average color. Each value ranges from 0 to 1.
|
||||
*/
|
||||
export function thumbHashToAverageRGBA(hash: Uint8Array) {
|
||||
const { min, max } = Math;
|
||||
const header = hash[0] | (hash[1] << 8) | (hash[2] << 16);
|
||||
const l = (header & 63) / 63;
|
||||
const p = ((header >> 6) & 63) / 31.5 - 1;
|
||||
const q = ((header >> 12) & 63) / 31.5 - 1;
|
||||
const hasAlpha = header >> 23;
|
||||
const a = hasAlpha ? (hash[5] & 15) / 15 : 1;
|
||||
const b = l - (2 / 3) * p;
|
||||
const r = (3 * l - b + q) / 2;
|
||||
const g = r - q;
|
||||
return {
|
||||
r: max(0, min(1, r)),
|
||||
g: max(0, min(1, g)),
|
||||
b: max(0, min(1, b)),
|
||||
a,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the approximate aspect ratio of the original image.
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns The approximate aspect ratio (i.e. width / height).
|
||||
*/
|
||||
export function thumbHashToApproximateAspectRatio(hash: Uint8Array) {
|
||||
const header = hash[3];
|
||||
const hasAlpha = hash[2] & 0x80;
|
||||
const isLandscape = hash[4] & 0x80;
|
||||
const lx = isLandscape ? (hasAlpha ? 5 : 7) : header & 7;
|
||||
const ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
|
||||
return lx / ly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by
|
||||
* A. This is optimized for speed and simplicity and does not optimize for size
|
||||
* at all. This doesn't do any compression (all values are stored uncompressed).
|
||||
*
|
||||
* @param w The width of the input image. Must be ≤100px.
|
||||
* @param h The height of the input image. Must be ≤100px.
|
||||
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
|
||||
* @returns A data URL containing a PNG for the input image.
|
||||
*/
|
||||
export function rgbaToDataURL(w: number, h: number, rgba: Uint8Array) {
|
||||
const row = w * 4 + 1;
|
||||
const idat = 6 + h * (5 + row);
|
||||
const bytes = [
|
||||
137,
|
||||
80,
|
||||
78,
|
||||
71,
|
||||
13,
|
||||
10,
|
||||
26,
|
||||
10,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
13,
|
||||
73,
|
||||
72,
|
||||
68,
|
||||
82,
|
||||
0,
|
||||
0,
|
||||
w >> 8,
|
||||
w & 255,
|
||||
0,
|
||||
0,
|
||||
h >> 8,
|
||||
h & 255,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
idat >>> 24,
|
||||
(idat >> 16) & 255,
|
||||
(idat >> 8) & 255,
|
||||
idat & 255,
|
||||
73,
|
||||
68,
|
||||
65,
|
||||
84,
|
||||
120,
|
||||
1,
|
||||
];
|
||||
const table = [
|
||||
0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, 1342533948, -306674912,
|
||||
-267414716, -690576408, -882789492, -1687895376, -2032938284, -1609899400, -1111625188,
|
||||
];
|
||||
let a = 1,
|
||||
b = 0;
|
||||
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
||||
bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0);
|
||||
for (b = (b + a) % 65521; i < end; i++) {
|
||||
const u = rgba[i] & 255;
|
||||
bytes.push(u);
|
||||
a = (a + u) % 65521;
|
||||
b = (b + a) % 65521;
|
||||
}
|
||||
}
|
||||
bytes.push(
|
||||
b >> 8,
|
||||
b & 255,
|
||||
a >> 8,
|
||||
a & 255,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
73,
|
||||
69,
|
||||
78,
|
||||
68,
|
||||
174,
|
||||
66,
|
||||
96,
|
||||
130
|
||||
);
|
||||
for (let [start, end] of [
|
||||
[12, 29],
|
||||
[37, 41 + idat],
|
||||
]) {
|
||||
let c = ~0;
|
||||
for (let i = start; i < end; i++) {
|
||||
c ^= bytes[i];
|
||||
c = (c >>> 4) ^ table[c & 15];
|
||||
c = (c >>> 4) ^ table[c & 15];
|
||||
}
|
||||
c = ~c;
|
||||
bytes[end++] = c >>> 24;
|
||||
bytes[end++] = (c >> 16) & 255;
|
||||
bytes[end++] = (c >> 8) & 255;
|
||||
bytes[end++] = c & 255;
|
||||
}
|
||||
return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a ThumbHash to a PNG data URL. This is a convenience function that
|
||||
* just calls "thumbHashToRGBA" followed by "rgbaToDataURL".
|
||||
*
|
||||
* @param hash The bytes of the ThumbHash.
|
||||
* @returns A data URL containing a PNG for the rendered ThumbHash.
|
||||
*/
|
||||
export function thumbHashToDataURL(hash: Uint8Array): string {
|
||||
const image = thumbHashToRGBA(hash);
|
||||
return rgbaToDataURL(image.w, image.h, image.rgba);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function added to the original thumbhash code, allows generating a thumbhash image directly from
|
||||
* thumbhash string.
|
||||
* @param thumbhash string from which thumbhashDataURL should be generated
|
||||
* @returns A data URL containing a PNG for the rendered ThumbHash
|
||||
*/
|
||||
export function thumbHashStringToDataURL(thumbhash: string): string {
|
||||
const hash = Uint8Array.from(atob(thumbhash), (c) => c.charCodeAt(0));
|
||||
return thumbHashToDataURL(hash);
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ImageTransition } from '../Image.types';
|
||||
|
||||
type Callbacks = {
|
||||
onReady?: (() => void) | null;
|
||||
onAnimationFinished?: (() => void) | null;
|
||||
onMount?: (() => void) | null;
|
||||
onError?: (() => void) | null;
|
||||
};
|
||||
|
||||
export type AnimationManagerNode = [
|
||||
key: string,
|
||||
renderFunction: (
|
||||
renderProps: NonNullable<Callbacks>
|
||||
) => (className: string, style: React.CSSProperties) => React.ReactElement,
|
||||
];
|
||||
|
||||
const SUPPORTED_ANIMATIONS: ImageTransition['effect'][] = [
|
||||
'cross-dissolve',
|
||||
'flip-from-left',
|
||||
'flip-from-right',
|
||||
'flip-from-top',
|
||||
'flip-from-bottom',
|
||||
];
|
||||
|
||||
type NodeStatus = 'mounted' | 'in' | 'active' | 'out' | 'errored';
|
||||
|
||||
function useAnimationManagerNode(node: AnimationManagerNode | null, initialStatus?: NodeStatus) {
|
||||
const newNode = React.useMemo(() => {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const [animationKey, renderFunction] = node;
|
||||
// key, ReactElement, ref, callbacks
|
||||
return {
|
||||
animationKey,
|
||||
persistedElement: renderFunction,
|
||||
status: (initialStatus || 'mounted') as NodeStatus,
|
||||
};
|
||||
}, [node?.[0]]);
|
||||
return newNode;
|
||||
}
|
||||
|
||||
function validateTimingFunctionForAnimation(
|
||||
animationClass: ImageTransition['effect'],
|
||||
timingFunction: ImageTransition['timing']
|
||||
) {
|
||||
if (animationClass?.includes('flip')) {
|
||||
if (timingFunction?.includes('ease')) {
|
||||
return 'ease-in-out';
|
||||
}
|
||||
return 'linear';
|
||||
}
|
||||
return timingFunction || null;
|
||||
}
|
||||
|
||||
function validateAnimationClass(effect: ImageTransition['effect']) {
|
||||
if (SUPPORTED_ANIMATIONS.includes(effect)) {
|
||||
return effect;
|
||||
}
|
||||
return 'cross-dissolve';
|
||||
}
|
||||
|
||||
export function getAnimatorFromTransition(transition: ImageTransition | null | undefined) {
|
||||
if (!transition?.duration) {
|
||||
return null;
|
||||
}
|
||||
const animationClass = validateAnimationClass(transition.effect);
|
||||
if (!animationClass) {
|
||||
return {
|
||||
startingClass: '',
|
||||
animateInClass: '',
|
||||
animateOutClass: '',
|
||||
containerClass: '',
|
||||
timingFunction: 'linear',
|
||||
animationClass: '',
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const timingFunction = validateTimingFunctionForAnimation(animationClass, transition.timing);
|
||||
const timingClass = `image-timing-${timingFunction}`;
|
||||
|
||||
return {
|
||||
startingClass: `${animationClass}-start`,
|
||||
animateInClass: [animationClass, 'transitioning', `${animationClass}-active`, timingClass].join(
|
||||
' '
|
||||
),
|
||||
animateOutClass: [animationClass, `${animationClass}-end`, timingClass].join(' '),
|
||||
containerClass: `${animationClass}-container`,
|
||||
timingFunction,
|
||||
animationClass,
|
||||
duration: transition?.duration || 0,
|
||||
};
|
||||
}
|
||||
|
||||
type MountedAnimationNode = {
|
||||
animationKey: string;
|
||||
persistedElement: (
|
||||
renderProps: Callbacks
|
||||
) => (className: string, style: React.CSSProperties) => React.ReactElement;
|
||||
status: NodeStatus;
|
||||
};
|
||||
|
||||
export default function AnimationManager({
|
||||
children: renderFunction,
|
||||
initial,
|
||||
transition,
|
||||
recyclingKey,
|
||||
}: {
|
||||
children: AnimationManagerNode;
|
||||
initial: AnimationManagerNode | null;
|
||||
transition: ImageTransition | null | undefined;
|
||||
recyclingKey?: string | null | undefined;
|
||||
}) {
|
||||
const animation = getAnimatorFromTransition(transition);
|
||||
|
||||
const initialNode = useAnimationManagerNode(initial, 'active');
|
||||
|
||||
const [nodes, setNodes] = React.useState<MountedAnimationNode[]>(
|
||||
initialNode ? [initialNode] : []
|
||||
);
|
||||
|
||||
const [prevRecyclingKey, setPrevRecyclingKey] = React.useState<string>(recyclingKey ?? '');
|
||||
if (prevRecyclingKey !== (recyclingKey ?? '')) {
|
||||
setPrevRecyclingKey(recyclingKey ?? '');
|
||||
setNodes(initialNode ? [initialNode] : []);
|
||||
}
|
||||
|
||||
const removeAllNodesOfKeyExceptShowing = (key?: string) => {
|
||||
setNodes((n) =>
|
||||
n.filter(
|
||||
(node) =>
|
||||
(key ? node.animationKey !== key : false) ||
|
||||
node.status === 'in' ||
|
||||
node.status === 'active'
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const newNode = useAnimationManagerNode(renderFunction);
|
||||
|
||||
React.useEffect(() => {
|
||||
setNodes((n) => {
|
||||
if (!newNode) {
|
||||
return n;
|
||||
}
|
||||
const existingNodeIndex = n.findIndex((node) => node.animationKey === newNode.animationKey);
|
||||
if (existingNodeIndex >= 0) {
|
||||
if (animation) {
|
||||
return n.map((n2) =>
|
||||
n2.animationKey === newNode.animationKey
|
||||
? { ...newNode, status: 'in' }
|
||||
: { ...n2, status: 'out' }
|
||||
);
|
||||
} else {
|
||||
return [{ ...newNode, status: 'in' }];
|
||||
}
|
||||
}
|
||||
return [...n, newNode];
|
||||
});
|
||||
}, [newNode]);
|
||||
|
||||
function wrapNodeWithCallbacks(node: MountedAnimationNode) {
|
||||
if (renderFunction[0] === node.animationKey) {
|
||||
return renderFunction[1]({
|
||||
onReady: () => {
|
||||
if (animation) {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((n) => (n === newNode ? { ...n, status: 'in' } : { ...n, status: 'out' }))
|
||||
);
|
||||
} else {
|
||||
setNodes([{ ...node, status: 'in' }]);
|
||||
}
|
||||
},
|
||||
onAnimationFinished: () => {
|
||||
setNodes([{ ...node, status: 'in' }]);
|
||||
},
|
||||
onError: () => {
|
||||
setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n)));
|
||||
},
|
||||
});
|
||||
}
|
||||
if (initial?.[0] === node.animationKey) {
|
||||
return initial[1]({
|
||||
onAnimationFinished: () => {
|
||||
if (node.status === 'out') {
|
||||
removeAllNodesOfKeyExceptShowing(node.animationKey);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setNodes((nodes) => nodes.map((n) => (n === node ? { ...n, status: 'errored' } : n)));
|
||||
},
|
||||
});
|
||||
}
|
||||
return node.persistedElement({
|
||||
onAnimationFinished: () => {
|
||||
removeAllNodesOfKeyExceptShowing(node.animationKey);
|
||||
},
|
||||
});
|
||||
}
|
||||
const styles = {
|
||||
transitionDuration: `${animation?.duration || 0}ms`,
|
||||
transitionTimingFunction: animation?.timingFunction || 'linear',
|
||||
};
|
||||
const classes = {
|
||||
in: animation?.animateInClass,
|
||||
out: animation?.animateOutClass,
|
||||
mounted: animation?.startingClass,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{[...nodes]
|
||||
.filter((n) => n.status !== 'errored')
|
||||
.map((n) => {
|
||||
const status = n.status as keyof typeof classes & NodeStatus;
|
||||
// TODO(@kitten): This creates impossible states!
|
||||
// Ensure that the above type is either exhaustively reflected in this `map` so `className` sheds `undefined`,
|
||||
// or retype the `MountedAnimationNode` function to accept `className: string | undefined`
|
||||
const className = classes[status]!;
|
||||
return (
|
||||
<div className={animation?.containerClass} key={n.animationKey}>
|
||||
{wrapNodeWithCallbacks(n)(className, styles)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export function getTintColorStyle(tintId: string, tintColor?: string | null) {
|
||||
if (!tintColor) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
filter: `url(#expo-image-tint-${tintId})`,
|
||||
};
|
||||
}
|
||||
|
||||
type TintColorFilterProps = { id: string; tintColor?: string | null };
|
||||
|
||||
export default function TintColorFilter({ id, tintColor }: TintColorFilterProps) {
|
||||
if (!tintColor) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<svg style={styles.svg}>
|
||||
<defs>
|
||||
<filter id={`expo-image-tint-${id}`}>
|
||||
<feFlood floodColor={tintColor} />
|
||||
<feComposite in2="SourceAlpha" operator="atop" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
svg: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { SharedRef } from 'expo';
|
||||
|
||||
import { ImageRef } from '../Image.types';
|
||||
|
||||
export default class ImageRefWeb extends SharedRef<'image'> implements ImageRef {
|
||||
override nativeRefType = 'image';
|
||||
|
||||
uri: string | null = null;
|
||||
width: number = 0;
|
||||
height: number = 0;
|
||||
mediaType: string | null = null;
|
||||
scale: number = 1;
|
||||
isAnimated: boolean = false;
|
||||
|
||||
static init(uri: string, width: number, height: number, mediaType: string | null): ImageRefWeb {
|
||||
return Object.assign(new ImageRefWeb(), {
|
||||
uri,
|
||||
width,
|
||||
height,
|
||||
mediaType,
|
||||
isAnimated: mediaType === 'image/gif',
|
||||
});
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, Ref, useId } from 'react';
|
||||
|
||||
import ColorTintFilter, { getTintColorStyle } from './ColorTintFilter';
|
||||
import { ImageWrapperProps } from './ImageWrapper.types';
|
||||
import { getImageWrapperEventHandler } from './getImageWrapperEventHandler';
|
||||
import { useHeaders, useImageHashes } from './hooks';
|
||||
import { absoluteFilledPosition, getObjectPositionFromContentPositionObject } from './positioning';
|
||||
import { SrcSetSource } from './useSourceSelection';
|
||||
import { ImageNativeProps, ImageSource } from '../Image.types';
|
||||
|
||||
function getFetchPriorityFromImagePriority(priority: ImageNativeProps['priority'] = 'normal') {
|
||||
return priority && ['low', 'high'].includes(priority) ? priority : 'auto';
|
||||
}
|
||||
|
||||
function getImgPropsFromSource(source: ImageSource | SrcSetSource | null | undefined) {
|
||||
if (source && 'srcset' in source) {
|
||||
return {
|
||||
srcSet: source.srcset,
|
||||
sizes: source.sizes,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const ImageWrapper = React.forwardRef(
|
||||
(
|
||||
{
|
||||
source,
|
||||
events,
|
||||
contentPosition,
|
||||
hashPlaceholderContentPosition,
|
||||
priority,
|
||||
style,
|
||||
hashPlaceholderStyle,
|
||||
tintColor,
|
||||
className,
|
||||
accessibilityLabel,
|
||||
cachePolicy,
|
||||
...props
|
||||
}: ImageWrapperProps,
|
||||
ref: Ref<HTMLImageElement>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
events?.onMount?.forEach((e) => e?.());
|
||||
}, []);
|
||||
|
||||
// Use a unique ID for the SVG filter so that multiple <Image> can be used
|
||||
// on the same page with different tint colors without conflicts.
|
||||
const tintId = useId()
|
||||
// Make it safe for use as an SVG ID. SVG IDs are most strict than HTML
|
||||
// IDs. They must be compliant with https://www.w3.org/TR/xml/#NT-Name.
|
||||
// React 19 changed useId() to include « and ». These must be removed or
|
||||
// the SVG filter will not work (e.g. in Safari which enforces the spec).
|
||||
.replace(/[«»]/g, '_');
|
||||
|
||||
// Thumbhash uri always has to start with 'thumbhash:/'
|
||||
const { resolvedSource, isImageHash } = useImageHashes(source);
|
||||
const objectPosition = getObjectPositionFromContentPositionObject(
|
||||
isImageHash ? hashPlaceholderContentPosition : contentPosition
|
||||
);
|
||||
|
||||
const sourceWithHeaders = useHeaders(resolvedSource, cachePolicy, events?.onError);
|
||||
if (!sourceWithHeaders) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ColorTintFilter id={tintId} tintColor={tintColor} />
|
||||
<img
|
||||
ref={ref}
|
||||
alt={accessibilityLabel}
|
||||
className={className}
|
||||
src={sourceWithHeaders?.uri || undefined}
|
||||
key={source?.uri}
|
||||
style={{
|
||||
objectPosition,
|
||||
...absoluteFilledPosition,
|
||||
...getTintColorStyle(tintId, tintColor),
|
||||
...style,
|
||||
...(isImageHash ? hashPlaceholderStyle : {}),
|
||||
}}
|
||||
// @ts-ignore
|
||||
// eslint-disable-next-line react/no-unknown-property
|
||||
fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')}
|
||||
{...getImageWrapperEventHandler(events, sourceWithHeaders)}
|
||||
{...getImgPropsFromSource(source)}
|
||||
{...props}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ImageWrapper;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { CSSProperties, SyntheticEvent } from 'react';
|
||||
|
||||
import { SrcSetSource } from './useSourceSelection';
|
||||
import { ImageContentPositionObject, ImageProps, ImageSource } from '../Image.types';
|
||||
|
||||
export type OnErrorEvent =
|
||||
| (({ source }: { source: ImageSource | null }) => void)
|
||||
| undefined
|
||||
| null;
|
||||
export type OnLoadEvent =
|
||||
| ((event: SyntheticEvent<HTMLImageElement, Event>) => void)
|
||||
| undefined
|
||||
| null;
|
||||
export type OnTransitionEndEvent = (() => void) | undefined | null;
|
||||
export type OnMountEvent = (() => void) | undefined | null;
|
||||
export type OnDisplayEvent = (() => void) | undefined | null;
|
||||
|
||||
export type ImageWrapperEvents = {
|
||||
onLoad?: OnLoadEvent[];
|
||||
onError?: OnErrorEvent[];
|
||||
onTransitionEnd?: OnTransitionEndEvent[];
|
||||
onMount?: OnMountEvent[];
|
||||
onDisplay?: OnDisplayEvent[];
|
||||
};
|
||||
|
||||
export type ImageWrapperProps = {
|
||||
source?: ImageSource | SrcSetSource | null;
|
||||
events?: ImageWrapperEvents;
|
||||
contentPosition?: ImageContentPositionObject;
|
||||
hashPlaceholderContentPosition?: ImageContentPositionObject;
|
||||
priority?: string | null;
|
||||
style: CSSProperties;
|
||||
tintColor?: string | null;
|
||||
hashPlaceholderStyle?: CSSProperties;
|
||||
className?: string;
|
||||
accessibilityLabel?: string;
|
||||
cachePolicy?: ImageProps['cachePolicy'];
|
||||
};
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { SyntheticEvent } from 'react';
|
||||
|
||||
import type { ImageWrapperEvents } from './ImageWrapper.types';
|
||||
import type { ImageSource } from '../Image.types';
|
||||
import { isBlurhashString } from '../utils/resolveSources';
|
||||
|
||||
export function getImageWrapperEventHandler(
|
||||
events: ImageWrapperEvents | undefined,
|
||||
source: ImageSource
|
||||
) {
|
||||
return {
|
||||
onLoad: (event: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||
events?.onLoad?.forEach((e) => e?.(event));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// On Web there is no way to detect when the image gets displayed, but we can assume it happens on the repaint right after the image is successfully loaded.
|
||||
window.requestAnimationFrame(() => {
|
||||
events?.onDisplay?.forEach((e) => e?.());
|
||||
});
|
||||
}
|
||||
},
|
||||
onTransitionEnd: () => events?.onTransitionEnd?.forEach((e) => e?.()),
|
||||
onError: () => {
|
||||
// A temporary workaround for blurhash blobs throwing opaque errors when used in an img tag.
|
||||
if (source?.uri && isBlurhashString(source?.uri)) {
|
||||
return;
|
||||
}
|
||||
events?.onError?.forEach((e) => e?.({ source: source || null }));
|
||||
},
|
||||
};
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { OnErrorEvent } from './ImageWrapper.types';
|
||||
import { ImageNativeProps, ImageSource } from '../Image.types';
|
||||
import { useBlurhash } from '../utils/blurhash/useBlurhash';
|
||||
import { isThumbhashString } from '../utils/resolveSources';
|
||||
import { thumbHashStringToDataURL } from '../utils/thumbhash/thumbhash';
|
||||
|
||||
export function useThumbhash(source: ImageSource | null | undefined) {
|
||||
const isThumbhash = isThumbhashString(source?.uri || '');
|
||||
const strippedThumbhashString = source?.uri?.replace(/thumbhash:\//, '') ?? '';
|
||||
const thumbhashSource = useMemo(
|
||||
() => (isThumbhash ? { uri: thumbHashStringToDataURL(strippedThumbhashString) } : null),
|
||||
[strippedThumbhashString, isThumbhash]
|
||||
);
|
||||
return [thumbhashSource, isThumbhash] as const;
|
||||
}
|
||||
|
||||
export function useImageHashes(source: ImageSource | null | undefined) {
|
||||
const [thumbhash, isThumbhashString] = useThumbhash(source);
|
||||
const [blurhash, isBlurhashString] = useBlurhash(source);
|
||||
return useMemo(() => {
|
||||
if (!isThumbhashString && !isBlurhashString) {
|
||||
return { resolvedSource: source, isImageHash: false };
|
||||
}
|
||||
if (!blurhash && !thumbhash) {
|
||||
return { resolvedSource: null, isImageHash: true };
|
||||
}
|
||||
return {
|
||||
resolvedSource: blurhash ?? thumbhash,
|
||||
isImageHash: true,
|
||||
};
|
||||
}, [blurhash, thumbhash, isThumbhashString, isBlurhashString, source]);
|
||||
}
|
||||
|
||||
export function useHeaders(
|
||||
source: ImageSource | null | undefined,
|
||||
cachePolicy: ImageNativeProps['cachePolicy'],
|
||||
onError?: OnErrorEvent[]
|
||||
): ImageSource | null | undefined {
|
||||
const [objectURL, setObjectURL] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!source?.headers || !source.uri) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await fetch(source.uri, {
|
||||
headers: source.headers,
|
||||
cache: cachePolicy === 'none' ? 'no-cache' : 'default',
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to fetch image: ${result.status} ${result.statusText}`);
|
||||
}
|
||||
const blob = await result.blob();
|
||||
setObjectURL((prevObjURL) => {
|
||||
if (prevObjURL) {
|
||||
URL.revokeObjectURL(prevObjURL);
|
||||
}
|
||||
return URL.createObjectURL(blob);
|
||||
});
|
||||
} catch {
|
||||
onError?.forEach((e) => e?.({ source }));
|
||||
}
|
||||
})();
|
||||
}, [source]);
|
||||
if (!source?.headers) {
|
||||
return source;
|
||||
}
|
||||
if (!objectURL) {
|
||||
// Avoid fetching a URL without headers if we have headers
|
||||
return null;
|
||||
}
|
||||
return { ...source, uri: objectURL };
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
const style = `
|
||||
[data-expoimage] .cross-dissolve {
|
||||
transition-property: opacity;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
[data-expoimage] .cross-dissolve-start:not(.transitioning) {
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .cross-dissolve-active {
|
||||
opacity: 1;
|
||||
}
|
||||
[data-expoimage] .cross-dissolve-end {
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-left {
|
||||
transition-property: transform, opacity;
|
||||
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
|
||||
transform-origin: center;
|
||||
|
||||
}
|
||||
[data-expoimage] .flip-from-left-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
perspective: 1000px;
|
||||
}
|
||||
[data-expoimage] .flip-from-left-start:not(.transitioning) {
|
||||
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-left-active {
|
||||
transform: translateZ(0px) rotateY(0) ;
|
||||
opacity:1;
|
||||
}
|
||||
[data-expoimage] .flip-from-left-end {
|
||||
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-right {
|
||||
transition-property: transform, opacity;
|
||||
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
[data-expoimage] .flip-from-right-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
perspective: 1000px;
|
||||
}
|
||||
[data-expoimage] .flip-from-right-start:not(.transitioning) {
|
||||
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-right-active {
|
||||
transform: translateZ(0px) rotateY(0) ;
|
||||
opacity:1;
|
||||
}
|
||||
[data-expoimage] .flip-from-right-end {
|
||||
transform: translateZ(calc(var(--expo-image-width,1000px) * -1.25)) rotateY(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-top {
|
||||
transition-property: transform, opacity;
|
||||
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
[data-expoimage] .flip-from-top-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
perspective: 1000px;
|
||||
}
|
||||
[data-expoimage] .flip-from-top-start:not(.transitioning) {
|
||||
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.5)) rotateX(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-top-active {
|
||||
transform: translateZ(0px) rotateX(0) ;
|
||||
opacity:1;
|
||||
}
|
||||
[data-expoimage] .flip-from-top-end {
|
||||
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.5)) rotateX(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-bottom {
|
||||
transition-property: transform, opacity;
|
||||
transition-timing-function: var(--expo-image-timing,linear), steps(2, jump-none) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
[data-expoimage] .flip-from-bottom-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
perspective: 1000px;
|
||||
}
|
||||
[data-expoimage] .flip-from-bottom-start:not(.transitioning) {
|
||||
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.25)) rotateX(-180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .flip-from-bottom-active {
|
||||
transform: translateZ(0px) rotateX(0) ;
|
||||
opacity:1;
|
||||
}
|
||||
[data-expoimage] .flip-from-bottom-end {
|
||||
transform: translateZ(calc(var(--expo-image-height,1000px) * -1.25)) rotateX(180deg);
|
||||
opacity: 0;
|
||||
}
|
||||
[data-expoimage] .image-timing-linear {
|
||||
--expo-image-timing: linear;
|
||||
}
|
||||
[data-expoimage] .image-timing-ease-in {
|
||||
--expo-image-timing: ease-in;
|
||||
}
|
||||
[data-expoimage] .image-timing-ease-out {
|
||||
--expo-image-timing: ease-out;
|
||||
}
|
||||
[data-expoimage] .image-timing-ease-in-out {
|
||||
--expo-image-timing: ease-in-out;
|
||||
}
|
||||
`;
|
||||
export default function loadStyle() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const styleTag = document.createElement('style');
|
||||
styleTag.innerHTML = style;
|
||||
styleTag.id = 'expo-image-styles';
|
||||
document.head.appendChild(styleTag);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { ImageContentPositionObject, ImageContentPositionValue } from '../Image.types';
|
||||
|
||||
export function ensureValueIsWebUnits(value: string | number) {
|
||||
const trimmedValue = String(value).trim();
|
||||
if (trimmedValue.endsWith('%')) {
|
||||
return trimmedValue;
|
||||
}
|
||||
return `${trimmedValue}px`;
|
||||
}
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
|
||||
export const absoluteFilledPosition = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
} as const;
|
||||
|
||||
export function getObjectPositionFromContentPositionObject(
|
||||
contentPosition?: ImageContentPositionObject
|
||||
): string {
|
||||
const resolvedPosition = { ...contentPosition } as Record<
|
||||
KeysOfUnion<ImageContentPositionObject>,
|
||||
ImageContentPositionValue
|
||||
>;
|
||||
if (!resolvedPosition) {
|
||||
return '50% 50%';
|
||||
}
|
||||
if (resolvedPosition.top == null && resolvedPosition.bottom == null) {
|
||||
resolvedPosition.top = '50%';
|
||||
}
|
||||
if (resolvedPosition.left == null && resolvedPosition.right == null) {
|
||||
resolvedPosition.left = '50%';
|
||||
}
|
||||
|
||||
return (
|
||||
(['top', 'bottom', 'left', 'right'] as const)
|
||||
.map((key) => {
|
||||
if (key in resolvedPosition) {
|
||||
return `${key} ${ensureValueIsWebUnits(resolvedPosition[key])}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join(' ') || '50% 50%'
|
||||
);
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import type { SharedRefType } from 'expo';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ImageProps, ImageSource } from '../Image.types';
|
||||
import { isImageRef } from '../utils';
|
||||
import { isBlurhashString, isThumbhashString } from '../utils/resolveSources';
|
||||
|
||||
function findBestSourceForSize(
|
||||
sources: ImageSource[] | undefined,
|
||||
size: DOMRect | null
|
||||
): ImageSource | null {
|
||||
if (sources?.length === 1) {
|
||||
return sources[0];
|
||||
}
|
||||
return (
|
||||
[...(sources || [])]
|
||||
// look for the smallest image that's still larger then a container
|
||||
?.map((source) => {
|
||||
if (!size) {
|
||||
return { source, penalty: 0, covers: false };
|
||||
}
|
||||
const { width, height } =
|
||||
typeof source === 'object' ? source : { width: null, height: null };
|
||||
if (width == null || height == null) {
|
||||
return { source, penalty: 0, covers: false };
|
||||
}
|
||||
if (width < size.width || height < size.height) {
|
||||
return {
|
||||
source,
|
||||
penalty: Math.max(size.width - width, size.height - height),
|
||||
covers: false,
|
||||
};
|
||||
}
|
||||
return { source, penalty: (width - size.width) * (height - size.height), covers: true };
|
||||
})
|
||||
.sort((a, b) => a.penalty - b.penalty)
|
||||
.sort((a, b) => Number(b.covers) - Number(a.covers))[0]?.source ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export interface SrcSetSource extends ImageSource {
|
||||
srcset: string;
|
||||
sizes: string;
|
||||
// used as key and a fallback in case srcset is not supported
|
||||
uri: string;
|
||||
type: 'srcset';
|
||||
}
|
||||
|
||||
function getCSSMediaQueryForSource(source: ImageSource) {
|
||||
return `(max-width: ${source.webMaxViewportWidth ?? source.width}px) ${source.width}px`;
|
||||
}
|
||||
|
||||
function selectSource(
|
||||
sources: ImageSource[] | undefined,
|
||||
size: DOMRect | null,
|
||||
responsivePolicy: ImageProps['responsivePolicy']
|
||||
): ImageSource | SrcSetSource | null {
|
||||
if (sources == null || sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sources.length === 1) {
|
||||
return sources[0];
|
||||
}
|
||||
|
||||
if (responsivePolicy !== 'static') {
|
||||
return findBestSourceForSize(sources, size);
|
||||
}
|
||||
const staticSupportedSources = sources
|
||||
.filter(
|
||||
(s) => s.uri && s.width != null && !isBlurhashString(s.uri) && !isThumbhashString(s.uri)
|
||||
)
|
||||
.sort(
|
||||
(a, b) => (a.webMaxViewportWidth ?? a.width ?? 0) - (b.webMaxViewportWidth ?? b.width ?? 0)
|
||||
);
|
||||
|
||||
if (staticSupportedSources.length === 0) {
|
||||
console.warn(
|
||||
"You've set the `static` responsivePolicy but none of the sources have the `width` properties set. Make sure you set both `width` and `webMaxViewportWidth` for best results when using static responsiveness. Falling back to the `initial` policy."
|
||||
);
|
||||
return findBestSourceForSize(sources, size);
|
||||
}
|
||||
|
||||
const srcset = staticSupportedSources
|
||||
?.map((source) => `${source.uri} ${source.width}w`)
|
||||
.join(', ');
|
||||
const sizes = `${staticSupportedSources
|
||||
?.map(getCSSMediaQueryForSource)
|
||||
.join(', ')}, ${staticSupportedSources[staticSupportedSources.length - 1]?.width}px`;
|
||||
return {
|
||||
srcset,
|
||||
sizes,
|
||||
uri: staticSupportedSources[staticSupportedSources.length - 1]?.uri ?? '',
|
||||
type: 'srcset',
|
||||
};
|
||||
}
|
||||
|
||||
export default function useSourceSelection(
|
||||
sources: ImageSource[] | SharedRefType<'image'> | undefined,
|
||||
responsivePolicy: ImageProps['responsivePolicy'] = 'static',
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
measurementCallback: ((target: HTMLElement, size: DOMRect) => void) | null = null
|
||||
): ImageSource | SrcSetSource | SharedRefType<'image'> | null {
|
||||
const hasMoreThanOneSource = (Array.isArray(sources) ? sources.length : 0) > 1;
|
||||
const [size, setSize] = useState<null | DOMRect>(
|
||||
containerRef.current?.getBoundingClientRect() ?? null
|
||||
);
|
||||
if (size && containerRef.current) {
|
||||
measurementCallback?.(containerRef.current, size);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if ((!hasMoreThanOneSource && !measurementCallback) || !containerRef.current) {
|
||||
return () => {};
|
||||
}
|
||||
if (responsivePolicy === 'live') {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
setSize(entries[0].contentRect);
|
||||
measurementCallback?.(entries[0].target as any, entries[0].contentRect);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [responsivePolicy, hasMoreThanOneSource, containerRef.current, measurementCallback]);
|
||||
|
||||
if (isImageRef(sources)) {
|
||||
// There is always only one image ref, so there is nothing else to select from.
|
||||
return sources;
|
||||
}
|
||||
return selectSource(sources, size, responsivePolicy);
|
||||
}
|
||||
Reference in New Issue
Block a user