본문 바로가기

Project

[Project / React] 리액트로 그림판 만들기 #1

728x90

Canvas를 활용하여 선과 도형을 그릴 수 있는 간단한 그리기 앱을 만들어 보고자 합니다. 공식 문서와 여러 포스팅을 참고하여 만든 것이므로, 최적의 방법은 아닐 수 있습니다!

 

우선 canvas에 대해 간단하게 알아보고 가겠습니다. 캔버스 튜토리얼에 따르면, canvas는 Javascript를 통해 그래픽을 사용할 수 있는 HTML요소 라고 합니다. 그래프를 그리거나, 사진을 결합하거나 간단한 애니메이션을 만드는데에도 사용할 수 있습니다.

 

 

Canvas tutorial - Web APIs | MDN

This tutorial describes how to use the <canvas> element to draw 2D graphics, starting with the basics. The examples provided should give you some clear ideas about what you can do with canvas, and will provide code snippets that may get you started in buil

developer.mozilla.org

 

React에서 Canvas 다루기

React에서 canvas요소에 접근할 때는, useRef Hook을 사용하여 접근할 수 있습니다. 리액트에서는 DOM에 접근하여 조작할 때 자주 사용되는 Hook입니다.

 

 

[React] useRef Hook의 이해와 활용

useRef ? React 컴포넌트는 기본적으로 내부 상태(state)가 변할 때마다 다시 렌더링이 됩니다. 대부분의 경우에는 상태가 변할 때마다 React 컴포넌트가 함수가 호출되어 화면이 갱신되기를 바랍니다.

doricoding.tistory.com

 

const MyComponent = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = 600;
    canvas.height = 800;

    const context = canvas.getContext("2d");
    if (!context) return;
    context.fillStyle = "#FFFFFF"; // 사각형 영역을 채울 색상을 설정
    context.fillRect(0, 0, 600, 800); // 사각형을 그리기 시작할 시작점의 x,y좌표와 너비 높이를 설정
    context.strokeStyle = "black"; // 색상
    context.lineWidth = 2.5; // 굵기
  }, []);

  return (
    <canvas
      ref={canvasRef}
      style={{
        borderRadius: "15px",
      }}
    />
  );
};

export default MyComponent;

 

위와 같이 useRef 함수를 사용해 Ref객체를 만들고 canvas 태그의 ref값으로 설정하였습니다. 그리고 useEffect를 사용해 canvas의 기본 값들을 설정해 주었습니다. (width, height)

 

getContext()메서드를 이용해서, 렌더링 컨텍스트의 그리기 함수를 사용할 수 있습니다. 2D 그래픽일 경우에는 "2d"로 지정하고, 각 속성을 설정해주었습니다.

 

  • fillStyle : 사각형 영역을 채울 색상을 설정
  • fillRect(0, 0, 600, 800) : 사각형을 그리기 시작할 시작점의 x,y좌표와 너비 높이를 설정
  • strokeStyle : 선의 색상
  • lineWidth : 선의 굵기

 

기본적인 canvas 설정을 마치고 이제 마우스를 사용하여 그림을 그릴 수 있도록 하기 위하여 현재 마우스의 좌표와 paint하고 있는지 여부에 대한 state를 만들어 주겠습니다.

 

 

interface Coordinate {
  x: number;
  y: number;
}

const MyComponent = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const [mousePosition, setMousePosition] = useState<Coordinate>();
  const [isPainting, setIsPainting] = useState<boolean>(false);

  const startPaint = useCallback((event: MouseEvent) => {}, []);

  const paint = useCallback((event: MouseEvent) => {},[]);

  const exitPaint = useCallback(() => {}, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = 600;
    canvas.height = 800;

    const context = canvas.getContext("2d");
    if (!context) return;
    context.fillStyle = "#FFFFFF"; // 사각형 영역을 채울 색상을 설정
    context.fillRect(0, 0, 600, 800); // 사각형을 그리기 시작할 시작점의 x,y좌표와 너비 높이를 설정
    context.strokeStyle = "black"; // 색상
    context.lineWidth = 2.5; // 굵기
  }, []);
  
   useEffect(() => {
    if (!canvasRef.current) {
      return;
    }
    const canvas: HTMLCanvasElement = canvasRef.current;

    canvas.addEventListener("mousedown", startPaint);
    canvas.addEventListener("mousemove", paint);
    canvas.addEventListener("mouseup", exitPaint);
    canvas.addEventListener("mouseleave", exitPaint);

    return () => {
      canvas.removeEventListener("mousedown", startPaint);
      canvas.removeEventListener("mousemove", paint);
      canvas.removeEventListener("mouseup", exitPaint);
      canvas.removeEventListener("mouseleave", exitPaint);
    };
  }, [startPaint, paint, exitPaint]);


  return (
    <canvas
      ref={canvasRef}
      style={{
        borderRadius: "15px",
      }}
    />
  );
};

export default MyComponent;

마우스의 현재 좌표를 x, y로 저장하기 위한 mousePosition과 painting 여부를 확인하기 위한 isPainting state를 만들어 주었습니다. 그리고 마우스 이벤트를 Listen할 수 있도록 canvas 요소에 addEventListener를 붙여주었습니다.

 

컴퓨터로 그림을 그릴 때 마우스 동작을 생각해본다면, 마우스를 누를 때 그리기를 시작하고, 누르면서 움직일 때 마우스를 따라 선이 만들어지고, 마우스를 떼거나 그림판 밖으로 나갔을 경우 그리기가 멈춥니다. 해당 동작에 맞게 함수를 붙여주고, 비어있는 함수를 채워보겠습니다.

 

1. 마우스를 누른다 -> 그리기를 시작한다.

그리기를 시작한다면 먼저 현재 좌표를 state에 저장해야 합니다. 그러기 위해서는 MouseEvent에서 현재 좌표를 불러올 수 있는 함수인 getCoordinates를 만들어 주며 관심사를 분리해 주었습니다. 그리고 isPainting 변수를 true로 표시합니다.

  const getCoordinates = (event: MouseEvent) => {
    if (!canvasRef.current) return;

    const canvas = canvasRef.current;
    return {
      x: event.pageX - canvas.offsetLeft,
      y: event.pageY - canvas.offsetTop,
    };
  };  
  
  const startPaint = useCallback((event: MouseEvent) => {
    const coordinates = getCoordinates(event);
    if (coordinates) {
      setIsPainting(true);
      setMousePosition(coordinates);
    }
  }, []);

MouseEvent의 pageX와 pageY속성은 캔버스의 요소 뿐만 아니라 전체 페이지의 왼쪽 상단 모서리를 기준으로 상대적인 마우스 포인터 위치를 나타내기 때문에 캔버스 요소의 offsetLeft와 offsetTop을 빼줌으로서 캔버스 요소의 왼쪽 상단 모서리를 기준으로 마우스 포인터의 올바른 좌표를 얻을 수 있습니다.

 

2. 마우스를 누르고 이동한다 -> 선이 그려진다.

마우스가 움직일 때 마다 작동하는 함수 이기 때문에, if문을 통해 누르고 있는 경우에만 작동하도록 조건문을 걸어주었습니다. 또한 드래그와 같은 부작용을 방지하기 위해 preventDefault()를 설정해주었습니다.

 

현재 저장된 마우스 위치와 이동하면서 바뀌는 마우스의 위치를 실시간으로 저장하고 비교하면서 선을 그려주는 함수를 호출합니다. 그리고 이동이 된 위치값을 mousePosition 변수에 갱신해줍니다.

  const drawLine = (
    mousePosition: Coordinate,
    newMousePosition: Coordinate
  ) => {
    if (!canvasRef.current) return;

    const canvas: HTMLCanvasElement = canvasRef.current;
    const context = canvas.getContext("2d");

    if (!context) return;

    context.beginPath(); // 그리기 시작
    context.moveTo(mousePosition.x, mousePosition.y); // 선이 시작되는 좌표
    context.lineTo(newMousePosition.x, newMousePosition.y); // 선이 끝나는 좌표
    context.stroke(); // 선 그리기 시작함

    context.closePath(); // 그리기 끝
  };  
  
  const paint = useCallback(
    (event: MouseEvent) => {
      event.preventDefault();

      if (isPainting) {
        const newMousePosition = getCoordinates(event);
        if (mousePosition && newMousePosition) {
          drawLine(mousePosition, newMousePosition);
          setMousePosition(newMousePosition);
        }
      }
    },
    [isPainting, mousePosition]
  );

 

drawLine 함수에서는 현재 마우스의 위치와 새롭게 움직인 마우스를 인자로 받아 getContext의 메서드를 통해 canvas에 선을 그려주도록 합니다.

  • beginPath() : 그리기를 시작하는 함수
  • moveTo(x, y): 선이 시작되는 좌표
  • lineTo(x, y): 선이 끝나는 좌표
  • stroke() : 선 그리기를 시작
  • closePath() : 그리기를 끝냄

3. 마우스를 뗀다 -> 그리기를 끝낸다.

마우스를 떼는 이벤트에 isPainting 변수를 false로 바꿈으로써 현재 그리기가 멈추었음을 표시할 수 있습니다.

  const exitPaint = useCallback(() => {
    setIsPainting(false);
  }, []);

 

최종 코드

import {useCallback, useEffect, useRef, useState} from "react";

interface Coordinate {
  x: number;
  y: number;
}

const MyComponent = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  const [mousePosition, setMousePosition] = useState<Coordinate>();
  const [isPainting, setIsPainting] = useState<boolean>(false);

  const getCoordinates = (event: MouseEvent) => {
    if (!canvasRef.current) return;

    const canvas = canvasRef.current;
    return {
      x: event.pageX - canvas.offsetLeft,
      y: event.pageY - canvas.offsetTop,
    };
  };

  const drawLine = (
    mousePosition: Coordinate,
    newMousePosition: Coordinate
  ) => {
    if (!canvasRef.current) return;

    const canvas: HTMLCanvasElement = canvasRef.current;
    const context = canvas.getContext("2d");

    if (!context) return;

    context.beginPath(); // 그리기 시작
    context.moveTo(mousePosition.x, mousePosition.y); // 선이 시작되는 좌표
    context.lineTo(newMousePosition.x, newMousePosition.y); // 선이 끝나는 좌표
    context.stroke(); // 선 그리기 시작함

    context.closePath(); // 그리기 끝
  };

  const startPaint = useCallback((event: MouseEvent) => {
    const coordinates = getCoordinates(event);
    if (coordinates) {
      setIsPainting(true);
      setMousePosition(coordinates);
    }
  }, []);

  const paint = useCallback(
    (event: MouseEvent) => {
      event.preventDefault();
      event.stopPropagation();

      if (isPainting) {
        const newMousePosition = getCoordinates(event);
        if (mousePosition && newMousePosition) {
          drawLine(mousePosition, newMousePosition);
          setMousePosition(newMousePosition);
        }
      }
    },
    [isPainting, mousePosition]
  );

  const exitPaint = useCallback(() => {
    setIsPainting(false);
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.width = 600;
    canvas.height = 800;

    const context = canvas.getContext("2d");
    if (!context) return;
    context.fillStyle = "#FFFFFF"; // 사각형 영역을 채울 색상을 설정
    context.fillRect(0, 0, 600, 800); // 사각형을 그리기 시작할 시작점의 x,y좌표와 너비 높이를 설정
    context.strokeStyle = "black"; // 색상
    context.lineWidth = 2.5; // 굵기
  }, []);

  useEffect(() => {
    if (!canvasRef.current) {
      return;
    }
    const canvas: HTMLCanvasElement = canvasRef.current;

    canvas.addEventListener("mousedown", startPaint);
    canvas.addEventListener("mousemove", paint);
    canvas.addEventListener("mouseup", exitPaint);
    canvas.addEventListener("mouseleave", exitPaint);

    return () => {
      canvas.removeEventListener("mousedown", startPaint);
      canvas.removeEventListener("mousemove", paint);
      canvas.removeEventListener("mouseup", exitPaint);
      canvas.removeEventListener("mouseleave", exitPaint);
    };
  }, [startPaint, paint, exitPaint]);

  return (
    <canvas
      ref={canvasRef}
      style={{
        borderRadius: "15px",
      }}
    />
  );
};

export default MyComponent;

 

마치며

canvas 태그를 사용하여 마우스를 이용해 그림을 그릴 수 있는 기능을 만들었습니다. 다음 포스트에서는 펜의 색상과 굵기를 사용자가 변경가능하게 하고, 그림을 저장할 수 있는 기능을 만들어 보겠습니다. 감사합니다.