Canvas에 대해 들어보셨는지요

24-06-11  |  Canvas

📌 Canvas! Canvas! Canvas!

1. Canvas 라이브러리

1.0.0-beta1.0.1 버전까지는, Fabric.js를 사용하였다. Fabric.js가 무엇이고 왜 사용해야 했는지에 대해 우선 설명해보겠다.

Fabric.js?

Fabric.js는 "Fabric.js is a powerful and simple Javascript HTML5 canvas library"이라고 공식 홈페이지에서 설명하듯이, canvas 라이브러리이다.

왜 Canvas 라이브러리가 필요해?

HTML에는 Canvas API가 존재한다. 해당 자료에서도 살펴볼 수 있듯이, Canvas API 자체로 캔버스 기능을 구현하는데는 문제가 없다.

HTML5 canvas, on the other hand, is like a TV screen set into your web page. You can do almost anything inside this screen with amazing speed, but the TV is separated from the rest of the page. The canvas does not use standard HTML elements, so you can’t, for example, put an input box inside your canvas. You would have to write any interface elements yourself or put them outside (or potentially on top of) the canvas.

위 내용에서 말하듯이, Canva API를 사용하면 상호작용하는 기능을 넣기 힘들다는 것이다.

나작길 프로젝트를 예로 들어서 설명해보면, 텍스트를 작성할 수 있는 기능이 있다. Fabric.js를 사용하면 아래 이미지처럼, 텍스트를 클릭하고 해당 텍스트를 바로 수정할 수가 있다.

사진_text_fabirc

하지만 Canvas API는 해당 기능처럼 구현이 어렵다. Canvas API는 a low-level graphics API that gives you full control over drawing operations이기 때문에, 개발자가 직접 해당 기능을 구현을 해야하는 것이다.

사진_text_canvasapi

정리해보자면, Canvas API는 low-level API이기 때문에, 선택한 텍스트를 바로 수정하는 기능 같은 걸 따로 제공해주지 않고 개발자가 직접 구현을 해야한다. 하지만 이는 생각보다 시간이 오래 소요되는 작업이고, 다양한 라이브러리에서 이미 기능을 제공하고 있기 때문에 라이브러리 사용이 좋은 선택일 수도 있다.

Canvas 라이브러리에는 무엇이 있을까?

사진_npm-trends

Canvas 관련 라이브러리로는, Fabric.js 외에도 Konva, Pixi 등이 있다. npm trens를 살펴보면(24.06.11 기준), Konva, Fabric.js, Pixi 순으로 되어있음을 확인이 가능하다.

하지만 npm trends 외 자료들을 찾아보면, 거의 Fabric.js VS Konva.js 양자구도로 보였다.

나작길의 모티브인 개발진스를 보면 Fabric.js를 사용하고 있었기에, Fabric.js를 채택하여 사용하였다.

2. Fabric.js를 버렸습니다.

하지만 Fabric.js를 사용하는 것은 좀처럼 쉬운 일이 아니었다.

모듈 'fabric'에 대한 선언 파일을 찾을 수 없습니다. '/Users/minselim/Desktop/my-gachon-president 복사본/node_modules/fabric/dist/fabric.js'에는 암시적으로 'any' 형식이 포함됩니다.
해당 항목이 있는 경우 'npm i --save-dev @types/fabric'을(를) 시도하거나, 'declare module 'fabric';'을(를) 포함하는 새 선언(.d.ts) 파일 추가

npm i --save-dev @types/fabric을 해주었음에도, 위 에러가 계속 발생하였다.

무엇보다 공식문서를 읽는 것이 힘들었다. 사진_공식문서

개발 실력이 부족한 이유가 컸겠지만, 당시에는 type 에러와 공식문서를 읽기 힘든게 너무 컸다. 그래서 이럴바에 라이브러리 사용하지 말고 Canvas API만을 사용해서 캔버스를 구현해보자는 오기가 발동한 거 같다.

3. 라이브러리 없이 Canvas 구현하기

