Meme's IT

[React] 마우스로 그림을 그려보자 본문

FrontEnd/React

[React] 마우스로 그림을 그려보자

Memez 2024. 3. 22. 17:58

프로젝트를 진행하면서,

그림 심리 검사를 하는 기능을 넣게 되면서 그림그리는 기능이 필요하게 되었다.

 

하고싶은건 캐치마인드처럼 웹에서도 마우스로 그릴 수 있고,

우리 프로젝트는 웹앱 기반이 될 것이기 때문에 터치로도 그릴 수 있는 기능으로 하고 싶었다.

 

프로젝트 환경

React + TypeScript


우선, 리액트를 쓰기 때문에 라이브러리가 엄청 많아서 

처음에는 라이브러리를 이용해서 그림을 그릴려고 했다.

 

 

후보 1. 이 분야에서 제일 많이 쓰는 것 같아 보이던 Fabric.js

 

Introduction to Fabric.js. Part 1. — Fabric.js Javascript Canvas Library

Introduction to Fabric.js. Part 1. Today I'd like to introduce you to Fabric.js — a powerful Javascript library that makes working with HTML5 canvas a breeze. Fabric provides a missing object model for canvas, as well as an SVG parser, layer of interacti

fabricjs.com

공식 홈페이지를 둘러보면서 이건 아닌 것 같아서 그만 뒀다.

상당히 많은 함수와 기능을 제공하는데, 내가 하려고 하는 기능은 사용자의 입력을 받아서 그리는 것이 다여서

굳이? 싶어서 배제했다. 하지만 나중에 사용자의 입력이 아니라 내가 직접 그림을 그려서 보여주고 싶다면 상당히 좋은 라이브러리인 것 같음. (객체를 만들고, 조작하고 등등)

 

후보 2. react-canvas-draw

앞에 fabric.js가 너무 내가 하려는 기능에 비해서 많은 기능이 있길래 최대한 원하는 기능만을 포함한 라이브러리를 찾다가 발견했다.

마우스로 그린 별

딱 이정도의 기능만을 원했기때문에 바로 적용해봤다.

하지만.... merge를 하고 빌드 오류가 발생했다.. 왜냐하면 현재 쓰는 react의 버전이 18인데 

해당 라이브러리는 react 18과는 호환이 안되기 때문에..

제일 최근 버전이 2년 전..

그래서 이 라이브러리는 아쉽지만 안녕

앞으로는 라이브러리를 가져올 때 버전 확인도 꼭 해야겠다.. 까는 건 쉬워도 지우는 건 은근히 귀찮더라.

 


그래서, 결론이 뭐임?

그냥 라이브러리 안 쓰고, 까짓꺼 간단한 기능인데 구현해보자

 

1. 도화지 만들기

우선 Canvas 태그를 이용해서 그림을 그리고, 해당 DOM 요소에 직접 접근해서 이벤트를 처리해야하기 때문에

React의 useRef를 이용해서 Canvas를 참조해준다.

import { useRef } from 'react'

function Draw() {
  const canvasRef = useRef<HTMLCanvasElement>(null)

  return (
    <div style={{width:'1000px', height:'1000px', border:'1px solid black'}}>
        <canvas ref={canvasRef} style={{ width:'100%', height:'100%'}}/>
    </div> 
   )
}

export default Draw

 

2. 그림 그리기

그림을 그리는 건 총 네단계로 이루어진다.

마우스의 좌표를 인식하고 →  그림 그리기 시작하면 → 해당 위치에 그림을 그려줌 → 마우스를 때면 멈춤

 

클릭 중에만 그림을 그려야하므로, useState를 이용해서 제어를 해주자

const [isDrawing, setIsDrawing] = useState<boolean>(false)

 

 

✅ 현재 마우스의 위치 정보를 얻을려면 mouseEvent를 이용하면 된다.

// 좌표 정보를 얻는 함수
const getCoordinates = (canvas :HTMLCanvasElement, event :MouseEvent) => {
  const mouseEvent = event as MouseEvent;
  return {
    offsetX: mouseEvent.offsetX,
    offsetY: mouseEvent.offsetY,  
  }
};

 

 

  다음으로 그리기를 시작하는 함수는 콜백함수를 이용해주자

  const startDrawing = useCallback((e: MouseEvent): void => {
  	// 캔버스를 가져옴
    const canvas = canvasRef.current;
    if (!canvas) return;
    
    // 2D 그림을 그리기 위한 컨텍스트를 가져옴
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    // 경로 시작
    ctx.beginPath();
    
    // 아까 가져온 좌표로 위치시킴
    const { offsetX, offsetY } = getCoordinates(canvas, e);
    ctx.moveTo(offsetX, offsetY);
    setIsDrawing(true); // 그리는 중임을 알림
  }, []);

여기서 컨텍스트는 canvas에서 그림을 그릴 때 사용되는 환경 / 영역으로 여기에 그림을 그릴 수 있게 해준다.

 

  이제 그려보자

// 그려줌
const draw = useCallback((e: MouseEvent) => {
  // 이미 그리는 중이면 ㄴㄴ
  if (!isDrawing) return;
  
  // 캔버스와 컨텍스트 가져오기
  const canvas = canvasRef.current;
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  
  // 위치 가져와서 해당 부분에 그림 그리자
  const { offsetX, offsetY } = getCoordinates(canvas, e);
  ctx.lineTo(offsetX, offsetY);
  ctx.stroke();
  ctx.strokeStyle = 'black'
  }, [isDrawing]);

 

  마지막으로 그만 그리면 멈춰줄 함수

const stopDrawing = useCallback(() => {
  // 캔버스, 컨텍스트를 가져와서
  const canvas = canvasRef.current;
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  // 끝내주자
  if (ctx) {
    ctx.closePath();
  }
  // 그림 그리기가 끝났다는 걸 알림
  setIsDrawing(false);
}, []);

 

 

 

그래서 얘네를 어떻게 쓸꺼냐?

  react의 useEffect를 써서 그리자

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

  // 캔버스 크기를 맞춰주자
  if (canvas.width !== canvas.offsetWidth || canvas.height !== canvas.offsetHeight) {
    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;
  }
  
  // 이벤트 리스너를 추가, 제거하기 위한 함수
  // 이벤트에 따라서 우리 함수를 붙여주거나, 제거해줌
  const handleEvent = (event: string, listener: (e: MouseEvent) => void, method: 'add' | 'remove') => {
    canvas[`${method}EventListener`](event, listener as EventListener);
  };

  // 각각의 상황에 이벤트 추가
  handleEvent('mousedown', startDrawing, 'add');
  handleEvent('mousemove', draw, 'add');
  handleEvent('mouseup', stopDrawing, 'add');
  handleEvent('mouseout', stopDrawing, 'add');

  return () => {
    // 각각의 상황에 이벤트 제거
    handleEvent('mousedown', startDrawing, 'remove');
    handleEvent('mousemove', draw, 'remove');
    handleEvent('mouseup', stopDrawing, 'remove');
    handleEvent('mouseout', stopDrawing, 'remove');
  };
}, [startDrawing, draw, stopDrawing]);
// 시작할 때, 그릴 때, 끝날때마다 실행

 

 

결과

 

 

 

근데 마우스로만 그려지고, 앱 화면에서는 그려지지 않음

여기선 안됨ㅜㅜ

더보기

전체코드

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

