Skip to content

Instantly share code, notes, and snippets.

@brandonscript
Created May 5, 2023 16:47
Show Gist options
  • Save brandonscript/92869a89c95f22236bccdd46304d6e5a to your computer and use it in GitHub Desktop.
Save brandonscript/92869a89c95f22236bccdd46304d6e5a to your computer and use it in GitHub Desktop.
Material-UI v5 component wrapper for next/image
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";
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;
}
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;
};
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;
};
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