gatsby-source-sanity icon indicating copy to clipboard operation
gatsby-source-sanity copied to clipboard

Support crop and hotspot with gatsby-image

Open iliquifysnacks opened this issue 5 years ago • 25 comments

It would be great if there was a way to use the crop and hotspot defined in the sanity backend with the gatsby-image component.

iliquifysnacks avatar Jun 18 '19 09:06 iliquifysnacks

Yes, that would strengthen the sanity/gatsby combination. Not sure if I could support coding, but would join testing and documenting.

schafevormfenster avatar Jan 06 '20 19:01 schafevormfenster

Any update on this subject?

Runehm avatar Feb 17 '20 18:02 Runehm

This would be really valuable. My GraphQL site is super-dependent on imagery and am struggling a bit without the crop/hotspot.

I'm dying to get this to work.. doesn't gatsby image take a styles prop, so you could feed it some x,y values related to the hotspot you get from Sanity?

pierrenel avatar Mar 12 '20 14:03 pierrenel

Adding my vote to this too. Would be very useful.

amcc avatar Apr 07 '20 14:04 amcc

There's a way to work around this by passing in a style object that contains an object-position/objectPosition key!

I did it (for a background-image but the same process applies) like this:

const positionStyles = {
    backgroundPositionX: `${data.sanitySiteSettings.image.hotspot.x * 100}%`,
    backgroundPositionY: `${data.sanitySiteSettings.image.hotspot.y * 100}%`,
  }

jenkshields avatar Apr 18 '20 21:04 jenkshields

Does that also work for crop?

ghost avatar Apr 22 '20 15:04 ghost

Does that also work for crop?

I haven't tried personally, but I reckon you should be able to by playing around with object-fit and object-position! It depends on your use case and output, I think.

jenkshields avatar Apr 22 '20 23:04 jenkshields

Here's how I'm managing this right now:

The Sanity image tool preview is based entirely on CSS, which is puzzling as the docs recommend dealing with hotspot and crop through @sanity/image-url. However, this is great for us, as we don't have to re-do how gatsby-image-sanity creates srcsets and the likes, we just have to copy the code from [imagetool/src/calculateStyles.js] and apply it to our image components, like so:

export const SanityFluidImage = ({
  assetId,
  fluidOptions,
  hotspot,
  crop,
  className,
  alt,
}) => {
  if (!assetId || !fluidOptions) {
    return null;
  }
  // If you already have the fluid props from GraphQL, skip this step
  const imgFluidProps = getFluidProps(assetId, fluidOptions);

  // If no hotspot or crop, we're good to go with regular GatsbyImage
  if (!hotspot && !crop) {
    return (
      <GatsbyImage alt={alt} fluid={imgFluidProps} className={className} />
    );
  }

  // If we do, however, let's get each element's styles to replicate the hotspot and crop
  const targetStyles = calculateStyles({
    container: {
      aspectRatio: imgFluidProps.aspectRatio,
    },
    image: {
      aspectRatio: imgFluidProps.aspectRatio,
    },
    hotspot,
    crop,
  });

  // Unfortunately, as we need an extra wrapper for the image to apply the crop styles, we recreate a padding div and add it all to a new container div
  return (
    <div style={targetStyles.container}>
      <div aria-hidden="true" style={targetStyles.padding}></div>
      <GatsbyImage
        fluid={imgFluidProps}
        alt={alt}
        className={className}
        // The GatsbyImage wrapper will have the crop styles
        style={targetStyles.crop}
        imgStyle={targetStyles.image}
      />
    </div>
  );
};

This works for most cases, with a big caveat: if the original image is cropped to a width that is lower than the width it should appear on the front-end, the result will be really weird. There are also some cases where the hotspot doesn't seem to work really well, but that's a small price to pay considering the flexibility we're giving editors 😊

The last time calculatedStyles.js was touched was on July 2018 and that code is for the specific purpose of displaying inside of the cropping dialog inside of Sanity. I think we can probably get to a better, more resilient way of taking crop and hotspot into consideration, but I haven't had the time to dive deep on this.

Hope this can help someone, let me know if I can help o/

EDIT: this getFluidProps is my own version of getFluidGatsbyImage that already contains the instantiated @sanity/client ;)

hdoro avatar Apr 27 '20 18:04 hdoro

