개발일지/🙂 Boarlog

Fabric.js를 이용한 React 화이트보드 만들기

무딘붓 2024. 1. 21. 20:41

 

기록으로 남기는 실시간 화이트보드 강의 서비스, 🙂 Boarlog 를 개발하며 구현한 내용을 담고 있습니다.
게시글의 기능을 이용한 최종 서비스는 GitHub 링크 에서 확인하실 수 있습니다. 

 

 

 

이 게시글에서는 React 프로젝트에서 Fabric.js 라이브러리로 화이트보드(그림판)를 만드는 과정을 정리합니다.
실행 예시와 React 코드는 링크(codesandbox)에서 확인할 수 있습니다.

 

0. 왜 Fabric.js 라이브러리를 사용하나요?

우선, Fabric.js는 HTML Canvas를 쉽고 강력하게 관리할 수 있게 해주는 라이브러리 입니다.

 

가장 큰 특징은, 캔버스에 그려진 요소를 객체 형태로 관리할 수 있다는 점 입니다. 이를 이용해서 캔버스의 요소들을 선택, 이동, 회전, 크기조절 할 수 있는 기능을 기본적으로 제공합니다.

 

 

위와 같이 강력한 객체 조절 기능을 쉽게 사용할 수 있기 때문에, 화이트보드 구현에 적합하다고 판단해서 사용했습니다.

 

이 게시글에서는 fabric.Canvas 초기 설정, 펜 기능과 hand tool 구현등을 살펴보겠습니다.

 

1. fabric.Canvas 만들기

import { fabric } from "fabric";

 

우선 fabric.js를 설치한 다음 fabric을 import 합니다.

const CanvasSection = () => {
  const canvasRef = useRef(null);
  const [canvas, setCanvas] = useState(null);
	
  //...
	
  return (
    <>
      <canvas style={{ border: "1px solid red" }} ref={canvasRef} />
    </>
  );
};

export default CanvasSection;

 

useRef를 이용하여 canvasRef 를 만들고, useState 를 이용하여 canvas 를 만듭니다.

 

canvasRef 는 반환할 HTML canvas 객체에 대한 참조로 사용하고,

canvas는 Fabric.js를 사용하여 생성한 캔버스 객체의 상태를 저장하는 용도로 사용합니다.

 

이제 fabric canvas를 생성하겠습니다.

useEffect(() => {
    // 캔버스 생성
    const newCanvas = new fabric.Canvas(canvasRef.current, {
      width: 800,
      height: 400
    });
    setCanvas(newCanvas);
    // 언마운트 시 캔버스 정리
    return () => {
      newCanvas.dispose();
    };
  }, []);

 

useEffect를 이용하여 컴포넌트가 마운트될 때 캔버스를 초기화 해주는 작업을 수행합니다.

new fabric.Canvas 를 이용하여 HTML canvas를 연결해 주고, 800*400크기의 fabric.canvas를 생성합니다.

그런 다음, canvas 에 새로 생성된 newCanvas 를 연결해 줍니다.

 

컴포넌트가 언마운트 되는 경우 메모리에서 캔버스를 제외하기 위해 .dispose() 를 이용해서 정리해 줄 수 있습니다.

 

여기까지의 코드를 정리하면 다음과 같습니다.

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

const CanvasSection = () => {
  const canvasRef = useRef(null);
  const [canvas, setCanvas] = useState(null);

  useEffect(() => {
    // 캔버스 생성
    const newCanvas = new fabric.Canvas(canvasRef.current, {
      width: 800,
      height: 400
    });
    setCanvas(newCanvas);
    // 언마운트 시 캔버스 정리, 이벤트 제거
    return () => {
      newCanvas.dispose();
    };
  }, []);

  return (
    <>
      <canvas style={{ border: "1px solid red" }} ref={canvasRef} />
    </>
  );
};

export default CanvasSection;

이제 canvas에서 마우스로 드래그 할 때 선택 영역이 표시됩니다.

 

2. 펜 기능 추가하기

마우스로 드래그 할 때 선택 박스를 표시하는 대신, 펜으로 그리는 기능을 추가해 보겠습니다.

const [activeTool, setActiveTool] = useState("select");

...

return (
    <>
      <canvas style={{ border: "1px solid red" }} ref={canvasRef} />
      <button
        style={{ width: "48px", height: "48px", border: "1px solid black" }}
        onClick={() => setActiveTool("select")}
        disabled={activeTool === "select"}
      >
        선택
      </button>
      <button
        style={{ width: "48px", height: "48px", border: "1px solid black" }}
        onClick={() => setActiveTool("pen")}
        disabled={activeTool === "pen"}
      >
        펜
      </button>
    </>
  );

 

우선, 현재 선택된 도구를 저장할 useState 인 activeTool 을 선언해 줍니다.

 

그런 다음, 위의 코드와 같이 버튼 2개를 만들어 각각 ‘선택’ 도구와 ‘펜’ 도구로 변경할 수 있게 합니다.

 

‘선택’ 도구는 처음 fabric.Canvas를 초기화 했을 때 처럼 객체의 선택이 가능하게 하고,

‘펜’ 도구를 선택했을 때에는 canvas에 펜을 그릴 수 있도록 할 예정입니다.

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

  switch (activeTool) {
    case "select":
      handleSelectTool();
      break;

    case "pen":
      handlePenTool();
      break;
  }
}, [activeTool]);

const handleSelectTool = () => {
  canvas.isDrawingMode = false;
};
const handlePenTool = () => {
  canvas.freeDrawingBrush.width = 10;
  canvas.isDrawingMode = true;
};

 

그런 다음, 위와 같이 activeTool 에 맞춰 canvas의 상태를 변화시키도록 코드를 추가해 줍니다.

선택 도구와 펜 도구의 전환은 fabric.canvas 객체의 isDrawingMode 속성을 바꾸는 식으로 구현합니다.

canvas.isDrawingMode = true; // 그리기 모드 활성화
canvas.isDrawingMode = false; // 그리기 모드 비활성화

 

isDrawingMode 속성이 true인 경우에는 그리기 모드가 활성화 되고,

isDrawingMode 속성이 false인 경우에는 비활성화 됩니다.

canvas.freeDrawingBrush.width = 5; // 그리기 선의 두께 설정
canvas.freeDrawingBrush.color = "blue"; // 그리기 선의 색상 설정

 

freeDrawingBrush 속성은 그리기 선의 모양을 변경할 수 있게 합니다.

위의 코드와 같이 width 속성값으로 두께를 변경할 수 있고, color 속성 값으로 색상도 변경할 수 있습니다.

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

const CanvasSection = () => {
  const canvasRef = useRef(null);
  const [canvas, setCanvas] = useState(null);
  const [activeTool, setActiveTool] = useState("select");

  useEffect(() => {
    // 캔버스 생성
    const newCanvas = new fabric.Canvas(canvasRef.current, {
      width: 800,
      height: 400,
    });
    setCanvas(newCanvas);
    // 언마운트 시 캔버스 정리
    return () => {
      newCanvas.dispose();
    };
  }, []);

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

    switch (activeTool) {
      case "select":
        handleSelectTool();
        break;

      case "pen":
        handlePenTool();
        break;
    }
  }, [activeTool]);

  const handleSelectTool = () => {
    canvas.isDrawingMode = false;
  };
  const handlePenTool = () => {
    canvas.freeDrawingBrush.width = 10;
    canvas.isDrawingMode = true;
  };

  return (
    <>
      <canvas style={{ border: "1px solid red" }} ref={canvasRef} />
      <button
        style={{ width: "48px", height: "48px", border: "1px solid black" }}
        onClick={() => setActiveTool("select")}
        disabled={activeTool === "select"}
      >
        선택
      </button>
      <button
        style={{ width: "48px", height: "48px", border: "1px solid black" }}
        onClick={() => setActiveTool("pen")}
        disabled={activeTool === "pen"}
      >
        펜
      </button>
    </>
  );
};

export default CanvasSection;

 

지금까지의 코드를 정리하면 위와 같습니다. 위의 코드는 아래와 같이 동작합니다.

 

3. 브라우저 크기에 맞춰 캔버스 크기 조절하기

캔버스 크기를 브라우저의 사이즈에 맞춰서 늘리고 줄일 수 있게 변경해 보겠습니다.

 

도구 선택 버튼도 캔버스 위에 떠있도록 디자인을 위의 이미지처럼 수정하겠습니다.

import "./CanvasSection.css";

//...

const CanvasSection = () => {
	const canvasContainerRef = useRef(null);
	
	//...
	
	useEffect(() => {
	  const canvasContainer = canvasContainerRef.current;
	  // 캔버스 생성
	  const newCanvas = new fabric.Canvas(canvasRef.current, {
	    width: canvasContainer.offsetWidth,
	    height: canvasContainer.offsetHeight
	  });
	
	//...
	
	return (
	  <div className="canvas-container" ref={canvasContainerRef}>
	    <canvas ref={canvasRef} />
	    <div className="tool-bar">
	      <button
	        onClick={() => setActiveTool("select")}
	        disabled={activeTool === "select"}
	      >
	        <svg width="20" height="20" viewBox="0 0 20 20" fill="black">
	          <path
	            d="M10.833 0.891602V7.49993H16.6663C16.6663 4.09993 14.1247 1.29993 10.833 0.891602ZM3.33301 12.4999C3.33301 16.1833 6.31634 19.1666 9.99967 19.1666C13.683 19.1666 16.6663 16.1833 16.6663 12.4999V9.1666H3.33301V12.4999ZM9.16634 0.891602C5.87467 1.29993 3.33301 4.09993 3.33301 7.49993H9.16634V0.891602Z"
	            fill="inherit"
	          />
	        </svg>
	      </button>
	      <button
	        onClick={() => setActiveTool("pen")}
	        disabled={activeTool === "pen"}
	      >
	        <svg width="20" height="20" viewBox="0 0 20 20" fill="black">
	          <path
	            d="M2.5 14.3751V17.5001H5.625L14.8417 8.28342L11.7167 5.15842L2.5 14.3751ZM17.2583 5.86675C17.3356 5.78966 17.3969 5.69808 17.4387 5.59727C17.4805 5.49646 17.502 5.38839 17.502 5.27925C17.502 5.17011 17.4805 5.06204 17.4387 4.96123C17.3969 4.86042 17.3356 4.76885 17.2583 4.69175L15.3083 2.74175C15.2312 2.6645 15.1397 2.60321 15.0389 2.56139C14.938 2.51957 14.83 2.49805 14.7208 2.49805C14.6117 2.49805 14.5036 2.51957 14.4028 2.56139C14.302 2.60321 14.2104 2.6645 14.1333 2.74175L12.6083 4.26675L15.7333 7.39175L17.2583 5.86675Z"
	            fill="inherit"
	          />
	        </svg>
	      </button>
	    </div>
	  </div>
	);

 

CanvasSection.css 은 다음과 같이 작성했습니다.

body {
  overflow-x: hidden;
  overflow-y: hidden;
  margin: 0;
}

.canvas-container {
  width: 100vw;
  height: 100vh;
}

.canvas-container canvas {
  border: 1px solid #f00;
}

.tool-bar {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 8px;
  gap: 8px;
  border-radius: 10px;
  background-color: #e7e6e7;
  border: 1px solid #e7e6e7;
  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  position: absolute;
  top: 10px;
  left: 10px;
}

.tool-bar button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 8px;
  border-radius: 10px;
  border: none;
  background-color: #e7e6e7;
  cursor: pointer;
}
.tool-bar button svg {
  fill: #000000;
}

.tool-bar button:disabled {
  background-color: #7279ff;
}
.tool-bar button:disabled svg {
  fill: #ffffff;
}

 

new fabric.Canvas 를 이용하여 width, height를 지정할 때는 vh나 % 같은 단위를 지원하지 않습니다. 따라서, 화면에 맞춰 크기를 조절하려면 다른 방법을 사용해야 합니다.

 

먼저, canvas 태그를 감싸는 div 객체를 하나 만들어주고, 해당 클래스가 브라우저 화면과 같은 크기를 가지도록 css를 지정해 줍니다.

 

그런 다음, 해당 div 객체를 canvasContainerRef 로 참조해서 fabric canvas를 생성 할 때, div 객체의 높이와 너비에 맞춰 크기를 지정해 줍니다.

const handleResize = () => {
  newCanvas.setDimensions({
    width: canvasContainer.offsetWidth,
    height: canvasContainer.offsetHeight
  });
};

// 윈도우가 리사이즈가 되었을 때 실행
window.addEventListener("resize", handleResize);

 

이제 브라우저의 크기를 변경했을 때, 그에 맞춰서 canvas 크기를 수정해주도록 하겠습니다.

 

윈도우 크기 조절 이벤트가 발생했을 때 호출할 함수 handleResize 를 만든 후, setDimensions 메서드를 이용해서 fabric canvas의 크기를 갱신해 줍니다.

 

코드를 정리하면 다음과 같습니다.

import { fabric } from "fabric";
import { useEffect, useRef, useState } from "react";
import "./CanvasSection.css";

const CanvasSection = () => {
  const canvasRef = useRef(null);
  const canvasContainerRef = useRef(null);
  const [canvas, setCanvas] = useState(null);
  const [activeTool, setActiveTool] = useState("select");

  useEffect(() => {
    const canvasContainer = canvasContainerRef.current;
    // 캔버스 생성
    const newCanvas = new fabric.Canvas(canvasRef.current, {
      width: canvasContainer.offsetWidth,
      height: canvasContainer.offsetHeight,
    });
    setCanvas(newCanvas);
    // 언마운트 시 캔버스 정리
    return () => {
      newCanvas.dispose();
    };
  }, []);

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

    switch (activeTool) {
      case "select":
        handleSelectTool();
        break;

      case "pen":
        handlePenTool();
        break;
    }
  }, [activeTool]);

  const handleSelectTool = () => {
    canvas.isDrawingMode = false;
  };
  const handlePenTool = () => {
    canvas.freeDrawingBrush.width = 10;
    canvas.isDrawingMode = true;
  };

  return (
    <div className="canvas-container" ref={canvasContainerRef}>
      <canvas ref={canvasRef} />
      <div className="tool-bar">
        <button
          onClick={() => setActiveTool("select")}
          disabled={activeTool === "select"}
        >
          <svg width="20" height="20" viewBox="0 0 20 20" fill="black">
            <path
              d="M10.833 0.891602V7.49993H16.6663C16.6663 4.09993 14.1247 1.29993 10.833 0.891602ZM3.33301 12.4999C3.33301 16.1833 6.31634 19.1666 9.99967 19.1666C13.683 19.1666 16.6663 16.1833 16.6663 12.4999V9.1666H3.33301V12.4999ZM9.16634 0.891602C5.87467 1.29993 3.33301 4.09993 3.33301 7.49993H9.16634V0.891602Z"
              fill="inherit"
            />
          </svg>
        </button>
        <button
          onClick={() => setActiveTool("pen")}
          disabled={activeTool === "pen"}
        >
          <svg width="20" height="20" viewBox="0 0 20 20" fill="black">
            <path
              d="M2.5 14.3751V17.5001H5.625L14.8417 8.28342L11.7167 5.15842L2.5 14.3751ZM17.2583 5.86675C17.3356 5.78966 17.3969 5.69808 17.4387 5.59727C17.4805 5.49646 17.502 5.38839 17.502 5.27925C17.502 5.17011 17.4805 5.06204 17.4387 4.96123C17.3969 4.86042 17.3356 4.76885 17.2583 4.69175L15.3083 2.74175C15.2312 2.6645 15.1397 2.60321 15.0389 2.56139C14.938 2.51957 14.83 2.49805 14.7208 2.49805C14.6117 2.49805 14.5036 2.51957 14.4028 2.56139C14.302 2.60321 14.2104 2.6645 14.1333 2.74175L12.6083 4.26675L15.7333 7.39175L17.2583 5.86675Z"
              fill="inherit"
            />
          </svg>
        </button>
      </div>
    </div>
  );
};

export default CanvasSection;

 

4. 마우스 휠로 줌 인 / 줌 아웃 하기

마우스 휠을 이용해서 canvas를 줌 인, 줌 아웃 하는 법을 알아보겠습니다.

// 휠을 이용해서 줌인/줌아웃
newCanvas.on("mouse:wheel", function (opt) {
  const delta = opt.e.deltaY;
  let zoom = newCanvas.getZoom();
  zoom *= 0.999 ** delta;
  if (zoom > 20) zoom = 20;
  if (zoom < 0.01) zoom = 0.01;
  newCanvas.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
  opt.e.preventDefault();
  opt.e.stopPropagation();
});

 

useEffect 에 위의 이벤트를 추가해서 아래와 같이 줌 인 / 줌 아웃을 구현할 수 있습니다.

 

 

5. Hand Tool (패닝) 기능 추가하기

마우스로 드래그해서 화면을 이동하는 기능을 추가해 보겠습니다.

 

이 기능은 유사한 프로그램에서 panning, hand tool 등 다양한 이름으로 불리고 있는데 여기서는 hand tool로 부르도록 하겠습니다.

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

  canvas.off("mouse:down");
  canvas.off("mouse:move");
  canvas.off("mouse:up");

  switch (activeTool) {

    //...

    case "hand":
      handleHandTool();
      break;
  }
}, [activeTool]);

const handleSelectTool = () => {
  canvas.isDrawingMode = false;
  canvas.selection = true;
  canvas.defaultCursor = "default";
};

//...

const handleHandTool = () => {
  canvas.isDrawingMode = false;
  canvas.selection = false;
  canvas.defaultCursor = "move";

  let panning = false;
  const handleMouseDown = () => {
    panning = true;
  };
  const handleMouseMove = (event) => {
    if (panning) {
      const delta = new fabric.Point(event.e.movementX, event.e.movementY);
      canvas.relativePan(delta);
    }
  };
  const handleMouseUp = () => {
    panning = false;
  };
  canvas.on("mouse:down", handleMouseDown);
  canvas.on("mouse:move", handleMouseMove);
  canvas.on("mouse:up", handleMouseUp);
};

 

hand tool 구현은 조금 복잡합니다. 마우스로 canvas를 드래그 해서 이동하기 위해 크게 3가지 이벤트를 이용합니다.

  • 1 : 마우스 버튼 누르기 ( handleMouseDown )
  • 2 : 마우스 움직이기 ( handleMouseMove)
  • 3 : 마우스 버튼 떼기 ( handleMouseUp )

위의 3가지 이벤트에 맞는 동작을 구현하기 전에, 가장 먼저 isDrawingMode 를 false로 만들어 만약 그리기 모드였다면 해당 모드를 취소합니다.

그런 다음 selection 속성을 false로 만들어 hand tool 선택 중에 그리기 객체가 선택되지 않도록 합니다.

그리고 defaultCursor 속성을 변경하여 hand tool 모드임을 더 명시적으로 나타냅니다.

 

selection 과 defaultCursor 속성이 변경되므로, 이에 맞춰서 handleSelectTool() 함수도 위의 코드처럼 변경해주면 됩니다.

 

그리고 canvas를 이동하기 위해 panning 변수를 만들어 줍니다. panning 변수는 마우스 버튼을 눌렀을 때 ( handleMouseDown ) 만 handleMouseMove 핸들러 내용이 실행되도록 합니다.

 

handleMouseMove 핸들러는 마우스의 움직임에 맞춰 canvas가 움직이도록 합니다.

이를 위해 fabric.canvas의 relativePan(point) 메서드를 사용합니다. relativePan(point) 메서드는 fabric.Point 객체를 인자로 받아 canvas를 현재 위치에서 주어진 포인트(거리)만큼 이동시킵니다.

 

따라서, 상대적인 이동 거리를 delta 라는 변수에 저장한 뒤 전달하여 드래그 중 canvas가 이동하도록 합니다.

이러한 이벤트는 hand tool이 선택되지 않았을 때에는 실행되면 안되므로 activeTool이 변경될 때마다 확인하여 이벤트를 제거해 줍니다.

 

fabric.canvas의 더 다양한 메서드는 아래 링크를 확인할 수 있습니다.

http://fabricjs.com/docs/fabric.Canvas.html

 

hand tool의 작동 모습은 아래 이미지와 같고, 최종 코드는 아래 링크에서 확인할 수 있습니다.

https://codesandbox.io/p/sandbox/fabric-canvas-with-react-txzx8r?file=%2Fsrc%2FCanvasSection.js

 

 

 


참고자료

 

http://fabricjs.com/fabric-intro-part-1

 

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

http://fabricjs.com/fabric-intro-part-5

 

Zoom and pan, introduction to FabricJS — Fabric.js Javascript Canvas Library

Zoom and pan, introduction to FabricJS part 5 We've covered so many topics in the previous series; from basic object manipulations to animations, events, filters, groups, and subclasses. But there's still couple of very interesting and useful things to dis

fabricjs.com

https://stackoverflow.com/questions/50216325/fabricjs-itext-width-and-height

 

FabricJS iText Width And Height

Is there a way to set the width and height of an iText object? When creating the object if you set width and height it doesn't do anything. What I am after is the bounding box to be a fixed size on...

stackoverflow.com