본문 바로가기
개발 이야기/React

React 에서 Canvas 활용하기 (feat. Typescript)

by 농개 2020. 9. 11.

React를 활용해서 canvas그림판처럼 활용해보는 예제입니다.

 

01. CRA를 이용해 프로젝트 생성

create-react-app canvas-example --typescript

--typescript를 붙여서 React + Typescript 트프로젝트를 생성합니다.

 

 

02. canvas element 추가

import React, { useRef } from 'react';
import './App.css';

interface CanvasProps {
  width: number;
  height: number;
}

function App({ width, height }: CanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  return (
    <div className="App">
      <canvas ref={canvasRef} height={height} width={width} className="canvas"/>
    </div>
  );
}

App.defaultProps = {
  width: 800,
  height: 600
};

export default App;

위와 같이 작성해 줍니다. 

React Hooks 중 하나인 useRef를 활용합니다. 이는 <canvas>와 같은 DOM노드에 쉽게 접근케 해줍니다.

그리고 width, height를 설정해줬습니다.

 

간단하게 css도 작성해봅시다.

.App {
  text-align: center;
}

.canvas {
  border-radius: 15px;
  background: lightgrey;
}

 

 

 

03. 마우스 포인터 상태 관리

canvas에 마우스로 선을 그을 때, 두가지 상태관리가 필요합니다. 색깔이나 굵기를 조작하려면 더 필요할 수 있지만..

  • mouse down시, 마우스 포인터(x, y)
  • isPainting
import React, { useRef, useState } from 'react';


interface CanvasProps {
  width: number;
  height: number;
}

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

function App({ width, height }: CanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  
  // 상태관리
  const [mousePosition, setMousePosition] = useState<Coordinate | undefined>(undefined);
  const [isPainting, setIsPainting] = useState(false);
  
  ...(중략)
  

 

위와 같이 useState를 활용해서 두개의 상태를 선언해줍니다.

 

 

 

04. drawLine

import React, { useRef, useState, useCallback } from 'react';


interface CanvasProps {
  width: number;
  height: number;
}

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

function App({ width, height }: CanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  
  // 상태관리
  const [mousePosition, setMousePosition] = useState<Coordinate | undefined>(undefined);
  const [isPainting, setIsPainting] = useState(false);
  
  // 좌표 얻는 함수
  const getCoordinates = (event: MouseEvent): Coordinate | undefined => {
    if (!canvasRef.current) {
      return;
    }

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

  // canvas에 선을 긋는 함수
  const drawLine = (originalMousePosition: Coordinate, newMousePosition: Coordinate) => {
    if (!canvasRef.current) {
      return;
    }
    const canvas: HTMLCanvasElement = canvasRef.current;
    const context = canvas.getContext('2d');

    if (context) {
      context.strokeStyle = "red";  // 선 색깔
      context.lineJoin = 'round';	// 선 끄트머리(?)
      context.lineWidth = 5;		// 선 굵기

      context.beginPath();
      context.moveTo(originalMousePosition.x, originalMousePosition.y);
      context.lineTo(newMousePosition.x, newMousePosition.y);
      context.closePath();

      context.stroke();
    }
  };

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

  const paint = useCallback(
    (event: MouseEvent) => {
      event.preventDefault();   // drag 방지
      event.stopPropagation();  // drag 방지

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

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

선을 그리는데 필요한 함수를 작성해줍니다.

  • getCoordinates(event) : MouseEvent로 부터 좌표를 얻음
  • drawLine(originPosition, newPosition) : 선을 그음
  • startPaint, paint, exitPaint : MouseEvent Listener에 사용될 callback 함수

 

 

05. EventListener 등록

컴포넌트가 마운트 되면 마우스 이벤트 리스너를 등록해줍시다.

 

import React, { useRef, useState, useCallback, useEffect } from 'react';


interface CanvasProps {
  width: number;
  height: number;
}

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

function App({ width, height }: CanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  
  
  const [mousePosition, setMousePosition] = useState<Coordinate | undefined>(undefined);
  const [isPainting, setIsPainting] = useState(false);
  
  ...(중략)
  
  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 () => {
      // Unmount 시 이벤트 리스터 제거
      canvas.removeEventListener('mousedown', startPaint);
      canvas.removeEventListener('mousemove', paint);
      canvas.removeEventListener('mouseup', exitPaint);
      canvas.removeEventListener('mouseleave', exitPaint);
    };
  }, [startPaint, paint, exitPaint]);
  
  
  ...(중략)
  

 

 

 

06. 전체 코드

import React, { useRef, useState, useCallback, useEffect } from 'react';
import './App.css';


interface CanvasProps {
  width: number;
  height: number;
}

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

function App({ width, height }: CanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

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

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

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

  const drawLine = (originalMousePosition: Coordinate, newMousePosition: Coordinate) => {
    if (!canvasRef.current) {
      return;
    }
    const canvas: HTMLCanvasElement = canvasRef.current;
    const context = canvas.getContext('2d');

    if (context) {
      context.strokeStyle = "red";
      context.lineJoin = 'round';
      context.lineWidth = 5;

      context.beginPath();
      context.moveTo(originalMousePosition.x, originalMousePosition.y);
      context.lineTo(newMousePosition.x, newMousePosition.y);
      context.closePath();

      context.stroke();
    }
  };

  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(() => {
    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 (
    <div className="App">
      <canvas ref={canvasRef} height={height} width={width} className="canvas"/>
    </div>
  );
}

App.defaultProps = {
  width: 800,
  height: 600
};

export default App;

전체코드는 위와 같습니다.

 

 

 

07. 실행 화면

잘 그려지네요!

 

 

 

08. 지우기(Clear) 기능

const clearCanvas = () => {
    if (!canvasRef.current) {
      return;
    }

    const canvas: HTMLCanvasElement = canvasRef.current;
    canvas.getContext('2d')!!.clearRect(0, 0, canvas.width, canvas.height);
}

지우기 버튼을 추가하고 싶다며 위 함수를 코드에 넣어주고 onClick 이벤트에 바인딩 해주면 됩니닷!

 

 

 

09. 모바일에서도?

모바일 환경에서 canvas에 그림 그리는 것도 됩니다.

단, 위 코드로는 canvas만 떡하니 보이고 선이 안그려질겁니다.

아래 이벤트에 대한 처리를 해주면 모바일에서도 canvas에 선을 그을수 있습니다

  • touchstart
  • touchmove
  • touchend

위 전체 코드에서 touch 이벤트에 대한 callback을 만들어 주면 됩니다. 예를들면 아래와 같이 말입니다.

...(중략)
const startTouch = useCallback((event: TouchEvent) => { // MouseEvent인터페이스를 TouchEvent로
    event.preventDefault();
    if (!canvasRef.current) {
      return;
    }
    const canvas: HTMLCanvasElement = canvasRef.current;
    var touch = event.touches[0];    // event로 부터 touch 좌표를 얻어낼수 있습니다.
    var mouseEvent = new MouseEvent("mousedown", {	
      clientX: touch.clientX,
      clientY: touch.clientY
    });
    canvas.dispatchEvent(mouseEvent); // 앞서 만든 마우스 이벤트를 디스패치해줍니다
  }, []);
    

 

github.com/kjcaway/sketchq/blob/master/src/containers/room/CanvasContainer.tsx

 

kjcaway/sketchq

sketchQ client. Contribute to kjcaway/sketchq development by creating an account on GitHub.

github.com

위 git 주소에 canvas를 활용한 응용 그림판 코드가 있습니다.