function Draw() {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [isDrawing, setIsDrawing] = useState<boolean>(false)

  // 좌표 정보를 얻는 함수
const getCoordinates = (canvas :HTMLCanvasElement, event :MouseEvent) => {
    const mouseEvent = event as MouseEvent;
    return {
      offsetX: mouseEvent.offsetX,
      offsetY: mouseEvent.offsetY,  
    }
  };

  const startDrawing = useCallback((e: MouseEvent): void => {
    // 캔버스를 가져옴
  const canvas = canvasRef.current;
  if (!canvas) return;
  
  // 2D 그림을 그리기 위한 컨텍스트를 가져옴
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  // 경로 시작
  ctx.beginPath();
  
  // 아까 가져온 좌표로 위치시킴
  const { offsetX, offsetY } = getCoordinates(canvas, e);
  ctx.moveTo(offsetX, offsetY);
  setIsDrawing(true); // 그리는 중임을 알림
}, []);

// 그려줌
const draw = useCallback((e: MouseEvent) => {
    // 이미 그리는 중이면 ㄴㄴ
    if (!isDrawing) return;
    
    // 캔버스와 컨텍스트 가져오기
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    
    // 위치 가져와서 해당 부분에 그림 그리자
    const { offsetX, offsetY } = getCoordinates(canvas, e);
    ctx.lineTo(offsetX, offsetY);
    ctx.stroke();
    ctx.strokeStyle = 'black'
    }, [isDrawing]);

    const stopDrawing = useCallback(() => {
        // 캔버스, 컨텍스트를 가져와서
        const canvas = canvasRef.current;
        if (!canvas) return;
        const ctx = canvas.getContext('2d');
        // 끝내주자
        if (ctx) {
          ctx.closePath();
        }
        // 그림 그리기가 끝났다는 걸 알림
        setIsDrawing(false);
      }, []);


      useEffect(() => {
        const canvas = canvasRef.current;
        if (!canvas) return;
      
        // 캔버스 크기를 맞춰주자
        if (canvas.width !== canvas.offsetWidth || canvas.height !== canvas.offsetHeight) {
          canvas.width = canvas.offsetWidth;
          canvas.height = canvas.offsetHeight;
        }
        
        // 이벤트 리스너를 추가, 제거하기 위한 함수
        // 이벤트에 따라서 우리 함수를 붙여주거나, 제거해줌
        const handleEvent = (event: string, listener: (e: MouseEvent) => void, method: 'add' | 'remove') => {
          canvas[`${method}EventListener`](event, listener as EventListener);
        };
      
        // 각각의 상황에 이벤트 추가
        handleEvent('mousedown', startDrawing, 'add');
        handleEvent('mousemove', draw, 'add');
        handleEvent('mouseup', stopDrawing, 'add');
        handleEvent('mouseout', stopDrawing, 'add');
      
        return () => {
          // 각각의 상황에 이벤트 제거
          handleEvent('mousedown', startDrawing, 'remove');
          handleEvent('mousemove', draw, 'remove');
          handleEvent('mouseup', stopDrawing, 'remove');
          handleEvent('mouseout', stopDrawing, 'remove');
        };
      }, [startDrawing, draw, stopDrawing]);
      // 시작할 때, 그릴 때, 끝날때마다 실행

  return (
    <div style={{width:'50%', height:'50%', border:'1px solid black'}} >
        <canvas ref={canvasRef}/>
    </div>
    
  )
}

export default Draw

 


모바일 환경에서도 하려면?(터치도 해결하려면)

간단하게 MouseEvent 부분에 TouchEvent들도 다 추가해주자

 

많이 다른 점은 우선 좌표를 가져올 때, 상대적인 좌표를 가져오기 위해서 계산을 좀 더 해준다.

const getCoordinates = (canvas :HTMLCanvasElement, event :MouseEvent|TouchEvent) => {
  if ('touches' in event) {
    // 터치 이벤트의 경우
    const rect = canvas.getBoundingClientRect();
    return {
      offsetX: event.touches[0].clientX - rect.left,
      offsetY: event.touches[0].clientY - rect.top,
    };
  } else {
    // 마우스 이벤트의 경우
    const mouseEvent = event as MouseEvent;
    return {
      offsetX: mouseEvent.offsetX,
      offsetY: mouseEvent.offsetY,
    };
  }
};

 

 

그리고 그림 그리기 시작하는 startDrawing 함수와 draw함수에서는 

const startDrawing = useCallback((e: MouseEvent | TouchEvent): void => {
    // 터치할 때 화면스크롤 안되도록
    e.preventDefault();
... 이하 같음

터치할 때에는 스크롤 방지를 해줌

 

그리고 useEffect에서도 이벤트 리스너도 따로 다 추가해주자

// 터치 이벤트 리스너 추가
handleEvent('touchstart', startDrawing, 'add');
handleEvent('touchmove', draw, 'add');
handleEvent('touchend', stopDrawing, 'add');
handleEvent('touchcancel', stopDrawing, 'add');

제거도 똑같이 해주면 댐!