sanity-io / gatsby-source-sanity

Gatsby source plugin for building websites using Sanity.io as a backend.
https://www.sanity.io/
MIT License
197 stars 60 forks source link

Support crop and hotspot with gatsby-image #31

Open iliquifysnacks opened 5 years ago

iliquifysnacks commented 5 years ago

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.

schafevormfenster commented 4 years ago

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

Runehm commented 4 years ago

Any update on this subject?

giles-cholmondley-durston commented 4 years ago

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

pierrenel commented 4 years ago

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?

amcc commented 4 years ago

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

jenkshields commented 4 years ago

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}%`,
  }
ghost commented 4 years ago

Does that also work for crop?

jenkshields commented 4 years ago

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.

hdoro commented 4 years ago

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 commented 4 years ago

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 🙌

kmelve commented 4 years ago

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!

mellson commented 4 years ago

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),
  }
}
wispyco commented 4 years ago

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

viperfx commented 4 years ago

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

mellson commented 4 years ago

@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
  )}
/>
eunjae-lee commented 4 years ago

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>
  );
MikeCastillo1 commented 4 years ago

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?

holly-searchengineunity commented 3 years ago

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

deodat commented 3 years ago

@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 commented 3 years ago

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

jenkshields commented 3 years ago

@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.

deodat commented 3 years ago

@jenkshields thanks a lot !

deodat commented 3 years ago

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:

xyng commented 3 years ago

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...

fderen-dev commented 3 years ago

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

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