As for fixed images, we must crop them properly. Here's the code I came to for doing that (it ignores hotspot as it doesn't make sense in most fixed contexts):

function getFixedWithCrop({ assetId, fixed, crop }) {
  let newFixed = { ...fixed };
  const [, , dimensions] = assetId.split('-');
  // Get the original width and height
  const [width, height] = dimensions.split('x');

  // Let's calculate the rect query string to crop the image
  const { left, top, right, bottom } = crop;
  const effectiveWidth = Math.ceil((1 - left - right) * width);
  const effectiveHeight = Math.ceil((1 - top - bottom) * height);

  // rect=x,y,width,height
  // (each is in absolute PX, that's why we refer to width and height)
  const cropQueryStr = `&rect=${Math.floor(left * width)},${Math.floor(
    top * height,
  )},${effectiveWidth},${effectiveHeight}`;

  /*
    cdn.sanity.io/...?w=100&h=94&fit=crop 1x,
    cdn.sanity.io/...?w=150&h=94&fit=crop 1.5x,
    */
  function addToSrcset(srcSet) {
    return (
      srcSet
        .split(',')
        // Map over each individual declaration (divided by ,)
        .map((declaration) => {
          // And get their URLs for further modification
          const [url, multiplier] = declaration.split(' ');
          return `${url}${cropQueryStr} ${multiplier}`;
        })
        // and finally turn this back into a string
        .join(',')
    );
  }
  
  // Add the rect query string we created to all src declarations
  newFixed.src = fixed.src + cropQueryStr;
  newFixed.srcWebp = fixed.srcWebp + cropQueryStr;
  newFixed.srcSet = addToSrcset(fixed.srcSet);
  newFixed.srcSetWebp = addToSrcset(fixed.srcSetWebp);

  console.log({ fixed, newFixed });

  return newFixed;
}

export const getFixedProps = ({ assetId, crop }, options) => {
  let fixed = getFixedGatsbyImage(assetId, options, sanityConfig);
  // If we have a crop, let's add it to every URL in the fixed object
  if (crop && crop.top) {
    return getFixedWithCrop({ assetId, fixed, crop });
  }
  return fixed;
};

As I come to think of it, probably combining @jenkshields object-position trick for hotspot with this URL-level crop is the best alternative both for fluid and fixed images, as we're saving some bandwidth from the cropped part of the image that doesn't have to be loaded. Plus, we're actually getting the image size we are looking for, as otherwise when cropping with CSS we're scaling the divs to compensate for the cropping.

Will update this if I come across a better solution 🙌

hdoro avatar Apr 27 '20 19:04 hdoro

Haha – this is gold @hcavalieri. It would have been cool if we could've made this a bit easier for you, but cool that we know have a way to go about it!

kmelve avatar Apr 27 '20 21:04 kmelve

Great work @hcavalieri - good thinking out of the box!

I do something similar where I grab both the rawImage and the fluidImage from GraphQL, and then I use the rawImage to get a url that support crop and hotspot using @sanity/image-url .

Then I pull out the rect part of that url and add it to the fluidImage's srcSet's.

Not the prettiest solution, but until Sanity supports this out of the box, it'll have to do.

The biggest problem I've found with this approach is that you can't use the base64 encoded baseimage because its not cropped.

I can probably clean up my code a bit after looking at your solution @hcavalieri - but just for reference here it is in its current form:

import imageUrlBuilder from "@sanity/image-url"
import { dataset, projectId } from "./sanityConfig"
import { isNil, join, map, pipe, split } from "ramda"

const builder = imageUrlBuilder({ projectId, dataset })
export const sanityImageSrc = (source: any) => builder.image(source)

export const getFluidImage = (
  rawImage: any,
  fluidImage: any,
  width: number,
  height: number
) => {
  const url = sanityImageSrc(rawImage).width(width).height(height).url()!

  const rect = new URL(url).searchParams.get("rect")

  const addRectToUrl = (rect: string | null) => (incomingUrl: string) => {
    if (isNil(rect)) return incomingUrl

    const [url, size] = split(" ")(incomingUrl)
    return `${url}&rect=${rect} ${size}`
  }
  const convertUrl = addRectToUrl(rect)

  const addRectToUrlSet = (rect: string | null) => (incomingUrl: string) =>
    isNil(rect)
      ? incomingUrl
      : pipe(split(","), map(convertUrl), join(","))(incomingUrl)
  const convertUrlSet = addRectToUrlSet(rect)

  return {
    ...fluidImage,
    src: convertUrl(fluidImage.src),
    srcSet: convertUrlSet(fluidImage.srcSet),
    srcSetWebp: convertUrlSet(fluidImage.srcSetWebp),
    srcWebp: convertUrl(fluidImage.srcWebp),
  }
}

mellson avatar Apr 28 '20 19:04 mellson

@hcavalieri Where am I putting this code for the SanityFluidImage component. Somewhere in sanity? Just not sure where?

wispyco avatar May 22 '20 21:05 wispyco

@mellson can you show how this is being used with a component? and a graphql query?

viperfx avatar May 25 '20 18:05 viperfx

@viperfx sure. The graphql query looks something like this:

query {
    rawImage: sanityDocument {
      _rawMainImage
      mainImage {
        asset {
          fluid(maxWidth: 1152, maxHeight: 420) {
            ...GatsbySanityImageFluid_noBase64
          }
        }
      }
    }
}

and I use it in an image like this:

<Img
  backgroundColor
  fluid={getFluidImage(
    rawImage._rawMainImage,
    rawImage.mainImage.asset.fluid,
    1152,
    420
  )}
/>

mellson avatar May 25 '20 19:05 mellson

Here is my workaround. I use hotspot only in one component, so I just created a custom image component instead of using gatsby-image. I basically made srcSet on my own and copied&pasted the output of gatsby-image. If you want to use this kind of workaround with gatsby-image, I guess you could use patch-package.

Plus, the code below is a little specific to my needs. I have aspectRatio which can be 1, 2/3, 3/2, ...

  const originalUrl = imageUrlFor(buildImageObj(imageNode))
    .width(1440)
    .height(1440 * aspectRatio)  // aspectRatio: 1, 2/3, 3/2, ...
    .url();

  const widths = [360, 720, 1440];
  const srcSetWebp = widths.map(width => {
    const url = `${imageUrlFor(buildImageObj(imageNode))
      .width(width)
      .height(width * aspectRatio)
      .url()}&fm=webp`;
    return `${url} ${width}w`;
  });
  const srcSet = widths.map(width => {
    const url = imageUrlFor(buildImageObj(imageNode))
      .width(width)
      .height(width * aspectRatio)
      .url();
    return `${url} ${width}w`;
  });
  const sizes = `(max-width: 1440px) 100vw, 1440px`;

  return (
    <figure className={className}>
      <div
        sx={{
          position: 'relative',
          overflow: 'hidden',
        }}
      >
        <div
          aria-hidden={true}
          sx={{
            width: '100%',
            paddingBottom: `${100 * aspectRatio}%`,
          }}
        ></div>
        <picture>
          <source type="image/webp" srcSet={srcSetWebp} sizes={sizes} />
          <source srcSet={srcSet} sizes={sizes} />
          <img
            sizes={sizes}
            srcSet={srcSet}
            src={originalUrl}
            alt={imageNode.alt}
            loading="lazy"
            sx={{
              position: 'absolute',
              top: '0px',
              left: '0px',
              width: '100%',
              height: '100%',
              objectFit: 'cover',
              objectPosition: 'center center',
              opacity: 1,
              transition: 'none 0s ease 0s',
            }}
          />
        </picture>
      </div>
    </figure>
  );

eunjae-lee avatar Jul 05 '20 15:07 eunjae-lee

maybe this funciton

 const fluidProps = getFluidGatsbyImage(
        props.node.photo.photo,
        {},
        sanity
      )

at this class getGatsbyImageProps.ts could use the crop and hotspot data came from the rawdata and use it, instead of using the arguments of the getFluidGatsbyImage and the default values that comes from them. not sure why the sanity team did that implementation, but I think it can be added the crop and hostpot support here.

What do you think?

MikeCastillo1 avatar Sep 28 '20 20:09 MikeCastillo1

@hdoro I would like to integrate your fluid option but I am lost where do you find the calculatedstyles.js?

holly-searchengineunity avatar Jan 11 '21 16:01 holly-searchengineunity

