Created
May 5, 2023 16:47
-
-
Save brandonscript/92869a89c95f22236bccdd46304d6e5a to your computer and use it in GitHub Desktop.
Material-UI v5 component wrapper for next/image
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import classNames from "classnames"; | |
// This is from another project isn't properly published yet, | |
// but you can grab it from https://github.com/brandonscript/mui-flexy | |
import { FlexBox, FlexBoxProps } from "components/ui/flexbox"; | |
import deepmerge from "deepmerge"; | |
import { toFiniteInt } from "lib/numbers"; | |
import { toSentenceCase } from "lib/strings"; | |
import NextImage, { ImageProps as NextImageProps } from "next/image"; | |
import { CSSProperties, forwardRef, useMemo } from "react"; | |
import { ImageProps } from "./Image.types"; | |
import { | |
aspectRatioToString, | |
heightFromAspectRatio, | |
parseAspectRatio, | |
pxValueToNumber, | |
widthFromAspectRatio, | |
} from "./Image.utils"; | |
export type { ImageProps } from "./Image.types"; | |
export const imageLoader = ({ | |
src, | |
width, | |
quality, | |
}: { | |
src: string; | |
width: number; | |
quality?: number; | |
}) => { | |
return `${src}?w=${width || "100vw"}&q=${quality || 75}`; | |
}; | |
const defaultNextImageProps = { | |
width: 400, | |
height: 300, | |
objectFit: "cover", | |
objectPosition: "center", | |
} as Partial<NextImageProps>; | |
export const Image = forwardRef<unknown, ImageProps>((props, ref) => { | |
const { | |
src, | |
alt, | |
id, | |
className, | |
fit = "cover", | |
nextImageProps = defaultNextImageProps, | |
placeholder, | |
lazy = true, | |
...flexBoxProps | |
} = props; | |
const imageProps = useMemo(() => { | |
const { width, height } = nextImageProps; | |
return { | |
...nextImageProps, | |
width: toFiniteInt(width), | |
height: toFiniteInt(height), | |
src, | |
alt, | |
}; | |
}, [nextImageProps, src, alt]); | |
const nextFill = imageProps?.fill ?? false; | |
const isRelative = !(src as string)?.startsWith("http"); | |
const objectFit = | |
imageProps?.style?.objectFit ?? (fit === "cover" || fit === "contain") ? fit : "cover"; | |
const objectPosition = imageProps?.style?.objectPosition ?? "center"; | |
// const { sx: boxSx, ...otherFlexBoxProps } = flexBoxProps ?? { sx: {} }; | |
const boxWidth = flexBoxProps?.width ?? (flexBoxProps?.sx as any)?.width; | |
const boxHeight = flexBoxProps?.height ?? (flexBoxProps?.sx as any)?.height; | |
const boxWidthAsNumber = pxValueToNumber(boxWidth); | |
const boxHeightAsNumber = pxValueToNumber(boxHeight); | |
const boxAspectRatio = parseAspectRatio((flexBoxProps?.sx as any)?.aspectRatio as string, { | |
width: boxWidthAsNumber, | |
height: boxHeightAsNumber, | |
}); | |
const hasBoxAspectRatio = (flexBoxProps?.sx as any)?.aspectRatio !== undefined; | |
const imageAspectRatio = parseAspectRatio(imageProps?.style?.aspectRatio as string, { | |
width: imageProps?.width, | |
height: imageProps?.height, | |
}); | |
const hasImageAspectRatio = imageAspectRatio !== undefined; | |
const guessBoxWidthFromAspectRatio = widthFromAspectRatio(imageAspectRatio, boxHeightAsNumber); | |
const guessBoxHeightFromAspectRatio = heightFromAspectRatio(imageAspectRatio, boxWidthAsNumber); | |
const guessImageWidthFromAspectRatio = widthFromAspectRatio(imageAspectRatio, imageProps?.height); | |
const guessImageHeightFromAspectRatio = heightFromAspectRatio( | |
imageAspectRatio, | |
imageProps?.width | |
); | |
// const guessImageOrientation = | |
// orientationFromAspectRatio(imageAspectRatio) ?? orientationFromAspectRatio(boxAspectRatio); | |
const imageWidth = | |
imageProps?.width ?? guessImageWidthFromAspectRatio ?? guessBoxWidthFromAspectRatio; | |
const imageHeight = | |
imageProps?.height ?? guessImageHeightFromAspectRatio ?? guessBoxHeightFromAspectRatio; | |
const boxSizeProps = useMemo(() => { | |
const _aspectRatio = ["fit"].includes(fit) | |
? boxAspectRatio ?? imageAspectRatio | |
: fit === "aspect-ratio" | |
? undefined | |
: boxAspectRatio; | |
return { | |
width: boxWidth, | |
height: boxHeight, | |
aspectRatio: aspectRatioToString(_aspectRatio), | |
}; | |
}, [fit, boxAspectRatio, imageAspectRatio, boxWidth, boxHeight]); | |
const imageSizeProps = useMemo(() => { | |
if (fit === "aspect-ratio" && !hasBoxAspectRatio && !hasImageAspectRatio) { | |
throw new Error( | |
"Image component: aspect-ratio fit requires `aspectRatio` to be set on either boxProps.sx or imageProps, or enough height and width props to calculate it." | |
); | |
} | |
const width = nextFill | |
? undefined | |
: ["fill", "contain", "cover", "auto", "aspect-ratio"].includes(fit) | |
? "auto" | |
: ["fit"].includes(fit) | |
? "100%" | |
: imageWidth; | |
const height = nextFill | |
? undefined | |
: ["fill"].includes(fit) | |
? "auto" | |
: ["contain", "cover", "stretch", "aspect-ratio"].includes(fit) | |
? "100%" | |
: ["auto"].includes(fit) | |
? ["-webkit-fill-available", "-moz-available", "fill-available"] | |
: imageHeight; | |
const _objectFit = | |
fit === "fill" ? "none" : ["aspect-ratio", "stretch"].includes(fit) ? "fill" : objectFit; | |
const _aspectRatio = | |
["cover", "aspect-ratio", "auto", "none"].includes(fit) || fit === undefined | |
? imageWidth && imageHeight | |
? undefined | |
: imageAspectRatio | |
: undefined; | |
return { | |
width, | |
height, | |
style: { | |
objectFit: _objectFit, | |
objectPosition, | |
aspectRatio: aspectRatioToString(_aspectRatio), | |
width, | |
height, | |
}, | |
}; | |
}, [ | |
fit, | |
hasBoxAspectRatio, | |
hasImageAspectRatio, | |
nextFill, | |
imageWidth, | |
imageHeight, | |
objectFit, | |
imageAspectRatio, | |
objectPosition, | |
]); | |
const _flexBoxProps: FlexBoxProps = { | |
...flexBoxProps, | |
id, | |
ref, | |
width: boxSizeProps.width, | |
height: boxSizeProps.height, | |
position: "relative", | |
x: flexBoxProps?.x ?? "stretch", | |
y: flexBoxProps?.y ?? "stretch", | |
className: classNames("MuiNextImageBox-root", className), | |
sx: deepmerge((flexBoxProps?.sx as any) ?? {}, { | |
"& > *": { | |
borderRadius: "inherit", | |
flexGrow: flexBoxProps?.flexGrow ?? 1, | |
}, | |
aspectRatio: boxSizeProps.aspectRatio, | |
"& > img": { | |
...imageProps?.style, | |
...(imageSizeProps.style as CSSProperties), | |
}, | |
}), | |
}; | |
return ( | |
<FlexBox {..._flexBoxProps}> | |
<NextImage | |
src={ | |
isRelative | |
? src | |
: imageLoader({ | |
src, | |
width: toFiniteInt(imageSizeProps.width), | |
quality: toFiniteInt(imageProps?.quality), | |
}) | |
} | |
priority={imageProps?.priority} | |
placeholder={placeholder ? "blur" : "empty"} | |
blurDataURL={placeholder ?? src?.replace(".webp", ".blur.jpg")} | |
alt={alt} | |
fill={nextFill} | |
width={!nextFill ? imageWidth : undefined} | |
height={!nextFill ? imageHeight : undefined} | |
loading={lazy ? "lazy" : "eager"} | |
className={`MuiNextImage-root MuiNextImage-Fit${toSentenceCase(fit)}`} | |
/> | |
</FlexBox> | |
); | |
}); | |
Image.displayName = "Image2"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { ResponsiveStyleValue } from "@mui/system/styleFunctionSx/styleFunctionSx.d"; | |
import { FlexBoxProps } from "components/ui/flexbox"; | |
import { toFiniteNumber } from "lib/numbers"; | |
import { ImageProps as NextImageProps } from "next/image"; | |
import { CSSProperties } from "react"; | |
export type SafeNumber = number | `${number}`; | |
export const isSafeNumber = (value: any): value is SafeNumber => { | |
return typeof value === "number" || /^\d+$/.test(value); | |
}; | |
export type ImageBoxFit = | |
| "fill" | |
| "contain" | |
| "cover" | |
| "aspect-ratio" | |
| "stretch" | |
| "auto" | |
| "fit" | |
| "none"; | |
/// ^ fill = fill box, resizing image to grow if necessary, not shrinking if it is greater than box size | |
/// ^ contain = fit image to box, resizing image to shrink if necessary, growing if it is smaller than box size - fit on long edge | |
/// ^ cover = fit image to box, resizing image to grow if necessary, shrinking if it is greater than box size - fit on short edge | |
/// ^ stretch = stretch image to fill box, resizing image to grow if necessary, shrinking if it is greater than box size | |
/// ^ auto = box will make best guess based on orientation | |
/// ^ fit = resize to fit box if possible | |
/// ^ aspect-ratio = resize to fit box if possible, maintaining aspect ratio | |
/// ^ none = do not resize image, align image to box | |
export interface ImageProps extends FlexBoxProps { | |
src: string; | |
alt: string; | |
fit?: ImageBoxFit; | |
placeholder?: string; | |
lazy?: boolean; | |
nextImageProps?: Omit<NextImageProps, "src" | "alt" | "objectFit" | "objectPosition">; | |
} | |
export interface ImageDimensions { | |
width: SafeNumber; | |
height: SafeNumber; | |
} | |
export const isImageDimensions = (value: any): value is ImageDimensions => { | |
return value?.width !== undefined && value?.height !== undefined; | |
}; | |
export const isNonZeroImageDimensions = (value: any): value is ImageDimensions => { | |
const width = toFiniteNumber(value.width); | |
const height = toFiniteNumber(value.height); | |
return isImageDimensions(value) && width > 0 && height > 0; | |
}; | |
export interface ContainerDimensions { | |
width?: number | string; | |
height?: number | string; | |
minWidth?: ResponsiveStyleValue<CSSProperties["maxHeight"]>; | |
minHeight?: ResponsiveStyleValue<CSSProperties["minHeight"]>; | |
maxWidth?: ResponsiveStyleValue<CSSProperties["maxWidth"]>; | |
maxHeight?: ResponsiveStyleValue<CSSProperties["maxHeight"]>; | |
aspectRatio?: string; | |
} | |
type NextImageLayoutValue = "fill" | "fixed" | "intrinsic" | "responsive" | "raw"; | |
export interface ImageProps extends FlexBoxProps { | |
src: string; | |
alt: string; | |
imageDimensions?: ImageDimensions; | |
containerDimensions?: ContainerDimensions; | |
objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; | |
objectPosition?: CSSProperties["objectPosition"]; | |
layout?: CSSProperties["objectFit"] | NextImageLayoutValue; | |
quality?: number; | |
blur?: boolean; | |
priority?: boolean; | |
debug?: boolean; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { toFiniteNumber } from "lib/numbers"; | |
import { | |
ImageDimensions, | |
isImageDimensions, | |
isNonZeroImageDimensions, | |
SafeNumber, | |
} from "./Image.types"; | |
export const widthFromAspectRatio = ( | |
aspectRatio: SafeNumber | ImageDimensions | undefined, | |
height: SafeNumber | undefined | |
) => { | |
if (!aspectRatio || !height) { | |
return undefined; | |
} | |
if (isImageDimensions(aspectRatio)) { | |
return ( | |
toFiniteNumber(height) * | |
(toFiniteNumber(aspectRatio.width) / toFiniteNumber(aspectRatio.height)) | |
); | |
} | |
return toFiniteNumber(height) * toFiniteNumber(aspectRatio); | |
}; | |
export const heightFromAspectRatio = ( | |
aspectRatio: SafeNumber | ImageDimensions | undefined, | |
width: SafeNumber | undefined | |
) => { | |
if (!aspectRatio || !width) { | |
return undefined; | |
} | |
if (isImageDimensions(aspectRatio)) { | |
return ( | |
toFiniteNumber(width) * | |
(toFiniteNumber(aspectRatio.height) / toFiniteNumber(aspectRatio.width)) | |
); | |
} | |
return toFiniteNumber(width) / toFiniteNumber(aspectRatio); | |
}; | |
export const orientationFromAspectRatio = ( | |
aspectRatio?: ImageDimensions | |
): undefined | "landscape" | "portrait" | "square" => { | |
if (!aspectRatio) { | |
return undefined; | |
} | |
return aspectRatio.width > aspectRatio.height | |
? "landscape" | |
: aspectRatio.width < aspectRatio.height | |
? "portrait" | |
: "square"; | |
}; | |
export const parseAspectRatio = ( | |
aspectRatio?: string, | |
fallback?: Partial<ImageDimensions> | |
): ImageDimensions | undefined => { | |
if (aspectRatio) { | |
const [width, height] = aspectRatio.split("/").map((n) => parseFloat(n)); | |
if (width && height) { | |
return { width, height }; | |
} | |
} | |
if (isNonZeroImageDimensions(fallback)) { | |
return fallback; | |
} | |
return undefined; | |
}; | |
export const aspectRatioToString = (aspectRatio?: ImageDimensions | string): string | undefined => { | |
if (typeof aspectRatio === "string") { | |
return aspectRatio; | |
} | |
if (isImageDimensions(aspectRatio)) { | |
return `${aspectRatio.width} / ${aspectRatio.height}`; | |
} | |
return undefined; | |
}; | |
export const parseUnit = (values?: any[]): string | null => { | |
if (!values) { | |
return null; | |
} | |
const units = values.map((value) => { | |
if (typeof value === "number") { | |
return null; | |
} | |
if (typeof value === "string") { | |
// if value has a number and a unit, return the unit | |
// if value has a number and no unit, return an empty string | |
// if value has no number, return an empty string | |
return value.match(/\d+([a-z%]+)/)?.[1] ?? null; | |
} | |
if (Array.isArray(value)) { | |
return parseUnit(value); | |
} | |
if (value && typeof value === "object") { | |
return parseUnit(Object.values(value)); | |
} | |
return null; | |
}); | |
return units.find((unit) => unit !== "" && unit !== null) ?? null; | |
}; | |
export const pxValueToNumber = (value: any): number | undefined => { | |
if (typeof value === "number" || !value) { | |
return value; | |
} | |
return parseUnit([value]) === "px" && !isNaN(parseFloat(value)) ? parseFloat(value) : undefined; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const toFiniteInt = (value: any, fallback: number = 0): number => { | |
if (!value) return 0; | |
const num = isNaN(value) || value === Infinity || value === -Infinity ? 0 : parseInt(value, 10); | |
return num || fallback; | |
}; | |
export const toFiniteNumber = (value: any, fallback: number = 0): number => { | |
if (!value) return 0; | |
const num = isNaN(value) || value === Infinity || value === -Infinity ? 0 : parseFloat(value); | |
return num || fallback; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const toSnakeCase = (str: string): string => { | |
return str | |
.split(/(?=[A-Z])/) | |
.join("_") | |
.replace(/([\s_]+)/g, "_") | |
.toLowerCase(); | |
}; | |
export const toKebabCase = (str: string): string => { | |
return str | |
.split(/(?=[A-Z])/) | |
.join("-") | |
.replace(/([\s-]+)/g, "-") | |
.toLowerCase(); | |
}; | |
export const toSentenceCase = (str: string): string => { | |
return str.charAt(0).toUpperCase() + str.slice(1); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment