-
Notifications
You must be signed in to change notification settings - Fork 7
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
base: main
Are you sure you want to change the base?
Conversation
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 |
@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));
} |
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.