나작길 기능 톺아보기

  1. 캐릭터 선택 사진_캐릭터 겨울 외투, 바캉스룩 등 다양한 버전의 나작길 캐릭터들이 있다. 따라서 유저가 원하는 캐릭터를 선택하면 캔버스에 보이게 하는 기능이다.

  2. 꾸미기 > 텍스트 작성 사진_꾸미기>텍스트1 사진_꾸미기>텍스트2 텍스트를 입력한 후, '추가하기' 버튼을 클릭하면 캔버스테 텍스트가 추가되었다. 또한 텍스트를 클릭하면, 해당 텍스트를 수정할 수 있는 화면이 노출되게 하였다. 삭제 또한 가능하다.

  3. 꾸미기 > 스티커 선택 사진_꾸미기>스티커 스티커를 선택하면, 캔버스에 해당 스티커가 보인다. 스티커를 클릭하면, 삭제 버튼 클릭 시 해당 스티커를 캔버스에서 없앨 수도 있다.

  4. 꾸미기 > 사진 선택 사진_꾸미기>사진 유저가 앨범에서 선택한 사진을 캔버스에 보이게 하는 기능이다. 다른 기능과 동일하게, 사진을 선택하면 삭제도 가능하게 하였다. 하지만 해당 기능은 완벽하게 구현을 하지 못했다.

  5. 꾸미기 > 펜 그리기 사진_꾸미기>펜 펜 색상과 굵기를 선택하여 그림을 그릴 수 있다. 이때 '이전'을 클릭하면, 이전 단계로 돌아간다. 이전 버튼을 두 번 클릭해야 이전 기능이 동작한다

  6. 꾸미기 초기화 버튼 꾸미기 선택 태그들 옆 제일 우측에 존재하는 클릭하면 모든 꾸미기 상태를 초기화한다.

  7. 배경 > 색상 사진_꾸미기>배경색상 배경 색상을 선택하면, 해당 색상으로 배경이 변경된다.

  8. 배경 > 이미지 사진_꾸미기>배경이미지 유저가 선택한 이미지가, 배경 이미지가 된다.

  9. 배경 초기화 버튼 배경 관련 태그들 옆 제일 우측에 존재하는 클릭하면 모든 배경 상태를 초기화한다.

모든 캔버스 요소들은 드래그 앤 드랍이 가능하지만, 크기 조절 및 줌 등의 기능은 불가능하였다.

나작길 코드 톺아보기

위에 구현한 기능들을 나열해 보았다. 그리고 제대로 구현이 안 된 기능 역시 언급하였다. 따라서 make 폴더 내 index.tsx 파일의 코드를 보면서 어떤 코드를 사용하여 구현했는지 살펴보고자 한다.

1) store

  // 탭
  const { activeTab, setActiveTab } = useTabStore();
  // 태그
  const { activeDecorationTag } = useDecorationTabStore();
  // 캐릭터
  const { activeCharacter } = useCharacterTabStore();
  // 배경화면
  const { activeBackgroundColor, activeBackgroundImage } = useBackgroundTabStore();
  // 캔버스
  const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  // 텍스트
  const {
    textObjects,
    setTextObjects,
    inputText,
    setInputText,
    editText,
    setEditText,
    selectedTextId,
    setSelectedTextId,
    textColor,
    textSize,
  } = useTextPanelStore();
  // 스티커
  const { stickerObjects, setStickerObjects, setSelectedStickerId } = useStickerPanelStore();
  // 사진
  const { photoObjects, setPhotoObjects, setSelectedPhotoId} = usePhotoPanelStore();
  // 브러쉬
  const { brushObjects, setBrushObjects, brushColor, brushSize } = useBrushPanelStore();

'캐릭터', '꾸미기', '배경'을 탭이라고 하였고, 예를 들어 꾸미기 내에 텍스트, 사진 등을 '태그'라고 명칭하였다. 텍스트, 스티커, 사진, 펜의 경우, 이전 상태로 돌아가는 기능이 있기 때문에 별도의 store를 두었다.

props를 사용하여 구현도 가능했겠지만, 당시에는 전역상태관리를 사용하여 구현하는 것이 상대적으로 나에게 더 쉬웠기 때문에 모두 전역상태관리를 통해 상태를 관리하였다.

사진_store폴더구조 태그가 보이는 화면을 panel이라고 명칭하여, tab과 panel로 구분하여 store 폴더를 구성하였다.

2) 캔버스 초기 상태

const canvasRef = useRef<HTMLCanvasElement>(null);

