Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Draw image on canvas after size changes #2

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

alukach
Copy link

@alukach alukach commented Mar 27, 2024

I encountered an error where the canvas was consistently empty. I tracked it down to this bug where the canvas loses its drawn image when resized. A quick example via codepen:

The fix is to break out the drawImage step into a separate effect that runs on resize or when the image changes.

@ian-hank
Copy link

ian-hank commented Apr 9, 2024

I can confirm this is an issue, using the image you provide in the docs will allow the image to render (allowing the component to work properly). If a larger image is provided, the resize breaks the render of the image and there is nothing visible on the screen (the mask drawing itself from canvas seems to still work and resize properly).

I (for whatever reason) was unable to get this fork, or my own fork with a fix to work in my project. So, I wrote another mask editor myself to resolve the problem. Yours is cleaner though, and I would appreciate if you could look into this.

@alukach - I'm not sure that this PR will actually work from a glance since I couldn't get it to install properly (although I could be wrong). The !img variable in the second useEffect (line 74) is out of scope. It wont be able to resolve that variable as its declared in the above React.useEffect hook.

@alukach
Copy link
Author

alukach commented Apr 10, 2024

@ian-hank good catch. I rewrote a chunk of this code for my own project and renamed a few variables. I also needed access to both the mask canvas and image canvas refs. I think this should fix the scope issue you mentioned: 5c0a677.

The entirety of the component I am using, in case I missed something...
// https://github.com/la-voliere/react-mask-editor
import {
  useRef,
  useEffect,
  useState,
  useLayoutEffect,
  useCallback,
} from "react";
import "./MaskEditor.scss";

export interface MaskEditorProps {
  src: string;
  maskCanvasRef?: React.MutableRefObject<HTMLCanvasElement>;
  imageCanvasRef?: React.MutableRefObject<HTMLCanvasElement>;
  cursorSize?: number;
  onCursorSizeChange?: (size: number) => void;
  maskOpacity?: number;
  maskColor?: string;
  maskBlendMode?:
    | "normal"
    | "multiply"
    | "screen"
    | "overlay"
    | "darken"
    | "lighten"
    | "color-dodge"
    | "color-burn"
    | "hard-light"
    | "soft-light"
    | "difference"
    | "exclusion"
    | "hue"
    | "saturation"
    | "color"
    | "luminosity";
}

export const MaskEditorDefaults = {
  cursorSize: 10,
  maskOpacity: 0.75,
  maskColor: "#23272d",
  maskBlendMode: "normal",
};

export const MaskEditor: React.FC<MaskEditorProps> = (
  props: MaskEditorProps
) => {
  const src = props.src;
  const cursorSize = props.cursorSize ?? MaskEditorDefaults.cursorSize;
  const maskColor = props.maskColor ?? MaskEditorDefaults.maskColor;
  const maskBlendMode = props.maskBlendMode ?? MaskEditorDefaults.maskBlendMode;
  const maskOpacity = props.maskOpacity ?? MaskEditorDefaults.maskOpacity;

  const canvas = useRef<HTMLCanvasElement | null>(null);
  const maskCanvas = useRef<HTMLCanvasElement | null>(null);
  const cursorCanvas = useRef<HTMLCanvasElement | null>(null);
  const [context, setContext] = useState<CanvasRenderingContext2D | null>(null);
  const [maskContext, setMaskContext] =
    useState<CanvasRenderingContext2D | null>(null);
  const [cursorContext, setCursorContext] =
    useState<CanvasRenderingContext2D | null>(null);
  const [size, setSize] = useState<{ x: number; y: number }>({
    x: 256,
    y: 256,
  });

  useLayoutEffect(() => {
    if (canvas.current && !context) {
      const ctx = (canvas.current as HTMLCanvasElement).getContext("2d");
      setContext(ctx);
    }
  }, [canvas]);

  useLayoutEffect(() => {
    if (maskCanvas.current && !context) {
      const ctx = (maskCanvas.current as HTMLCanvasElement).getContext("2d");
      if (ctx) {
        ctx.fillStyle = "#ffffff";
        ctx.fillRect(0, 0, size.x, size.y);
      }
      setMaskContext(ctx);
    }
  }, [maskCanvas]);

  useLayoutEffect(() => {
    if (cursorCanvas.current && !context) {
      const ctx = (cursorCanvas.current as HTMLCanvasElement).getContext("2d");
      setCursorContext(ctx);
    }
  }, [cursorCanvas]);

  /** CUSTOM: Disabled this...
  useEffect(() => {
    if (src && context) {
      const img = new Image;
      img.onload = evt => {
        setSize({x: img.width, y: img.height});
        context?.drawImage(img, 0, 0);
      }
      img.src = src;
    }
  }, [src, context]);
  */

  // CUSTOM: Instead, do this...
  const [image, setImage] = useState<HTMLImageElement>();
  useEffect(() => {
    const img = new Image();
    img.onload = (evt) => {
      setSize({ x: img.width, y: img.height });
      context?.drawImage(img, 0, 0);
    };
    img.src = src;
    setImage(img);
  }, [src]);

  useEffect(() => {
    context?.drawImage(image!, 0, 0);
  }, [size, image]);

  // Pass image canvas up
  useLayoutEffect(() => {
    if (props.imageCanvasRef) {
      props.imageCanvasRef.current = canvas.current!;
    }
  }, [canvas]);
  // CUSTOM ends.

  // Pass mask canvas up
  useLayoutEffect(() => {
    if (props.maskCanvasRef) {
      props.maskCanvasRef.current = maskCanvas.current!;
    }
  }, [maskCanvas]);

  useEffect(() => {
    const listener = (evt: MouseEvent) => {
      if (cursorContext) {
        cursorContext.clearRect(0, 0, size.x, size.y);

        cursorContext.beginPath();
        cursorContext.fillStyle = `${maskColor}88`;
        cursorContext.strokeStyle = maskColor;
        cursorContext.arc(evt.offsetX, evt.offsetY, cursorSize, 0, 360);
        cursorContext.fill();
        cursorContext.stroke();
      }
      if (maskContext && evt.buttons > 0) {
        maskContext.beginPath();
        maskContext.fillStyle =
          evt.buttons > 1 || evt.shiftKey ? "#ffffff" : maskColor;
        maskContext.arc(evt.offsetX, evt.offsetY, cursorSize, 0, 360);
        maskContext.fill();
      }
    };
    const scrollListener = (evt: WheelEvent) => {
      if (cursorContext) {
        props.onCursorSizeChange(
          Math.max(0, cursorSize + (evt.deltaY > 0 ? 1 : -1))
        );

        cursorContext.clearRect(0, 0, size.x, size.y);

        cursorContext.beginPath();
        cursorContext.fillStyle = `${maskColor}88`;
        cursorContext.strokeStyle = maskColor;
        cursorContext.arc(evt.offsetX, evt.offsetY, cursorSize, 0, 360);
        cursorContext.fill();
        cursorContext.stroke();

        evt.stopPropagation();
        evt.preventDefault();
      }
    };

    cursorCanvas.current?.addEventListener("mousemove", listener);
    if (props.onCursorSizeChange) {
      cursorCanvas.current?.addEventListener("wheel", scrollListener);
    }
    return () => {
      cursorCanvas.current?.removeEventListener("mousemove", listener);
      if (props.onCursorSizeChange) {
        cursorCanvas.current?.removeEventListener("wheel", scrollListener);
      }
    };
  }, [cursorContext, maskContext, cursorCanvas, cursorSize, maskColor, size]);

  const replaceMaskColor = useCallback(
    (hexColor: string, invert: boolean) => {
      const imageData = maskContext?.getImageData(0, 0, size.x, size.y);
      const color = hexToRgb(hexColor);
      if (imageData) {
        for (var i = 0; i < imageData?.data.length; i += 4) {
          const pixelColor =
            (imageData.data[i] === 255) != invert ? [255, 255, 255] : color!;
          imageData.data[i] = pixelColor[0];
          imageData.data[i + 1] = pixelColor[1];
          imageData.data[i + 2] = pixelColor[2];
          imageData.data[i + 3] = imageData.data[i + 3];
        }
        maskContext?.putImageData(imageData, 0, 0);
      }
    },
    [maskContext]
  );
  useEffect(() => replaceMaskColor(maskColor, false), [maskColor]);

  return (
    <div className="react-mask-editor-outer">
      <div
        className="react-mask-editor-inner"
        style={{
          width: size.x,
          height: size.y,
        }}
      >
        <canvas
          ref={canvas}
          style={{
            width: size.x,
            height: size.y,
          }}
          width={size.x}
          height={size.y}
          className="react-mask-editor-base-canvas"
        />
        <canvas
          ref={maskCanvas}
          width={size.x}
          height={size.y}
          style={{
            width: size.x,
            height: size.y,
            opacity: maskOpacity,
            mixBlendMode: maskBlendMode as any,
          }}
          className="react-mask-editor-mask-canvas"
        />
        <canvas
          ref={cursorCanvas}
          width={size.x}
          height={size.y}
          style={{
            width: size.x,
            height: size.y,
          }}
          className="react-mask-editor-cursor-canvas"
        />
      </div>
    </div>
  );
};

export function toMask(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext("2d");
  const size = {
    x: canvas.width,
    y: canvas.height,
  };

  const imageData = ctx?.getImageData(0, 0, size.x, size.y);
  const origData = Uint8ClampedArray.from(imageData!.data!);

  if (imageData) {
    for (var i = 0; i < imageData.data.length; i += 4) {
      const pixelColor =
        imageData.data[i] === 255 ? [255, 255, 255] : [0, 0, 0];
      imageData.data[i] = pixelColor[0];
      imageData.data[i + 1] = pixelColor[1];
      imageData.data[i + 2] = pixelColor[2];
      imageData.data[i + 3] = 255;
    }
    ctx?.putImageData(imageData, 0, 0);
  }

  if (!imageData) throw new Error("No image data");

  const dataUrl = canvas.toDataURL();
  for (var i = 0; i < imageData.data.length!; i++) {
    imageData.data[i] = origData[i];
  }
  ctx!.putImageData(imageData, 0, 0);

  return dataUrl;
}

export async function toMaskBlob(canvas: HTMLCanvasElement): Promise<Blob> {
  const ctx = canvas.getContext("2d");
  const size = {
    x: canvas.width,
    y: canvas.height,
  };

  // Read image
  const imageData = ctx?.getImageData(0, 0, size.x, size.y);

  // Backup image data
  const origData = Uint8ClampedArray.from(imageData!.data!);

  // Convert image to mask
  if (imageData) {
    for (var i = 0; i < imageData.data.length; i += 4) {
      const pixelColor =
        imageData.data[i] === 255 ? [255, 255, 255] : [0, 0, 0];
      imageData.data[i] = pixelColor[0];
      imageData.data[i + 1] = pixelColor[1];
      imageData.data[i + 2] = pixelColor[2];
      imageData.data[i + 3] = 255;
    }
    ctx?.putImageData(imageData, 0, 0);
  } else {
    throw new Error("No image data");
  }

  // Convert mask to blob
  const blob = await new Promise<Blob | null>((resolve) =>
    canvas.toBlob(resolve)
  );

  // Restore image data
  for (var i = 0; i < imageData.data.length!; i++) {
    imageData.data[i] = origData[i];
  }
  ctx!.putImageData(imageData, 0, 0);

  return blob!;
}

function hexToRgb(color: string) {
  var parts = color.replace("#", "").match(/.{1,2}/g);
  return parts?.map((part) => parseInt(part, 16));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants