From 02edcdcac365757349f2c6e3ab16a8f576865209 Mon Sep 17 00:00:00 2001 From: Tim Brown Date: Fri, 21 Jun 2019 04:39:50 -0400 Subject: [PATCH] feat(gatsby-image): Add art direction (#13395) * Add optional media key to PropTypes and Typescript declarations * Add optional fluidImages and fixedImages props * Add art direction to fixed and fluid images * Add art direction to base64 and tracedSVG * Add art direction to noscript image * Add tests for fixedImages and fluidImages * Respond to code review * Use const in tests * Additinal code review refactor * Fix e2e tests * Add README docs * Fix typo and update wording in README * Name selectors in e2e test * Work around SVG bug by encoding spaces * Fix breaking Placeholder change, respond to code review, and update snapshots * Use @polarthene's Pastebin * Update sharp snapshot test * Reset integration tests * Move fluidImages & fixedImages into fluid & fixed * update tests with no media * cleanup spreadprops * Add warning if multiple sources with no media were used * review changes * fix tests --- .../src/components/floating-image.js | 27 +-- packages/gatsby-image/README.md | 100 +++++++--- packages/gatsby-image/index.d.ts | 4 + .../src/__tests__/__snapshots__/index.js.snap | 148 +++++++++++++- packages/gatsby-image/src/__tests__/index.js | 106 ++++++++++ packages/gatsby-image/src/index.js | 181 ++++++++++++++---- .../src/__tests__/__snapshots__/index.js.snap | 4 +- packages/gatsby-plugin-sharp/src/trace-svg.js | 4 + 8 files changed, 490 insertions(+), 84 deletions(-) diff --git a/examples/using-gatsby-image/src/components/floating-image.js b/examples/using-gatsby-image/src/components/floating-image.js index 56ef35ca8b781..f033653c0ffcd 100644 --- a/examples/using-gatsby-image/src/components/floating-image.js +++ b/examples/using-gatsby-image/src/components/floating-image.js @@ -12,18 +12,6 @@ const Image = styled(Img)` margin-left: ${rhythm(options.blockMarginBottom * 2)}; margin-right: -${gutter.default}; - ${mq.phablet} { - display: none; - } -` - -const ImageDesktop = styled(Image)` - display: none; - - ${mq.phablet} { - display: block; - } - ${mq.tablet} { margin-right: -${gutter.tablet}; } @@ -53,15 +41,14 @@ const FloatingImage = ({ https://www.gatsbyjs.org/packages/gatsby-image/#gatsby-image-props */} - diff --git a/packages/gatsby-image/README.md b/packages/gatsby-image/README.md index 409fad98a03eb..cc305c25ce34c 100644 --- a/packages/gatsby-image/README.md +++ b/packages/gatsby-image/README.md @@ -331,31 +331,85 @@ You will need to add it in your graphql query as is shown in the following snipp } ``` +## Art-directing multiple images + +`gatsby-image` supports showing different images at different breakpoints, which is known as [art direction](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images#Art_direction). To do this, you can define your own array of `fixed` or `fluid` images, along with a `media` key per image, and pass it to `gatsby-image`'s `fixed` or `fluid` props. The `media` key that is set on an image can be any valid CSS media query. + +```jsx +import React from "react" +import { graphql } from "gatsby" +import Img from "gatsby-image" + +export default ({ data }) => { + // Set up the array of image data and `media` keys. + // You can have as many entries as you'd like. + const sources = [ + data.mobileImage.childImageSharp.fluid, + { + ...data.desktopImage.childImageSharp.fluid, + media: `(min-width: 768px)`, + }, + ] + + return ( +
+

Hello art-directed gatsby-image

+ +
+ ) +} + +export const query = graphql` + query { + mobileImage(relativePath: { eq: "blog/avatars/kyle-mathews.jpeg" }) { + childImageSharp { + fluid(maxWidth: 1000, quality: 100) { + ...GatsbyImageSharpFluid + } + } + } + desktopImage( + relativePath: { eq: "blog/avatars/kyle-mathews-desktop.jpeg" } + ) { + childImageSharp { + fluid(maxWidth: 2000, quality: 100) { + ...GatsbyImageSharpFluid + } + } + } + } +` +``` + +While you could achieve a similar effect with plain CSS media queries, `gatsby-image` accomplishes this using the `` tag, which ensures that browsers only download the image they need for a given breakpoint. + ## `gatsby-image` props -| Name | Type | Description | -| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------- | -| `fixed` | `object` | Data returned from the `fixed` query | -| `fluid` | `object` | Data returned from the `fluid` query | -| `fadeIn` | `bool` | Defaults to fading in the image on load | -| `durationFadeIn` | `number` | fading duration is set up to 500ms by default | -| `title` | `string` | Passed to the `img` element | -| `alt` | `string` | Passed to the `img` element. Defaults to an empty string `alt=""` | -| `crossOrigin` | `string` | Passed to the `img` element | -| `className` | `string` / `object` | Passed to the wrapper element. Object is needed to support Glamor's css prop | -| `style` | `object` | Spread into the default styles of the wrapper element | -| `imgStyle` | `object` | Spread into the default styles of the actual `img` element | -| `placeholderStyle` | `object` | Spread into the default styles of the placeholder `img` element | -| `placeholderClassName` | `string` | A class that is passed to the placeholder `img` element | -| `backgroundColor` | `string` / `bool` | Set a colored background placeholder. If true, uses "lightgray" for the color. You can also pass in any valid color string. | -| `onLoad` | `func` | A callback that is called when the full-size image has loaded. | -| `onStartLoad` | `func` | A callback that is called when the full-size image starts loading, it gets the parameter { wasCached: boolean } provided. | -| `onError` | `func` | A callback that is called when the image fails to load. | -| `Tag` | `string` | Which HTML tag to use for wrapping elements. Defaults to `div`. | -| `objectFit` | `string` | Passed to the `object-fit-images` polyfill when importing from `gatsby-image/withIEPolyfill`. Defaults to `cover`. | -| `objectPosition` | `string` | Passed to the `object-fit-images` polyfill when importing from `gatsby-image/withIEPolyfill`. Defaults to `50% 50%`. | -| `loading` | `string` | Set the browser's native lazy loading attribute. One of `lazy`, `eager` or `auto`. Defaults to `lazy`. | -| `critical` | `bool` | Opt-out of lazy-loading behavior. Defaults to `false`. Deprecated, use `loading` instead. | +| Name | Type | Description | +| ---------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `fixed` | `object` / `array` | Data returned from the `fixed` query. When prop is an array it has to be combined with `media` keys, allows for art directing `fixed` images. | +| `fluid` | `object` / `array` | Data returned from the `fluid` query. When prop is an array it has to be combined with `media` keys, allows for art directing `fluid` images. | +| `fadeIn` | `bool` | Defaults to fading in the image on load | +| `durationFadeIn` | `number` | fading duration is set up to 500ms by default | +| `title` | `string` | Passed to the `img` element | +| `alt` | `string` | Passed to the `img` element. Defaults to an empty string `alt=""` | +| `crossOrigin` | `string` | Passed to the `img` element | +| `className` | `string` / `object` | Passed to the wrapper element. Object is needed to support Glamor's css prop | +| `style` | `object` | Spread into the default styles of the wrapper element | +| `imgStyle` | `object` | Spread into the default styles of the actual `img` element | +| `placeholderStyle` | `object` | Spread into the default styles of the placeholder `img` element | +| `placeholderClassName` | `string` | A class that is passed to the placeholder `img` element | +| `backgroundColor` | `string` / `bool` | Set a colored background placeholder. If true, uses "lightgray" for the color. You can also pass in any valid color string. | +| `onLoad` | `func` | A callback that is called when the full-size image has loaded. | +| `onStartLoad` | `func` | A callback that is called when the full-size image starts loading, it gets the parameter { wasCached: boolean } provided. | +| `onError` | `func` | A callback that is called when the image fails to load. | +| `Tag` | `string` | Which HTML tag to use for wrapping elements. Defaults to `div`. | +| `objectFit` | `string` | Passed to the `object-fit-images` polyfill when importing from `gatsby-image/withIEPolyfill`. Defaults to `cover`. | +| `objectPosition` | `string` | Passed to the `object-fit-images` polyfill when importing from `gatsby-image/withIEPolyfill`. Defaults to `50% 50%`. | +| `loading` | `string` | Set the browser's native lazy loading attribute. One of `lazy`, `eager` or `auto`. Defaults to `lazy`. | +| `critical` | `bool` | Opt-out of lazy-loading behavior. Defaults to `false`. Deprecated, use `loading` instead. | +| `fixedImages` | `array` | An array of objects returned from `fixed` queries. When combined with `media` keys, allows for art directing `fixed` images. | +| `fluidImages` | `array` | An array of objects returned from `fluid` queries. When combined with `media` keys, allows for art directing `fluid` images. | ## Image processing arguments diff --git a/packages/gatsby-image/index.d.ts b/packages/gatsby-image/index.d.ts index 11d6b01ccf9e5..976e4a2b08f21 100644 --- a/packages/gatsby-image/index.d.ts +++ b/packages/gatsby-image/index.d.ts @@ -9,6 +9,7 @@ export interface FixedObject { tracedSVG?: string srcWebp?: string srcSetWebp?: string + media?: string } export interface FluidObject { @@ -20,6 +21,7 @@ export interface FluidObject { tracedSVG?: string srcWebp?: string srcSetWebp?: string + media?: string } interface GatsbyImageProps { @@ -27,6 +29,8 @@ interface GatsbyImageProps { sizes?: FluidObject fixed?: FixedObject fluid?: FluidObject + fixedImages?: FixedObject[] + fluidImages?: FluidObject[] fadeIn?: boolean title?: string alt?: string diff --git a/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap b/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap index dbd0f6218016a..bb624596d6cfd 100644 --- a/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby-image/src/__tests__/__snapshots__/index.js.snap @@ -22,6 +22,9 @@ exports[` should have a transition-delay of 1sec 1`] = ` srcset="some srcSetWebp" type="image/webp" /> + Alt text for the image should have a transition-delay of 1sec 1`] = ` /> @@ -64,6 +67,9 @@ exports[` should render fixed size images 1`] = ` srcset="some srcSetWebp" type="image/webp" /> + Alt text for the image should render fixed size images 1`] = ` /> @@ -110,6 +116,10 @@ exports[` should render fluid images 1`] = ` srcset="some srcSetWebp" type="image/webp" /> + Alt text for the image should render fluid images 1`] = ` /> + + +`; + +exports[` should render multiple fixed image variants 1`] = ` +
+
+
+ + + + + + + + + + + Alt text for the image + + +
+
+`; + +exports[` should render multiple fluid image variants 1`] = ` +
+
+
+
+ + + + + + + + + + + Alt text for the image + +
diff --git a/packages/gatsby-image/src/__tests__/index.js b/packages/gatsby-image/src/__tests__/index.js index 102963e2c686b..7d1d70b90cba4 100644 --- a/packages/gatsby-image/src/__tests__/index.js +++ b/packages/gatsby-image/src/__tests__/index.js @@ -23,6 +23,46 @@ const fluidShapeMock = { base64: `string_of_base64`, } +const fixedImagesShapeMock = [ + { + width: 100, + height: 100, + src: `test_image.jpg`, + srcSet: `some srcSet`, + srcSetWebp: `some srcSetWebp`, + base64: `string_of_base64`, + }, + { + width: 100, + height: 100, + src: `test_image_2.jpg`, + srcSet: `some other srcSet`, + srcSetWebp: `some other srcSetWebp`, + base64: `other_string_of_base64`, + media: `only screen and (min-width: 768px)`, + }, +] + +const fluidImagesShapeMock = [ + { + aspectRatio: 1.5, + src: `test_image.jpg`, + srcSet: `some srcSet`, + srcSetWebp: `some srcSetWebp`, + sizes: `(max-width: 600px) 100vw, 600px`, + base64: `string_of_base64`, + }, + { + aspectRatio: 2, + src: `test_image_2.jpg`, + srcSet: `some other srcSet`, + srcSetWebp: `some other srcSetWebp`, + sizes: `(max-width: 600px) 100vw, 600px`, + base64: `string_of_base64`, + media: `only screen and (min-width: 768px)`, + }, +] + const setup = ( fluid = false, props = {}, @@ -51,6 +91,32 @@ const setup = ( return container } +const setupImages = ( + fluidImages = false, + onLoad = () => {}, + onError = () => {} +) => { + const { container } = render( + {`Alt + ) + + return container +} + describe(``, () => { it(`should render fixed size images`, () => { const component = setup() @@ -62,6 +128,16 @@ describe(``, () => { expect(component).toMatchSnapshot() }) + it(`should render multiple fixed image variants`, () => { + const component = setupImages() + expect(component).toMatchSnapshot() + }) + + it(`should render multiple fluid image variants`, () => { + const component = setupImages(true) + expect(component).toMatchSnapshot() + }) + it(`should have correct src, title, alt, and crossOrigin attributes`, () => { const imageTag = setup().querySelector(`picture img`) expect(imageTag.getAttribute(`src`)).toEqual(`test_image.jpg`) @@ -91,9 +167,39 @@ describe(``, () => { }) it(`should have the the "critical" prop set "loading='eager'"`, () => { + jest.spyOn(global.console, `log`) + const props = { critical: true } const imageTag = setup(false, props).querySelector(`picture img`) expect(imageTag.getAttribute(`loading`)).toEqual(`eager`) + expect(console.log).toBeCalled() + }) + + it(`should warn if image variants provided are missing media keys.`, () => { + jest.spyOn(global.console, `warn`) + + render( + {`Alt + ) + expect(console.warn).toBeCalled() }) it(`should call onLoad and onError image events`, () => { diff --git a/packages/gatsby-image/src/index.js b/packages/gatsby-image/src/index.js index 80c9d440fa894..d9f2bf0f88747 100644 --- a/packages/gatsby-image/src/index.js +++ b/packages/gatsby-image/src/index.js @@ -37,29 +37,43 @@ const convertProps = props => { convertedProps.loading = `eager` } + // convert fluid & fixed to arrays so we only have to work with arrays + if (convertedProps.fluid) { + convertedProps.fluid = groupByMedia([].concat(convertedProps.fluid)) + } + if (convertedProps.fixed) { + convertedProps.fixed = groupByMedia([].concat(convertedProps.fixed)) + } + return convertedProps } +/** + * Find the source of an image to use as a key in the image cache. + * Use `the first image in either `fixed` or `fluid` + * @param {{fluid: {src: string}[], fixed: {src: string}[]}} args + * @return {string} + */ +const getImageSrcKey = ({ fluid, fixed }) => { + const data = (fluid && fluid[0]) || (fixed && fixed[0]) + + return data.src +} + // Cache if we've seen an image before so we don't bother with // lazy-loading & fading in on subsequent mounts. const imageCache = Object.create({}) const inImageCache = props => { const convertedProps = convertProps(props) // Find src - const src = convertedProps.fluid - ? convertedProps.fluid.src - : convertedProps.fixed.src - + const src = getImageSrcKey(convertedProps) return imageCache[src] || false } const activateCacheForImage = props => { const convertedProps = convertProps(props) // Find src - const src = convertedProps.fluid - ? convertedProps.fluid.src - : convertedProps.fixed.src - + const src = getImageSrcKey(convertedProps) imageCache[src] = true } @@ -101,6 +115,73 @@ function getIO() { return io } +function generateImageSources(imageVariants) { + return imageVariants.map(({ src, srcSet, srcSetWebp, media, sizes }) => ( + + {srcSetWebp && ( + + )} + + + )) +} + +// Return an array ordered by elements having a media prop, does not use +// native sort, as a stable sort is not guaranteed by all browsers/versions +function groupByMedia(imageVariants) { + const withMedia = [] + const without = [] + imageVariants.forEach(variant => + (variant.media ? withMedia : without).push(variant) + ) + + if (without.length > 1 && process.env.NODE_ENV !== `production`) { + console.warn( + `We've found ${ + without.length + } sources without a media property. They might be ignored by the browser, see: https://www.gatsbyjs.org/packages/gatsby-image/#art-directing-multiple-images` + ) + } + + return [...withMedia, ...without] +} + +function generateTracedSVGSources(imageVariants) { + return imageVariants.map(({ src, media, tracedSVG }) => ( + + )) +} + +function generateBase64Sources(imageVariants) { + return imageVariants.map(({ src, media, base64 }) => ( + + )) +} + +function generateNoscriptSource({ srcSet, srcSetWebp, media, sizes }, isWebp) { + const src = isWebp ? srcSetWebp : srcSet + const mediaAttr = media ? `media="${media}" ` : `` + const typeAttr = isWebp ? `type='image/webp' ` : `` + const sizesAttr = sizes ? `sizes="${sizes}" ` : `` + + return `` +} + +function generateNoscriptSources(imageVariants) { + return imageVariants + .map( + variant => + (variant.srcSetWebp ? generateNoscriptSource(variant, true) : ``) + + generateNoscriptSource(variant) + ) + .join(``) +} + const listenToIntersections = (el, cb) => { const observer = getIO() @@ -120,9 +201,6 @@ const noscriptImg = props => { // HTML validation issues caused by empty values like width="" and height="" const src = props.src ? `src="${props.src}" ` : `src="" ` // required attribute const sizes = props.sizes ? `sizes="${props.sizes}" ` : `` - const srcSetWebp = props.srcSetWebp - ? `` - : `` const srcSet = props.srcSet ? `srcset="${props.srcSet}" ` : `` const title = props.title ? `title="${props.title}" ` : `` const alt = props.alt ? `alt="${props.alt}" ` : `alt="" ` // required attribute @@ -133,7 +211,25 @@ const noscriptImg = props => { : `` const loading = props.loading ? `loading="${props.loading}" ` : `` - return `${srcSetWebp}` + let sources = generateNoscriptSources(props.imageVariants) + + return `${sources}` +} + +// Earlier versions of gatsby-image during the 2.x cycle did not wrap +// the `Img` component in a `picture` element. This maintains compatibility +// until a breaking change can be introduced in the next major release +const Placeholder = ({ src, imageVariants, generateSources, spreadProps }) => { + const baseImage = + + return imageVariants.length > 1 ? ( + + {generateSources(imageVariants)} + {baseImage} + + ) : ( + baseImage + ) } const Img = React.forwardRef((props, ref) => { @@ -314,7 +410,8 @@ class Image extends React.Component { } if (fluid) { - const image = fluid + const imageVariants = fluid + const image = imageVariants[0] return ( + )} {/* Show the traced SVG image. */} {image.tracedSVG && ( - + )} {/* Once the image is visible (or the browser doesn't support IntersectionObserver), start downloading the image */} {this.state.isVisible && ( - {image.srcSetWebp && ( - - )} - + {generateImageSources(imageVariants)} {alt} @@ -408,7 +509,9 @@ class Image extends React.Component { } if (fixed) { - const image = fixed + const imageVariants = fixed + const image = imageVariants[0] + const divStyle = { position: `relative`, overflow: `hidden`, @@ -445,25 +548,28 @@ class Image extends React.Component { {/* Show the blurry base64 image. */} {image.base64 && ( - + )} {/* Show the traced SVG image. */} {image.tracedSVG && ( - + )} {/* Once the image is visible, start downloading the image */} {this.state.isVisible && ( - {image.srcSetWebp && ( - - )} - + {generateImageSources(imageVariants)} {alt} @@ -523,6 +630,7 @@ const fixedObject = PropTypes.shape({ tracedSVG: PropTypes.string, srcWebp: PropTypes.string, srcSetWebp: PropTypes.string, + media: PropTypes.string, }) const fluidObject = PropTypes.shape({ @@ -534,13 +642,14 @@ const fluidObject = PropTypes.shape({ tracedSVG: PropTypes.string, srcWebp: PropTypes.string, srcSetWebp: PropTypes.string, + media: PropTypes.string, }) Image.propTypes = { resolutions: fixedObject, sizes: fluidObject, - fixed: fixedObject, - fluid: fluidObject, + fixed: PropTypes.oneOfType([fixedObject, PropTypes.arrayOf(fixedObject)]), + fluid: PropTypes.oneOfType([fluidObject, PropTypes.arrayOf(fluidObject)]), fadeIn: PropTypes.bool, durationFadeIn: PropTypes.number, title: PropTypes.string, diff --git a/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap b/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap index 1a30c242e9255..40b27b2a80c7a 100644 --- a/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap +++ b/packages/gatsby-plugin-sharp/src/__tests__/__snapshots__/index.js.snap @@ -110,7 +110,7 @@ Object { "originalName": undefined, "src": "/static/1234/c0399/test.png", "srcSet": "/static/1234/c0399/test.png 1x", - "tracedSVG": "data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3e%3cpath d='M41 24c-18 7-24 29-11 43 15 17 44 8 46-15 1-19-17-34-35-28' fill='red' fill-rule='evenodd'/%3e%3c/svg%3e", + "tracedSVG": "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3e%3cpath%20d='M41%2024c-18%207-24%2029-11%2043%2015%2017%2044%208%2046-15%201-19-17-34-35-28'%20fill='red'%20fill-rule='evenodd'/%3e%3c/svg%3e", "width": 100, } `; @@ -130,6 +130,6 @@ Object { /static/1234/bc08f/test.png 50w, /static/1234/c0399/test.png 100w", "srcSetType": "image/png", - "tracedSVG": "data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3e%3cpath d='M41 24c-18 7-24 29-11 43 15 17 44 8 46-15 1-19-17-34-35-28' fill='red' fill-rule='evenodd'/%3e%3c/svg%3e", + "tracedSVG": "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='100'%20height='100'%3e%3cpath%20d='M41%2024c-18%207-24%2029-11%2043%2015%2017%2044%208%2046-15%201-19-17-34-35-28'%20fill='red'%20fill-rule='evenodd'/%3e%3c/svg%3e", } `; diff --git a/packages/gatsby-plugin-sharp/src/trace-svg.js b/packages/gatsby-plugin-sharp/src/trace-svg.js index 663465747bfc6..541a1fdaa1666 100644 --- a/packages/gatsby-plugin-sharp/src/trace-svg.js +++ b/packages/gatsby-plugin-sharp/src/trace-svg.js @@ -118,9 +118,13 @@ exports.notMemoizedtraceSVG = async ({ file, args, fileArgs, reporter }) => { const optionsSVG = _.defaults(args, defaultArgs) + // `srcset` attribute rejects URIs with literal spaces + const encodeSpaces = str => str.replace(/ /gi, `%20`) + return trace(tmpFilePath, optionsSVG) .then(optimize) .then(svgToMiniDataURI) + .then(encodeSpaces) } catch (e) { throw e }