우선 useRef를 사용하여 캔버스를 참조하였다. useRef는 특정 DOM 요소에 직접 접근할 수 있는 방법을 제공하는데, 컴포넌트가 재렌더링하더라도 값이 유지된다. 따라서 canvasRef를 만들어서 canvas DOM에 접근하게 하였다.

useEffect(() => {
    if (canvasRef.current) {
    setCanvas(canvasRef.current);
    }
}, []);

컴포넌트가 처음 렌더링될 때 canvas 요소에 대한 참조를 설정하고 이를 setCanvas 함수로 전달하는 역할을 수행한다.

const context = canvas.getContext('2d');
    if (!context) {
      return;
    }

    context.clearRect(0, 0, canvas.width, canvas.height);

이후 렌더링 컨텍스트를 사용해서 2D 그래픽으로 지정해주고, 컨텐츠를 생성하고 다루게 해준다.

그리고 context.clearRect를 통해 매번 캔버스를 깨끗이 지우게 하였다.

textObjects.forEach(({ text, x, y, color, font }) => {
    context.fillStyle = color;
    context.font = font;
    context.fillText(text, x, y);
});

...

brushObjects.forEach((brushObject) => {
    const { path } = brushObject;
    if (path?.length < 2) {
        return;
    }
    context.lineJoin = 'round';
    context.lineCap = 'round';
    context.strokeStyle = brushColor;
    context.lineWidth = brushSize;
    context.beginPath();
    context.moveTo(path[0].x, path[0].y);
    path.forEach((point) => {
        context.lineTo(point.x, point.y);
    });
    context.stroke();
});

캔버스를 초기화한 후, 배열에 있는 객체를 순회하면서 각각의 컨텐츠를 캔버스에 그리는 작업을 수행한다.

3) 컨텐츠(오브젝트) 추가

  // [텍스트] 텍스트 오브젝트 추가
  const handleTextButtonClick = () => {
    if (!canvas) {
      return;
    }
    if (!inputText) {
      return;
    }

    const newTextObject = {
      id: `${Date.now()}`,
      text: inputText,
      x: 50,
      y: 50,
      color: textColor,
      font: `${textSize}px Arial`,
      dragging: false,
      offsetX: 0,
      offsetY: 0,
    };

    setTextObjects([...textObjects, newTextObject]);
    setInputText('');
  };

텍스트, 스티커, 펜의 경우, handle***ButtonClick로 오브젝트를 추가하는 함수를 만들었다. 컨텐츠에 해당하는 속성을 미리 정의한 newObject를 만들어주고, store에 이전 값들에 추가해주었다.

그리고 개발하면서 🚨이슈가 있었다. 바로 지금 어느 태그가 선택되어있는지이다. 예를 들어 텍스트 태그가 선택되어있다면, 사진 기능을 사용이 불가능해야한다. 물론 컨텐츠 추가일 때는 화면 자체가 다른 기능 선택 화면이 안 보여서 작동을 안 하지만, 캔버스 내에 사진, 텍스트 등 여러 컨텐츠 등이 존재할 때면 문제가 심각해진다. 따라서 활성화된 태그에 따라 사용 가능한 함수를 정의해주었다.

<canvas
    ...
        onClick={
            activeDecorationTag === 'text'
              ? handleTextCanvasClick
              : activeDecorationTag === 'sticker'
                ? handleStickerCanvasClick
                : activeDecorationTag === 'photo'
                  ? handlePhotoCanvasClick
                  : activeDecorationTag === 'brush'
                    ? handleBrushClick
                    : undefined
        }
/>

4) 캔버스 이벤트

캔버스에서 할 수 있는건, 캔버스를 클릭(CanvasClick), 드래그(MouseDown), 이동(MouseMove), 드래그 종료(MouseUp)이 있다.

스티커로 예를 들어보자면, 아래와 같다.

먼저 캔버스 클릭이다.

const handleStickerCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
    const canvasRect = canvas?.getBoundingClientRect();
    if (!canvasRect || !canvas) {
      return;
    }

    const mouseX = event.clientX - canvasRect.left;
    const mouseY = event.clientY - canvasRect.top;

    stickerObjects.forEach((stickerObject) => {
      const { id, x, y } = stickerObject;

      if (mouseX >= x && mouseX <= x + 100 && mouseY >= y && mouseY <= y + 100) {
        setSelectedStickerId(id);
      }
    });
  };

