import { CoValueLoadingState, ImageDefinition } from "jazz-tools";
import {
type JSX,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { highestResAvailable } from "../../media/index.js";
import { useCoState } from "../hooks.js";
export type ImageProps = Omit<
JSX.IntrinsicElements["img"],
"src" | "srcSet" | "width" | "height"
> & {
/** The ID of the ImageDefinition to display */
imageId: string;
/**
* The desired width of the image. Can be a number in pixels or "original" to use the image's original width.
* When set to a number, the component will select the best available resolution and maintain aspect ratio.
*
* @example
* ```tsx
* // Use original width
*
*
* // Set width to 600px, height will be calculated to maintain aspect ratio
*
*
* // Set both width and height to maintain aspect ratio
*
* ```
*/
width?: number | "original";
/**
* The desired height of the image. Can be a number in pixels or "original" to use the image's original height.
* When set to a number, the component will select the best available resolution and maintain aspect ratio.
*
* @example
* ```tsx
* // Use original height
*
*
* // Set height to 400px, width will be calculated to maintain aspect ratio
*
*
* // Set both width and height to maintain aspect ratio
*
* ```
*/
height?: number | "original";
/**
* A custom placeholder to display while an image is loading. This will
* be passed as the src of the img tag, so a data URL works well here.
* This will be used as a fallback if no images are ready and no placeholder
* is available otherwise.
*/
placeholder?: string;
};
/**
* A React component for displaying images stored as ImageDefinition CoValues.
*
* @example
* ```tsx
* import { Image } from "jazz-tools/react";
*
* // Force specific dimensions (may crop or stretch)
* function Avatar({ imageId }: { imageId: string }) {
* return (
*
* );
* }
* ```
*/
export const Image = forwardRef(function Image(
{ imageId, width, height, ...props },
ref,
) {
const image = useCoState(ImageDefinition, imageId, {
select: (image) => {
if (image.$isLoaded) {
return image;
} else if (image.$jazz.loadingState === CoValueLoadingState.LOADING) {
return undefined;
} else return null;
},
});
const lastBestImage = useRef<[string, string] | null>(null);
/**
* For lazy loading, we use the browser's strategy for images with loading="lazy".
* We use an empty image, and when the browser triggers the load event, we load the best available image.
* On page loading, if the image url is already in browser's cache, the load event is triggered immediately.
* This is why we need to use a different blob url for every image.
*/
const [waitingLazyLoading, setWaitingLazyLoading] = useState(
props.loading === "lazy",
);
const lazyPlaceholder = useMemo(
() =>
waitingLazyLoading ? URL.createObjectURL(getEmptyPixelBlob()) : undefined,
[waitingLazyLoading],
);
const dimensions: { width: number | undefined; height: number | undefined } =
useMemo(() => {
const originalWidth = image?.originalSize?.[0];
const originalHeight = image?.originalSize?.[1];
// Both width and height are "original"
if (width === "original" && height === "original") {
return { width: originalWidth, height: originalHeight };
}
// Width is "original", height is a number
if (width === "original" && typeof height === "number") {
if (originalWidth && originalHeight) {
return {
width: Math.round((height * originalWidth) / originalHeight),
height,
};
}
return { width: undefined, height };
}
// Height is "original", width is a number
if (height === "original" && typeof width === "number") {
if (originalWidth && originalHeight) {
return {
width,
height: Math.round((width * originalHeight) / originalWidth),
};
}
return { width, height: undefined };
}
// In all other cases, use the property value:
return {
width: width === "original" ? originalWidth : width,
height: height === "original" ? originalHeight : height,
};
}, [image?.originalSize, width, height]);
const src = useMemo(() => {
if (waitingLazyLoading) {
return lazyPlaceholder;
}
if (image === undefined)
return "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==";
if (!image) return undefined;
const bestImage = highestResAvailable(
image,
dimensions.width || dimensions.height || 9999,
dimensions.height || dimensions.width || 9999,
);
if (!bestImage) return image.placeholderDataURL ?? props?.placeholder;
if (lastBestImage.current?.[0] === bestImage.image.$jazz.id)
return lastBestImage.current?.[1];
const blob = bestImage.image.toBlob();
if (blob) {
const url = URL.createObjectURL(blob);
revokeObjectURL(lastBestImage.current?.[1]);
lastBestImage.current = [bestImage.image.$jazz.id, url];
return url;
}
return image.placeholderDataURL ?? props?.placeholder;
}, [image, dimensions.width, dimensions.height, waitingLazyLoading]);
const onThresholdReached = useCallback(() => {
setWaitingLazyLoading(false);
}, []);
// Revoke object URL when component unmounts
useEffect(
() => () => {
// In development mode we don't revokeObjectURL on unmount because
// it triggers twice under StrictMode.
if (process.env.NODE_ENV === "development") return;
revokeObjectURL(lastBestImage.current?.[1]);
},
[],
);
return (
);
});
function revokeObjectURL(url: string | undefined) {
if (url && url.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
let emptyPixelBlob: Blob | undefined;
function getEmptyPixelBlob() {
if (!emptyPixelBlob) {
emptyPixelBlob = new Blob(
[
Uint8Array.from(
atob(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
),
(c) => c.charCodeAt(0),
),
],
{ type: "image/png" },
);
}
return emptyPixelBlob;
}