From 079770c8524b2e3d5e873e7eeeba4bd43b343609 Mon Sep 17 00:00:00 2001 From: Siamak Date: Tue, 17 Dec 2024 23:15:26 +0330 Subject: [PATCH] feat(react): add support for dark/light mode images and legacy image fallback (#1415) * feat(ui): Dark/light mode for Sanity image and support legacy images * chore: Support lightImage & darkImage type --- .../components/shared/SanityImage.tsx | 83 ++++++++++------ .../components/shared/pt.blocks/Image.tsx | 14 ++- apps/frontend/utils/images/index.ts | 96 +++++-------------- 3 files changed, 84 insertions(+), 109 deletions(-) diff --git a/apps/frontend/components/shared/SanityImage.tsx b/apps/frontend/components/shared/SanityImage.tsx index 427b30077..f37119ad3 100644 --- a/apps/frontend/components/shared/SanityImage.tsx +++ b/apps/frontend/components/shared/SanityImage.tsx @@ -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"; @@ -7,13 +8,6 @@ type SanityImageBaseProps = { alt?: string; loading?: "lazy" | "eager"; preload?: boolean; - /** - * If this 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< @@ -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 - {alt + <> + {/* Light Mode Image */} + {lightProps?.src && ( + {alt + )} + + {/* Dark Mode Image */} + {darkProps?.src && ( + {alt + )} + ); } diff --git a/apps/frontend/components/shared/pt.blocks/Image.tsx b/apps/frontend/components/shared/pt.blocks/Image.tsx index a7665785f..fc70c910d 100644 --- a/apps/frontend/components/shared/pt.blocks/Image.tsx +++ b/apps/frontend/components/shared/pt.blocks/Image.tsx @@ -5,14 +5,12 @@ export default function ImageBlock(props: ImageBlock) { return (
- { - - } +
{props.caption && (

diff --git a/apps/frontend/utils/images/index.ts b/apps/frontend/utils/images/index.ts index b77b78075..b5512e333 100644 --- a/apps/frontend/utils/images/index.ts +++ b/apps/frontend/utils/images/index.ts @@ -1,6 +1,13 @@ 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 { @@ -8,9 +15,6 @@ export function getImageDimensions( 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); @@ -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, @@ -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 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, @@ -118,7 +78,6 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) { typeof props.maxWidth === "string" && !/^\d{1,3}vw$/.test(props.maxWidth) ) { - // Must be NUMvw return {}; } @@ -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; }); @@ -192,14 +143,14 @@ export function createGetImageProps(imageBuilder: ImageUrlBuilder) { style: { "--img-aspect-ratio": aspectRatio, "--img-natural-width": `${imageDimensions.width}px`, - } as React.HTMLAttributes["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(), @@ -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`, ) @@ -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), };