diff --git a/packages/gatsby-plugin-image/src/components/hooks.ts b/packages/gatsby-plugin-image/src/components/hooks.ts index 276acd94daa46..361aa70eaef68 100644 --- a/packages/gatsby-plugin-image/src/components/hooks.ts +++ b/packages/gatsby-plugin-image/src/components/hooks.ts @@ -19,6 +19,8 @@ import { generateImageData, Layout, EVERY_BREAKPOINT, + IImage, + ImageFormat, } from "../image-utils" const imageCache = new Set() @@ -88,24 +90,114 @@ export async function applyPolyfill( ;(window as any).objectFitPolyfill(ref.current) } -export function useGatsbyImage({ +export interface IUrlBuilderArgs { + width: number + height: number + baseUrl: string + format: ImageFormat + options: OptionsType +} +export interface IUseGatsbyImageArgs { + baseUrl: string + /** + * For constrained and fixed images, the size of the image element + */ + width?: number + height?: number + /** + * If available, pass the source image width and height + */ + sourceWidth?: number + sourceHeight?: number + /** + * If only one dimension is passed, then this will be used to calculate the other. + */ + aspectRatio?: number + layout?: Layout + /** + * Returns a URL based on the passed arguments. Should be a pure function + */ + urlBuilder: (args: IUrlBuilderArgs) => string + + /** + * Should be a data URI + */ + placeholderURL?: string + backgroundColor?: string + /** + * Used in error messages etc + */ + pluginName?: string + + /** + * If you do not support auto-format, pass an array of image types here + */ + formats?: Array + + breakpoints?: Array + + /** + * Passed to the urlBuilder function + */ + options?: OptionsType +} + +/** + * Use this hook to generate gatsby-plugin-image data in the browser. + */ +export function useGatsbyImage({ + baseUrl, + urlBuilder, + sourceWidth, + sourceHeight, pluginName = `useGatsbyImage`, + formats = [`auto`], breakpoints = EVERY_BREAKPOINT, - ...args -}: IGatsbyImageHelperArgs): IGatsbyImageData { - return generateImageData({ pluginName, breakpoints, ...args }) + options, + ...props +}: IUseGatsbyImageArgs): IGatsbyImageData { + const generateImageSource = ( + baseUrl: string, + width: number, + height?: number, + format?: ImageFormat + ): IImage => { + return { + width, + height, + format, + src: urlBuilder({ baseUrl, width, height, options, format }), + } + } + + const sourceMetadata: IGatsbyImageHelperArgs["sourceMetadata"] = { + width: sourceWidth, + height: sourceHeight, + format: `auto`, + } + + const args: IGatsbyImageHelperArgs = { + ...props, + pluginName, + generateImageSource, + filename: baseUrl, + formats, + breakpoints, + sourceMetadata, + } + return generateImageData(args) } export function getMainProps( isLoading: boolean, isLoaded: boolean, - images: any, + images: IGatsbyImageData["images"], loading?: "eager" | "lazy", toggleLoaded?: (loaded: boolean) => void, cacheKey?: string, ref?: RefObject, style: CSSProperties = {} -): MainImageProps { +): Partial { const onLoad: ReactEventHandler = function (e) { if (isLoaded) { return diff --git a/packages/gatsby-plugin-image/src/image-utils.ts b/packages/gatsby-plugin-image/src/image-utils.ts index 89ff7fe73f7fc..9c7db11837caa 100644 --- a/packages/gatsby-plugin-image/src/image-utils.ts +++ b/packages/gatsby-plugin-image/src/image-utils.ts @@ -1,5 +1,6 @@ /* eslint-disable no-unused-expressions */ import { stripIndent } from "common-tags" +import camelCase from "camelcase" import { IGatsbyImageData } from "." const DEFAULT_PIXEL_DENSITIES = [0.25, 0.5, 1, 2] @@ -19,7 +20,8 @@ export const EVERY_BREAKPOINT = [ 4096, ] const DEFAULT_FLUID_WIDTH = 800 -const DEFAULT_FIXED_WIDTH = 400 +const DEFAULT_FIXED_WIDTH = 800 +const DEFAULT_ASPECT_RATIO = 4 / 3 export type Fit = "cover" | "fill" | "inside" | "outside" | "contain" @@ -107,6 +109,8 @@ export interface IGatsbyImageHelperArgs { fit?: Fit options?: Record breakpoints?: Array + backgroundColor?: string + aspectRatio?: number } const warn = (message: string): void => console.warn(message) @@ -150,20 +154,68 @@ export function formatFromFilename(filename: string): ImageFormat | undefined { return undefined } +export function setDefaultDimensions( + args: IGatsbyImageHelperArgs +): IGatsbyImageHelperArgs { + let { + layout = `constrained`, + width, + height, + sourceMetadata, + breakpoints, + aspectRatio, + formats = [`auto`, `webp`], + } = args + formats = formats.map(format => format.toLowerCase() as ImageFormat) + layout = camelCase(layout) as Layout + + if (width && height) { + return args + } + if (sourceMetadata.width && sourceMetadata.height && !aspectRatio) { + aspectRatio = sourceMetadata.width / sourceMetadata.height + } + + if (layout === `fullWidth`) { + width = width || sourceMetadata.width || breakpoints[breakpoints.length - 1] + height = height || Math.round(width / (aspectRatio || DEFAULT_ASPECT_RATIO)) + } else { + if (!width) { + if (height && aspectRatio) { + width = height * aspectRatio + } else if (sourceMetadata.width) { + width = sourceMetadata.width + } else if (height) { + width = Math.round(height / DEFAULT_ASPECT_RATIO) + } else { + width = DEFAULT_FIXED_WIDTH + } + } + + if (aspectRatio && !height) { + height = Math.round(width / aspectRatio) + } + } + return { ...args, width, height, aspectRatio, layout, formats } +} + export function generateImageData( args: IGatsbyImageHelperArgs ): IGatsbyImageData { + args = setDefaultDimensions(args) + let { pluginName, sourceMetadata, generateImageSource, - layout = `constrained`, + layout, fit, options, width, height, filename, reporter = { warn }, + backgroundColor, } = args if (!pluginName) { @@ -175,18 +227,19 @@ export function generateImageData( if (typeof generateImageSource !== `function`) { throw new Error(`generateImageSource must be a function`) } + if (!sourceMetadata || (!sourceMetadata.width && !sourceMetadata.height)) { // No metadata means we let the CDN handle max size etc, aspect ratio etc sourceMetadata = { width, height, - format: formatFromFilename(filename), + format: sourceMetadata?.format || formatFromFilename(filename) || `auto`, } } else if (!sourceMetadata.format) { sourceMetadata.format = formatFromFilename(filename) } - // - const formats = new Set(args.formats || [`auto`, `webp`]) + + const formats = new Set(args.formats) if (formats.size === 0 || formats.has(`auto`) || formats.has(``)) { formats.delete(`auto`) @@ -262,7 +315,11 @@ export function generateImageData( } }) - const imageProps: Partial = { images: result, layout } + const imageProps: Partial = { + images: result, + layout, + backgroundColor, + } switch (layout) { case `fixed`: imageProps.width = imageSizes.presentationWidth @@ -317,7 +374,7 @@ export function calculateImageSizes(args: IImageSizeArgs): IImageSizes { return responsiveImageSizes({ breakpoints, ...args }) } else { reporter.warn( - `No valid layout was provided for the image at ${filename}. Valid image layouts are fixed, fullWidth, and constrained.` + `No valid layout was provided for the image at ${filename}. Valid image layouts are fixed, fullWidth, and constrained. Found ${layout}` ) return { sizes: [imgDimensions.width], diff --git a/packages/gatsby-plugin-image/src/index.browser.ts b/packages/gatsby-plugin-image/src/index.browser.ts index c3e2ff8c9843d..25b9b82b2a296 100644 --- a/packages/gatsby-plugin-image/src/index.browser.ts +++ b/packages/gatsby-plugin-image/src/index.browser.ts @@ -13,6 +13,8 @@ export { useGatsbyImage, useArtDirection, IArtDirectedImage, + IUseGatsbyImageArgs, + IUrlBuilderArgs, } from "./components/hooks" export { generateImageData, diff --git a/packages/gatsby-plugin-image/src/index.ts b/packages/gatsby-plugin-image/src/index.ts index 0f3ddbe216bf6..dda22dcd2f1ad 100644 --- a/packages/gatsby-plugin-image/src/index.ts +++ b/packages/gatsby-plugin-image/src/index.ts @@ -12,6 +12,8 @@ export { useGatsbyImage, useArtDirection, IArtDirectedImage, + IUseGatsbyImageArgs, + IUrlBuilderArgs, } from "./components/hooks" export { generateImageData, diff --git a/packages/gatsby-plugin-image/src/resolver-utils.ts b/packages/gatsby-plugin-image/src/resolver-utils.ts index b8c92355cd3e3..0f471d4c8b8dd 100644 --- a/packages/gatsby-plugin-image/src/resolver-utils.ts +++ b/packages/gatsby-plugin-image/src/resolver-utils.ts @@ -16,7 +16,7 @@ export const ImageFormatType = new GraphQLEnumType({ name: `GatsbyImageFormat`, values: { NO_CHANGE: { value: `` }, - AUTO: { value: `` }, + AUTO: { value: `auto` }, JPG: { value: `jpg` }, PNG: { value: `png` }, WEBP: { value: `webp` }, @@ -43,6 +43,88 @@ export const ImagePlaceholderType = new GraphQLEnumType({ }, }) +export interface IGatsbyGraphQLFieldConfig { + description?: string + type: string + args?: Record + resolve: GraphQLFieldResolver +} +export interface IGatsbyGraphQLResolverArgumentConfig { + description?: string + type: string | Array + defaultValue?: TValue +} + +export function getGatsbyImageResolver( + resolve: GraphQLFieldResolver, + extraArgs?: Record +): IGatsbyGraphQLFieldConfig { + return { + type: `JSON!`, + args: { + layout: { + type: `enum GatsbyImageLayout { FIXED, FULL_WIDTH, CONSTRAINED }`, + description: stripIndent` + The layout for the image. + FIXED: A static image sized, that does not resize according to the screen width + FULL_WIDTH: The image resizes to fit its container. Pass a "sizes" option if it isn't going to be the full width of the screen. + CONSTRAINED: Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size. + `, + }, + width: { + type: `Int`, + description: stripIndent` + The display width of the generated image for layout = FIXED, and the display width of the largest image for layout = CONSTRAINED. + The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities + Ignored if layout = FULL_WIDTH. + `, + }, + height: { + type: `Int`, + description: stripIndent` + If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image.`, + }, + aspectRatio: { + type: `Float`, + description: stripIndent` + If set along with width or height, this will set the value of the other dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + `, + }, + sizes: { + type: `String`, + description: stripIndent` + The "sizes" property, passed to the img tag. This describes the display size of the image. + This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image + container will be the full width of the screen. In these cases we will generate an appropriate value. + `, + }, + outputPixelDensities: { + type: `[Float]`, + description: stripIndent` + A list of image pixel densities to generate for FIXED and CONSTRAINED images. You should rarely need to change this. It will never generate images larger than the source, and will always include a 1x image. + Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, 3x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide. + Ignored for FULL_WIDTH, which uses breakpoints instead. + `, + }, + breakpoints: { + type: `[Int]`, + description: stripIndent` + Specifies the image widths to generate. You should rarely need to change this. For FIXED and CONSTRAINED images it is better to allow these to be determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override the default, which is determined by the plugin. + It will never generate any images larger than the source. + `, + }, + backgroundColor: { + type: `String`, + description: `Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio.`, + }, + ...extraArgs, + }, + resolve, + } +} + export function getGatsbyImageFieldConfig( resolve: GraphQLFieldResolver, extraArgs?: GraphQLFieldConfigArgumentMap @@ -52,7 +134,6 @@ export function getGatsbyImageFieldConfig( args: { layout: { type: ImageLayoutType, - defaultValue: `constrained`, description: stripIndent` The layout for the image. FIXED: A static image sized, that does not resize according to the screen width @@ -73,9 +154,15 @@ export function getGatsbyImageFieldConfig( description: stripIndent` If set, the height of the generated image. If omitted, it is calculated from the supplied width, matching the aspect ratio of the source image.`, }, + aspectRatio: { + type: GraphQLFloat, + description: stripIndent` + If set along with width or height, this will set the value of the other dimension to match the provided aspect ratio, cropping the image if needed. + If neither width or height is provided, height will be set based on the intrinsic width of the source image. + `, + }, placeholder: { type: ImagePlaceholderType, - defaultValue: `blurred`, description: stripIndent` Format of generated placeholder image, displayed while the main image loads. BLURRED: a blurred, low resolution image, encoded as a base64 data URI (default) @@ -91,24 +178,35 @@ export function getGatsbyImageFieldConfig( not know the formats of the source images, as this could lead to unwanted results such as converting JPEGs to PNGs. Specifying both PNG and JPG is not supported and will be ignored. AVIF support is currently experimental. `, - defaultValue: [``, `webp`], + defaultValue: [`auto`, `webp`], }, outputPixelDensities: { type: GraphQLList(GraphQLFloat), description: stripIndent` - A list of image pixel densities to generate. It will never generate images larger than the source, and will always include a 1x image. - Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, 3x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide + A list of image pixel densities to generate for FIXED and CONSTRAINED images. You should rarely need to change this. It will never generate images larger than the source, and will always include a 1x image. + Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, 3x, and [0.25, 0.5, 1, 2] for fluid. In this case, an image with a fluid layout and width = 400 would generate images at 100, 200, 400 and 800px wide. `, }, + breakpoints: { + type: GraphQLList(GraphQLInt), + description: stripIndent` + Specifies the image widths to generate. You should rarely need to change this. For FIXED and CONSTRAINED images it is better to allow these to be determined automatically, + based on the image size. For FULL_WIDTH images this can be used to override the default, which is [750, 1080, 1366, 1920]. + It will never generate any images larger than the source. + `, + }, sizes: { type: GraphQLString, - defaultValue: ``, description: stripIndent` The "sizes" property, passed to the img tag. This describes the display size of the image. This does not affect the generated images, but is used by the browser to decide which images to download. You can leave this blank for fixed images, or if the responsive image container will be the full width of the screen. In these cases we will generate an appropriate value. `, }, + backgroundColor: { + type: GraphQLString, + description: `Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio.`, + }, ...extraArgs, }, resolve, diff --git a/packages/gatsby-source-contentful/src/extend-node-type.js b/packages/gatsby-source-contentful/src/extend-node-type.js index 04cbd59057d70..42e51936c7953 100644 --- a/packages/gatsby-source-contentful/src/extend-node-type.js +++ b/packages/gatsby-source-contentful/src/extend-node-type.js @@ -775,9 +775,6 @@ exports.extendNodeType = ({ type, store, reporter }) => { type: GraphQLInt, defaultValue: 50, }, - backgroundColor: { - type: GraphQLString, - }, }) }