Skip to content

Commit

Permalink
feat(react): add support for dark/light mode images and legacy image …
Browse files Browse the repository at this point in the history
…fallback (#1415)

* feat(ui): Dark/light mode for Sanity image and support legacy images

* chore: Support lightImage & darkImage type
  • Loading branch information
siamak authored Dec 17, 2024
1 parent c878ac4 commit 079770c
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 109 deletions.
83 changes: 55 additions & 28 deletions apps/frontend/components/shared/SanityImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getImageProps } from "@/utils/getImageProps";
import clsx from "clsx";
import type { CSSProperties } from "react";
import type React from "react";
import { preload } from "react-dom";
Expand All @@ -7,13 +8,6 @@ type SanityImageBaseProps = {
alt?: string;
loading?: "lazy" | "eager";
preload?: boolean;
/**
* If this <img> component's size is constrained in ways that force it to have a different
* aspect-ratio than the native image, then `applyHotspot` will apply a CSS `object-position`
* to reflect the hotspot selected by editors in Sanity.
*
* @see https://toolkit.tinloof.com/images
*/
applyHotspot?: boolean;
elProps?: Partial<
React.DetailedHTMLProps<
Expand All @@ -30,41 +24,74 @@ export function SanityImage({
loading = "lazy",
...props
}: SanityImageBaseProps) {
const imageProps = getImageProps(props);
const image = props.image;
const maxWidth = props.maxWidth || 0;

if (!image) return null;

// Handle light/dark mode images
const lightImage = image?.lightImage || image; // Fallback for legacy
const darkImage = image?.darkImage;

// Get image props
const lightProps = lightImage?.asset
? getImageProps({ image: lightImage, maxWidth })
: null;
const darkProps = darkImage?.asset
? getImageProps({ image: darkImage, maxWidth })
: null;

// Extract className from elProps to avoid spreading it
const { className, ...restElProps } = elProps;

// Apply hotspot styles
const hotspotStyle: CSSProperties = props.applyHotspot
? {
objectFit: "cover",
objectPosition: props.image?.hotspot
? `${props.image?.hotspot.x * 100}% ${props.image?.hotspot.y * 100}%`
objectPosition: lightImage?.hotspot
? `${lightImage.hotspot.x * 100}% ${lightImage.hotspot.y * 100}%`
: undefined,
}
: {};
const style = { ...hotspotStyle, ...imageProps.style, ...elProps.style };

if (!imageProps?.src) {
return null;
}
const style = { ...hotspotStyle, ...lightProps?.style, ...restElProps.style };

if (shouldPreload) {
preload(imageProps.src as string, {
// Preload logic for light mode image
if (shouldPreload && lightProps?.src) {
preload(lightProps.src as string, {
fetchPriority: "high",
imageSizes: elProps.sizes ?? imageProps.sizes,
imageSrcSet: elProps.srcSet ?? imageProps.srcSet,
// @ts-ignore
imageSizes: restElProps.sizes ?? lightProps.sizes,
imageSrcSet: restElProps.srcSet ?? lightProps.srcSet,
as: "image",
});
}

return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageProps.src as string}
alt={alt || props.image.alt || props.image.caption || ""}
{...imageProps}
{...elProps}
style={style}
loading={loading}
/>
<>
{/* Light Mode Image */}
{lightProps?.src && (
<img
src={lightProps.src}
alt={alt || lightImage?.alt || ""}
className={clsx(
darkProps?.src && "block dark:hidden",
className, // Add user-provided className here
)}
style={style}
{...restElProps} // Spread elProps without className
/>
)}

{/* Dark Mode Image */}
{darkProps?.src && (
<img
src={darkProps.src}
alt={alt || darkImage?.alt || ""}
className={clsx("hidden dark:block", className)}
style={style}
{...restElProps}
/>
)}
</>
);
}
14 changes: 6 additions & 8 deletions apps/frontend/components/shared/pt.blocks/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@ export default function ImageBlock(props: ImageBlock) {
return (
<div className="my-10 -mx-2 lg:-mx-10 flex flex-col gap-4">
<div className="flex justify-center overflow-hidden rounded-xl">
{
<SanityImage
maxWidth={1440}
alt={props.image.alt || ""}
image={props.image}
elProps={{ className: "rounded-xl" }}
/>
}
<SanityImage
maxWidth={1440}
alt={props.image.alt || ""}
image={props.image}
elProps={{ className: "rounded-xl" }}
/>
</div>
{props.caption && (
<p className="body-s-medium font-medium text-secondary-light dark:text-secondary-dark text-center pb-0">
Expand Down
96 changes: 23 additions & 73 deletions apps/frontend/utils/images/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type { ImageUrlBuilder } from "@sanity/image-url/lib/types/builder";
import type { SanityImageObject } from "@sanity/image-url/lib/types/types";

export type ExtendedSanityImageObject = SanityImageObject & {
alt?: string;
caption?: string;
lightImage?: SanityImageObject;
darkImage?: SanityImageObject;
};

export function getImageDimensions(
image: SanityImageObject,
): { width: number; height: number; aspectRatio: number } | undefined {
if (!image?.asset?._ref) {
return;
}

// example asset._ref:
// image-7558c4a4d73dac0398c18b7fa2c69825882e6210-366x96-png
// When splitting by '-' we can extract the dimensions, id and extension
const dimensions = image.asset._ref.split("-")[2];
const [width, height] = dimensions.split("x").map(Number);

Expand All @@ -20,9 +24,9 @@ export function getImageDimensions(

if (image.crop) {
const croppedWidth =
width * (1 - (image.crop?.right || 0) - image.crop?.left || 0);
width * (1 - (image.crop?.right || 0) - (image.crop?.left || 0));
const croppedHeight =
height * (1 - (image.crop?.top || 0) - image.crop?.bottom || 0);
height * (1 - (image.crop?.top || 0) - (image.crop?.bottom || 0));
return {
width: croppedWidth,
height: croppedHeight,
Expand All @@ -37,65 +41,21 @@ export function getImageDimensions(
};
}

const LARGEST_VIEWPORT = 1920; // Retina sizes will take care of 4k (2560px) and other huge screens

const DEFAULT_MIN_STEP = 0.1; // 10%
const DEFAULT_WIDTH_STEPS = [400, 600, 850, 1000, 1150]; // arbitrary
// Based on statcounter's most common screen sizes: https://gs.statcounter.com/screen-resolution-stats
const LARGEST_VIEWPORT = 1920;
const DEFAULT_MIN_STEP = 0.1;
const DEFAULT_WIDTH_STEPS = [400, 600, 850, 1000, 1150];
const DEFAULT_FULL_WIDTH_STEPS = [360, 414, 768, 1366, 1536, 1920];

/**
* Given an image reference and maxWidth, returns optimized srcSet and sizes properties for <img> elements
*/
export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
return function getImageProps(props: {
image: SanityImageObject & { alt?: string; caption?: string };

image: ExtendedSanityImageObject;
imageTransformer?: (builder: ImageUrlBuilder) => ImageUrlBuilder;

/**
* Width of the image in Figma's desktop layout or "Xvw" if occupies a portion of the viewport's width
*/
maxWidth: number | string;

/**
* The minimal width difference, in PERCENTAGE (decimal), between the image's srcSet variations.
*
* -> 0.10 (10%) by default.
*/
minimumWidthStep?: number;

/**
* Custom widths to use for the image's `srcSet`.
* We'll multiply each by 2 & 3 to get the retina size variations.
*/
customWidthSteps?: number[];

/**
* Custom value for the image's `sizes` attribute.
* Use this if your image follows a non-trivial layout that not `(max-width: ${MAX_WIDTH}) 100vw, ${MAX_WIDTH}`.
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/sizes
*/
customSizes?: string;

/**
* Force a specific aspect ratio for the image.
* This will be reflected at the Sanity's CDN level - the download image will already be cropped by this aspect ratio, with hotspots applied.
*
* @example
* // Avatars with square/round images
* forceAspectRatio={1}
*
* // 16:9 video banner images
* forceAspectRatio={16/9}
*/
forcedAspectRatio?: number;

/**
* @deprecated use `forcedAspectRatio` instead (renamed for clarity)
*/
forceAspectRatio?: number;
forceAspectRatio?: number; // Deprecated
}): Pick<
React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
Expand All @@ -118,7 +78,6 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
typeof props.maxWidth === "string" &&
!/^\d{1,3}vw$/.test(props.maxWidth)
) {
// Must be NUMvw
return {};
}

Expand Down Expand Up @@ -147,33 +106,25 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
? DEFAULT_WIDTH_STEPS
: DEFAULT_FULL_WIDTH_STEPS)),
];

const retinaSizes = Array.from(
// De-duplicate sizes with a Set
new Set([
...baseSizes,
...baseSizes.map((size) => size * 2),
...baseSizes.map((size) => size * 3),
]),
)
.sort((a, b) => a - b) // Lowest to highest

.sort((a, b) => a - b)
.filter(
(size) =>
// Exclude sizes 10% or more larger than the image itself. Sizes slightly larger
// than the image are included to ensure we always get closest to the highest
// quality for an image. Sanity's CDN won't scale the image above its limits.
(!imageDimensions?.width || size <= imageDimensions.width * 1.1) &&
// Exclude those larger than maxWidth's retina (x3)
size <= maxWidth * 3,
)

// Exclude those with a value difference to their following size smaller than `minimumWidthStep`
.filter((size, i, arr) => {
const nextSize = arr[i + 1];
if (nextSize) {
return nextSize / size > minimumWidthStep + 1;
}

return true;
});

Expand All @@ -192,14 +143,14 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
style: {
"--img-aspect-ratio": aspectRatio,
"--img-natural-width": `${imageDimensions.width}px`,
} as React.HTMLAttributes<HTMLElement>["style"],
} as React.CSSProperties,

src: builder
.width(maxWidth)
.height(
props.forceAspectRatio
? Math.round(maxWidth / props.forceAspectRatio)
: (undefined as any as number),
props.forcedAspectRatio
? Math.round(maxWidth / props.forcedAspectRatio)
: undefined,
)
.url(),

Expand All @@ -209,9 +160,9 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
`${builder
.width(size)
.height(
props.forceAspectRatio
? Math.round(size / props.forceAspectRatio)
: (undefined as any as number),
props.forcedAspectRatio
? Math.round(size / props.forcedAspectRatio)
: undefined,
)
.url()} ${size}w`,
)
Expand All @@ -223,7 +174,6 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) {
? props.maxWidth
: `(max-width: ${maxWidth}px) 100vw, ${maxWidth}px`),

// Let's also tell the browser what's the size of the image so it can calculate aspect ratios
width: imageDimensions.width,
height: Math.round(imageDimensions.width / aspectRatio),
};
Expand Down

0 comments on commit 079770c

Please sign in to comment.