@holly-searchengineunity it's in Sanity : node-modules/@sanity/imagetool that said, I'd really like to see a real implementation of this :) any codesandbox somewhere ?

deodat avatar Jan 27 '21 21:01 deodat

@jenkshields sorry, your solution is a bit confusing for me, where do you this code ? in your Sanity schema for the image ?

deodat avatar Jan 27 '21 22:01 deodat

@jenkshields sorry, your solution is a bit confusing for me, where do you this code ? in your Sanity schema for the image ?

No, in the front-end - in my case in particular I pulled the x and y information from sanity and used it to position a background image. You can do this by using the x and way to set object-position in your css/style object.

jenkshields avatar Jan 27 '21 22:01 jenkshields

@jenkshields thanks a lot !

deodat avatar Jan 28 '21 10:01 deodat

Two months later... Okay, so if dumb guys like me need to be taken by the hand on this, here's how I finally managed to get it working based on @jenkshields solution:

  • First, in my grapql query, I grab the hotspot from Sanity:
  mainImage {
    hotspot {
      x
      y
    }
    asset {
      fluid(
        maxWidth: 1200
      ) {
        ...GatsbySanityImageFluid_noBase64
      }
    }
  }
  • Second, I get it in my component:
export default function BlogPostPreviewList({ mainImage }) {
  const objectPosition = {
    x: `${mainImage.hotspot.x * 100}%`,
    y: `${mainImage.hotspot.y * 100}%`,
  };

  return ( 
    <FirstNodeStyles className="firstPost" objectPosition={objectPosition}>
          {mainImage?.asset && (
            <Img
              fluid={mainImage.asset.fluid}
              alt={mainImage.alt}
            />
          )}
    </FirstNodeStyles>
  );
}
  • Third, in my styled component :
const FirstNodeStyles = styled.div`
  .gatsby-image-wrapper {
    --x: ${(props) => props.objectPosition.x};
    --y: ${(props) => props.objectPosition.y};

    div[aria-hidden='true'] {
      padding-bottom: 41% !important;
    }
    img {
      object-position: var(--x) var(--y) !important;
    }
  }
`;

deodat avatar Mar 14 '21 17:03 deodat

For people that might want the same: If you use the new Gatsby Image handling and would like hotspots, you can simply extend the Type that contains the asset.

getGatsbyImageData already accepts an Object that has Keys asset, hotspot and crop, you can simply throw your wrapping type in there. To make things easier (and make gatsby-node handle things) extend your Type that contains the asset (in my case SanityImage) with the field gatsbyImageData.

Would look something like this:

const { getGatsbyImageData } = require('gatsby-source-sanity')
const { getGatsbyImageFieldConfig } = require('gatsby-plugin-image/graphql-utils')

exports.createResolvers = ({ createResolvers }) => {
	createResolvers({
		SanityImage: {
			// pretty much copy and paste from here:
			// https://github.com/sanity-io/gatsby-source-sanity/blob/bbe8565c0c639797e25b742df4e1dc120c465108/src/images/extendImageNode.ts#L47
			gatsbyImageData: getGatsbyImageFieldConfig(
				(image, args) => getGatsbyImageData(image, args, sanityConfig),
				{
					placeholder: {
						type: 'SanityGatsbyImagePlaceholder',
						defaultValue: `dominantColor`,
						// Also copy the description from this line if you want that comment in your schema
						// https://github.com/sanity-io/gatsby-source-sanity/blob/bbe8565c0c639797e25b742df4e1dc120c465108/src/images/extendImageNode.ts#L53
						description: "..."
					},
					fit: {
						type: 'SanityImageFit',
						defaultValue: 'fill',
					},
				},
			),
		},
	})
}

You can then query like this (works the same as the asset field):

fragment ImageWithCropAndHotspot on SanityImage {
	gatsbyImageData(
		layout: FULL_WIDTH
		fit: FILL
		height: 600
	)
}

Only "downside" I noticed so far: GraphQLCodegen exports the type of that field as any - but as long as it works...

xyng avatar Sep 08 '21 15:09 xyng

Hey guys, I found small plugin which handles crops and hotspots

https://github.com/coreyward/gatsby-plugin-sanity-image

fderen-dev avatar Oct 12 '21 12:10 fderen-dev