Search code examples
reactjsreact-konvakonva

Change the opacity outside of a cropping rectangle in konva


I'm trying to build a image cropping tool with React and konva. I'd like to change the opacity outside of the cropping rectangle to blur the rest of the image.

I have so far tried to set different opacities to the rectangle and the image but failed. I have looked up and there is no direct way to doing this

Here's the cropping function that I adapted to react with the help of this answer

import React, { useState, useEffect, useRef } from "react";
import { render } from "react-dom";
import { Stage, Layer, Rect, Image } from "react-konva";
import Konva from "konva";

const App = () => {
  // Stage dims
  let sW = 720,
    sH = 720,
    sX = 0,
    sY = 0;

   let src = "https://dummyimage.com/720x720/e85de8/fff&text=SO Rocks!";
  let img = document.createElement("img");

  useEffect(() => {
    img.src = src;
    function loadStatus() {
      setloading(false);
    }
    img.addEventListener("load", loadStatus);
    return () => {
      img.removeEventListener("load", loadStatus);
    };
  }, [img, src]);

  let scale = 1;
  const [loading, setloading] = useState(true);
  const [posStart, setposStart] = useState({});
  const [posNow, setposNow] = useState({});
  const [mode, setmode] = useState("");

  /**
   * Sets the state of posStart and posNow for tracking the coordinates of the cropping rectangle
   * @param {Object} posIn - Coordinates of the pointer when MouseDown is fired
   */
  function startDrag(posIn) {
    setposStart({ x: posIn.x, y: posIn.y });
    setposNow({ x: posIn.x, y: posIn.y });
  }

  /**
   * Updates the state accordingly when the MouseMove event is fired
   * @param {Object} posIn - Coordiantes of the current position of the pointer
   */
  function updateDrag(posIn) {
    setposNow({ x: posIn.x, y: posIn.y });
    let posRect = reverse(posStart, posNow);
    r2.current.x(posRect.x1);
    r2.current.y(posRect.y1);
    r2.current.width(posRect.x2 - posRect.x1);
    r2.current.height(posRect.y2 - posRect.y1);
    r2.current.visible(true);
  }

  /**
   * Reverse coordinates if dragged left or up
   * @param {Object} r1 - Coordinates of the starting position of cropping rectangle
   * @param {Object} r2 - Coordinates of the current position of cropping rectangle
   */
  function reverse(r1, r2) {
    let r1x = r1.x,
      r1y = r1.y,
      r2x = r2.x,
      r2y = r2.y,
      d;
    if (r1x > r2x) {
      d = Math.abs(r1x - r2x);
      r1x = r2x;
      r2x = r1x + d;
    }
    if (r1y > r2y) {
      d = Math.abs(r1y - r2y);
      r1y = r2y;
      r2y = r1y + d;
    }
    return { x1: r1x, y1: r1y, x2: r2x, y2: r2y }; // return the corrected rect.
  }

  /**
   * Crops the image and saves it in jpeg format
   * @param {Konva.Rect} r - Ref of the cropping rectangle
   */
  function setCrop(r) {
    let jpeg = new Konva.Image({
      image: img,
      x: sX,
      y: sY
    });
    jpeg.cropX(r.x());
    jpeg.cropY(r.y());
    jpeg.cropWidth(r.width() * scale);
    jpeg.cropHeight(r.height() * scale);
    jpeg.width(r.width());
    jpeg.height(r.height());
    const url = jpeg.toDataURL({ mimeType: "image/jpeg", quality: "1.0" });
    const a = document.createElement("a");
    a.href = url;
    a.download = "cropped.jpg";
    a.click();
  }

  // Foreground rect to capture events
  const r1 = useRef();

  // Cropping rect
  const r2 = useRef();

  const image = useRef();

  return (
    <div className="container">
      <Stage width={sW} height={sH}>
        <Layer>
          {!loading && (
            <Image
              ref={image}
              {...{
                image: img,
                x: sX,
                y: sY
              }}
            />
          )}
          <Rect
            ref={r1}
            {...{
              x: 0,
              y: 0,
              width: sW,
              height: sH,
              fill: "white",
              opacity: 0
            }}
            onMouseDown={function (e) {
              setmode("drawing");
              startDrag({ x: e.evt.layerX, y: e.evt.layerY });
            }}
            onMouseMove={function (e) {
              if (mode === "drawing") {
                updateDrag({ x: e.evt.layerX, y: e.evt.layerY });
                image.current.opacity(0.5);
                r2.current.opacity(1);
              }
            }}
            onMouseUp={function (e) {
              setmode("");
              r2.current.visible(false);
              setCrop(r2.current);
              image.current.opacity(1);
            }}
          />
          <Rect
            ref={r2}
            listening={false}
            {...{
              x: 0,
              y: 0,
              width: 0,
              height: 0,
              stroke: "white",
              dash: [5, 5]
            }}
          />
        </Layer>
      </Stage>
    </div>
  );
};

render(<App />, document.getElementById("root"));

Demo for the above code


Solution

  • It can be done using Konva.Group and its clip property. Add a new Konva.Image to the group and set its clipping positions and size to be the same as the cropping rectangle. Don't forget to set the listening prop of the group to false otherwise it will complicate things. Here's the final result

    import { render } from "react-dom";
    import React, { useLayoutEffect, useRef, useState } from "react";
    import { Stage, Layer, Image, Rect, Group } from "react-konva";
    
    /**
     * Crops a portion of image in Konva stage and saves it in jpeg format
     * @param {*} props - Takes no props
     */
    function Cropper(props) {
      // Stage dims
      let sW = 720,
        sH = 720,
        sX = 0,
        sY = 0;
    
      let src = "https://dummyimage.com/720x720/e85de8/fff&text=SO Rocks!";
      let img = new window.Image();
      useLayoutEffect(() => {
        img.src = src;
        function loadStatus() {
          // img.crossOrigin = "Anonymous";
          setloading(false);
        }
        img.addEventListener("load", loadStatus);
        return () => {
          img.removeEventListener("load", loadStatus);
        };
      }, [img, src]);
      let i = new Konva.Image({
        x: 0,
        y: 0,
        width: 0,
        height: 0
      });
      let scale = 1;
      const [loading, setloading] = useState(true);
      const [posStart, setposStart] = useState({});
      const [posNow, setposNow] = useState({});
      const [mode, setmode] = useState("");
    
      /**
       * Sets the state of posStart and posNow for tracking the coordinates of the cropping rectangle
       * @param {Object} posIn - Coordinates of the pointer when MouseDown is fired
       */
      function startDrag(posIn) {
        setposStart({ x: posIn.x, y: posIn.y });
        setposNow({ x: posIn.x, y: posIn.y });
      }
    
      /**
       * Updates the state accordingly when the MouseMove event is fired
       * @param {Object} posIn - Coordiantes of the current position of the pointer
       */
      function updateDrag(posIn) {
        setposNow({ x: posIn.x, y: posIn.y });
        let posRect = reverse(posStart, posNow);
        r2.current.x(posRect.x1);
        r2.current.y(posRect.y1);
        r2.current.width(posRect.x2 - posRect.x1);
        r2.current.height(posRect.y2 - posRect.y1);
        r2.current.visible(true);
        grp.current.clip({
          x: posRect.x1,
          y: posRect.y1,
          width: posRect.x2 - posRect.x1,
          height: posRect.y2 - posRect.y1
        });
        grp.current.add(i);
        i.image(img);
        i.width(img.width);
        i.height(img.height);
        i.opacity(1);
      }
    
      /**
       * Reverse coordinates if dragged left or up
       * @param {Object} r1 - Coordinates of the starting position of cropping rectangle
       * @param {Object} r2 - Coordinates of the current position of cropping rectangle
       */
      function reverse(r1, r2) {
        let r1x = r1.x,
          r1y = r1.y,
          r2x = r2.x,
          r2y = r2.y,
          d;
        if (r1x > r2x) {
          d = Math.abs(r1x - r2x);
          r1x = r2x;
          r2x = r1x + d;
        }
        if (r1y > r2y) {
          d = Math.abs(r1y - r2y);
          r1y = r2y;
          r2y = r1y + d;
        }
        return { x1: r1x, y1: r1y, x2: r2x, y2: r2y }; // return the corrected rect.
      }
    
      /**
       * Crops the image and saves it in jpeg format
       * @param {Konva.Rect} r - Ref of the cropping rectangle
       */
      function setCrop(r) {
        let jpeg = new Konva.Image({
          image: img,
          x: sX,
          y: sY
        });
        jpeg.cropX(r.x());
        jpeg.cropY(r.y());
        jpeg.cropWidth(r.width() * scale);
        jpeg.cropHeight(r.height() * scale);
        jpeg.width(r.width());
        jpeg.height(r.height());
        const url = jpeg.toDataURL({ mimeType: "image/jpeg", quality: "1.0" });
        const a = document.createElement("a");
        a.href = url;
        a.download = "cropped.jpg";
        a.click();
      }
    
      // Foreground rect to capture events
      const r1 = useRef();
    
      // Cropping rect
      const r2 = useRef();
    
      const image = useRef();
      const grp = useRef();
    
      return (
        <div className="container">
          <Stage width={sW} height={sH}>
            <Layer>
              {!loading && (
                <Image
                  ref={image}
                  listening={false}
                  {...{
                    image: img,
                    x: sX,
                    y: sY
                  }}
                />
              )}
    
              <Rect
                ref={r1}
                {...{
                  x: 0,
                  y: 0,
                  width: sW,
                  height: sH,
                  fill: "white",
                  opacity: 0
                }}
                onMouseDown={function (e) {
                  setmode("drawing");
                  startDrag({ x: e.evt.layerX, y: e.evt.layerY });
                  image.current.opacity(0.2);
                }}
                onMouseMove={function (e) {
                  if (mode === "drawing") {
                    updateDrag({ x: e.evt.layerX, y: e.evt.layerY });
                  }
                }}
                onMouseUp={function (e) {
                  setmode("");
                  r2.current.visible(false);
                  setCrop(r2.current);
                  image.current.opacity(1);
                  grp.current.removeChildren(i);
                }}
              />
              <Group listening={false} ref={grp}></Group>
    
              <Rect
                ref={r2}
                listening={false}
                {...{
                  x: 0,
                  y: 0,
                  width: 0,
                  height: 0,
                  stroke: "white",
                  dash: [5, 10]
                }}
              />
            </Layer>
          </Stage>
        </div>
      );
    }
    
    render(<Cropper />, document.getElementById("root"));

    Thanks to @Vanquished Wombat for all the precious inputs. This is an adaptation of his answer here

    Demo of the above code