위 코드를 쉽게 설명하면, 클릭한 좌표를 계산하고, 이 좌표가 스티커 객체의 범위 내에 있는지 확인한다. 그리고 만약 좌표가 범위 내에 있는 경우, 해당 스티커 객체의 ID를 선택된 스티커로 설정하는 것이다.

canvas?.getBoundingClientRect()로 우선 캔버스 범위를 가져온다. event.clientX - canvasRect.left를 통해 클릭 이벤트가 발생한 페이지의 X 좌표에서 canvasRect.left를 빼서, 캔버스 내에서의 클릭 좌표를 계산하다. 그리고 stickerObjects 배열을 순회하면서 mouseX와 mouseY가 해당하는 sticker가 있는지 확인하고, 만약 있다면 해당 스티커를 선택한 스티커로 지정하였다.

해당 함수는 드래그 앤 드랍을 위해서기보다는, 선택한 컨텐츠를 확인하기 위해서이다. panel을 보면 선택한 컨텐츠의 border 색상이 달라진다. 이를 위해서 선택한 컨텐츠를 해당 함수를 통해 확인하는 것이다.

이제 드래그 앤 드랍 기능 차례이다. 이때 MouseDown과 MouseMove, MouseUp의 차이에 대해 간단하게 언급해보면, MouseDown은 클릭을 한 순간으로 드래그를 준비하는 것이다. MouseMove는 드래그하는 상태로 움직이고 있는 중인 것이다. 마지막으로 MouseUp은 드래그가 끝난 것으로 새로운 위치로 업데이트가 되어야 한다.

먼저 MouseDown부터 살펴보면 아래와 같다.

  //  [스티커] 스티커 드래그
  const handleStickerMouseDown = (event: React.MouseEvent<HTMLCanvasElement>) => {
    if (!canvas) {
      return;
    }

    const canvasRect = canvas.getBoundingClientRect();
    const mouseX = event.clientX - canvasRect.left;
    const mouseY = event.clientY - canvasRect.top;

    stickerObjects.forEach((stickerObject, index) => {
      const { x, y, dragging } = stickerObject;
      if (mouseX >= x && mouseX <= x + 100 && mouseY >= y && mouseY <= y + 100 && !dragging) {
        const offsetX = mouseX - x;
        const offsetY = mouseY - y;

        const updatedStickerObjects = [...stickerObjects];
        updatedStickerObjects[index] = {
          ...updatedStickerObjects[index],
          dragging: true,
          offsetX,
          offsetY,
        };
        setStickerObjects(updatedStickerObjects);
      }
    });
  };

handleStickerCanvasClick 코드와 유사하다. 하지만 차이가 있다면 draggin:true이다. dragging을 true로 하여 드래그 가능 상태로 변경해 준 것이다.

  //  [스티커] 스티커 드래그 이동
  const handleStickerMouseMove = (event: React.MouseEvent<HTMLCanvasElement>) => {
    if (!canvas) {
      return;
    }

    const canvasRect = canvas.getBoundingClientRect();
    const mouseX = event.clientX - canvasRect.left;
    const mouseY = event.clientY - canvasRect.top;

    stickerObjects.forEach((stickerObject, index) => {
      const { dragging, offsetX, offsetY } = stickerObject;
      if (dragging) {
        const updatedStickerObjects = [...stickerObjects];
        updatedStickerObjects[index] = {
          ...updatedStickerObjects[index],
          x: mouseX - offsetX,
          y: mouseY - offsetY,
        };
        setStickerObjects(updatedStickerObjects);
      }
    });
  };

이제 MouseMove로 이동을 한다. 이때 x와 y 값을 업데이트 해주는데, 아래와 같이 x, y 그리고 offsetX, offsetY의 차이를 알 필요가 있다.

type StickerObject = {
  id: string;
  imageUrl: string;
  x: number;
  y: number;
  dragging: boolean;
  offsetX: number;
  offsetY: number;
};

x와 y는 스티커의 현재 위치를 나타내는 속성이고, offsetX와 offsetY는 스티커를 클릭했을 때 클릭 지점과 스티커 좌상단 사이의 거리를 나타내는 속성이다. handleStickerMouseDown는 드래그를 시작할 때 호출한다. 드래그를 시작할 때 클릭한 지점을 기준으로 스티커를 움직여야 하기 때문에 클릭 지점과 스티커의 상대적인 위치를 나타내는 offsetX와 offsetY를 업데이트하는 것이다. 반면에 handleStickerMouseMove는 드래그할 때 호출된다. x와 y는 스티커의 새로운 위치를 나타내기 때문에, 드래그하는 동안 계속해서 업데이트하는 것이다.

마지막으로 MouseUp이다.

  const handleStickerMouseUp = () => {
    const updatedStickerObjects = stickerObjects.map((stickerObject) => ({
      ...stickerObject,
      dragging: false,
    }));
    setStickerObjects(updatedStickerObjects);
  };

drraging:false로 하여 드래그가 끝났음을 알려준다.

위 함수들을 통해, 캔버스 내에서 드래그 앤 드랍이 가능해진다. 그리고 모든 함수는 canvas에도 이벤트로 등록해준다.

<canvas
    ...
        onMouseDown={
            activeDecorationTag === 'text'
                ? handleTextMouseDown
                : activeDecorationTag === 'sticker'
                  ? handleStickerMouseDown
                  : activeDecorationTag === 'photo'
                    ? handlePhotoMouseDown
                    : activeDecorationTag === 'brush'
                      ? handleBrushMouseDown
                      : undefined
            }
            onMouseMove={
              activeDecorationTag === 'text'
                ? handleTextMouseMove
                : activeDecorationTag === 'sticker'
                  ? handleStickerMouseMove
                  : activeDecorationTag === 'photo'
                    ? handlePhotoMouseMove
                    : activeDecorationTag === 'brush'
                      ? handleBrushMouseMove
                      : undefined
            }
            onMouseUp={
              activeDecorationTag === 'text'
                ? handleTextMouseUp
                : activeDecorationTag === 'sticker'
                  ? handleStickerMouseUp
                  : activeDecorationTag === 'photo'
                    ? handlePhotoMouseUp
                    : activeDecorationTag === 'brush'
                      ? handleBrushMouseUp
                      : undefined
            }
          />

4. 뭐가 문제일까?

해결하지 못 한 이슈들을 리스트업 해보려고 한다.

1) 터치가 안돼요...ㅠㅠㅠㅠ
제일 심각한 이슈다. 애초에 모바일 사이즈로 만들었는데, 터치가 안 된다. 위에 Touch Event 관련 함수는 제외하였는데, Touch 관련 이벤트 함수도 작성하였지만 작동을 안 했다.

2) 버벅임이 심해요
gif 놀랍게도 그냥 마우스를 움직이는 것이 아니라, 컨텐츠를 드래그하고 있는 상태이다. 드래그하는 중에 컨텐츠가 안 보이는 이슈가 발생했다.

3) 펜 > 이전 클릭을 두 번 해야 실행이 돼요

const handleBackButtonClick = () => {
    const latestBrushObject = brushObjects[brushObjects.length - 2];
    const updatedBrushObjects = brushObjects.filter(
      (brushObject) => brushObject.id !== latestBrushObject.id,
    );
    setBrushObjects(updatedBrushObjects);
    setBackSnackOpen(true);
    setTimeout(() => {
      setBackSnackOpen(false);
    }, 1000);
};

이전 버튼을 클릭하면 제일 최근 컨텐츠가 삭제되게 하였다. 그런데 제대로 작동을 안 한다.

4) 사진 업로드가 안돼요
"Application error: a client-side exception has occurred (see the browser console for more information)" 사진 업로드를 하면 위 에러가 발생하면서 웹 사이트가 꺼진다.

5) 중복된 코드가 많아요 위 드래그 앤 드랍 함수들을 통해서도 알 수 있듯이 중복된 코드가 정말 많다. 그러다보니, 코드를 작성한 나도 읽기 힘들다.

5. 그래서 나작길은 어떻게 되었을까요?

2024년 4월 25일 나작길 팀은 해체하기로 했다. 2023년 8월 18, 19일 해커톤 이후로, 작업을 진행 안 한 기간도 있지만 계속 이어오고 있었다. 짧지 않은 기간이었고 그러다보니 팀원 모두가 지치게 되었다. 그래서 2024년 4월 25일에 팀원들의 합의 한에 프로젝트 진행을 종료하기로 하였다.