React+fabric.jsで作る手書きアプリ
この記事は「Medley (メドレー) Advent Calendar 2024」の10日目の記事となります。
はじめに
こんにちは、メドレーの吉岡です。
最近、Reactでfabric.jsを使用して手書きアプリを作る機会があり、日本語のドキュメントが少なかったり、v6で挙動が変わったり、消しゴム機能について書かれている記事が少ないなどで詰まってしまうことがあったので、手書きアプリの作り方を紹介したいと思います。
今回実装する機能は以下です。
- 手書き機能
- 太さ、色が変えられる
 
- 消しゴム機能
- 履歴機能
- undo、redoができる
 
完成したアプリは以下のようになります。

完成したアプリ
それでは実装していきましょう。
使用するバージョン
react@18.3.1
fabric@6.5.1
手書きアプリの実装方法
セットアップ
READMEの手順に従って、以下のように初期化処理を実装していきます。
import { useEffect, useRef } from "react";
import * as fabric from "fabric";
export const App = () => {
  const canvasEl = useRef<HTMLCanvasElement>(null);
  useEffect(() => {
    if (canvasEl.current === null) {
      return;
    }
    const canvas = new fabric.Canvas(canvasEl.current);
    return () => {
      canvas.dispose();
    };
  }, []);
  return <canvas width="1000" height="1000" ref={canvasEl} />;
};
export default App;
これで準備完了です。
手書き機能
まずは、手書き機能を作っていきましょう。
手書き機能では、PencilBrushを使用します。先ほどの初期化処理に実装を追加します。
  useEffect(() => {
    if (canvasEl.current === null) {
      return;
    }
    const canvas = new fabric.Canvas(canvasEl.current);
+   // 手書き機能を追加
+   const pencil = new fabric.PencilBrush(canvas);
+   pencil.color = "#000000";
+   pencil.width = 10;
+   canvas.freeDrawingBrush = pencil;
+   canvas.isDrawingMode = true;
    return () => {
      canvas.dispose();
    };
  }, []);
canvas.isDrawingMode = true;
でisDrawingModeをtrueにすることで、canvasに手書きができるようになることに注意が必要です。

手書き機能
太さ・色を変えられるようにする
次は、ペンの太さ・色を変えられるようにしてみましょう。
先ほど、
const pencil = new fabric.PencilBrush(canvas);
pencil.color = "#000000";
pencil.width = 10;
で太さと色を指定していたように、widthとcolorを変更することで太さと色を変更します。canvas変数のプロパティを変更する必要があるので、useStateで管理するようにします。実装例は以下のようになります。
export const App = () => {
  const canvasEl = useRef<HTMLCanvasElement>(null);
+ const [canvas, setCanvas] = useState<fabric.Canvas | null>(null);
  useEffect(() => {
    if (canvasEl.current === null) {
      return;
    }
    const canvas = new fabric.Canvas(canvasEl.current);
+   setCanvas(canvas);
    // 手書き機能を追加
    const pencil = new fabric.PencilBrush(canvas);
    pencil.color = "#000000";
    pencil.width = 10;
    canvas.freeDrawingBrush = pencil;
    canvas.isDrawingMode = true;
    return () => {
      canvas.dispose();
    };
  }, []);
+ const changeToRed = () => {
+   if (canvas?.freeDrawingBrush === undefined) {
+     return;
+   }
+   canvas.freeDrawingBrush.color = "#ff0000";
+ };
+
+ const changeToBlack = () => {
+   if (canvas?.freeDrawingBrush === undefined) {
+    return;
+   }
+   canvas.freeDrawingBrush.color = "#000000";
+ };
+
+ const changeToThick = () => {
+   if (canvas?.freeDrawingBrush === undefined) {
+     return;
+   }
+   canvas.freeDrawingBrush.width = 20;
+ };
+
+ const changeToThin = () => {
+   if (canvas?.freeDrawingBrush === undefined) {
+     return;
+   }
+   canvas.freeDrawingBrush.width = 10;
+ };
+
+ return (
+   <div>
+     <button onClick={changeToRed}>赤色に変更</button>
+     <button onClick={changeToBlack}>黒色に変更</button>
+     <button onClick={changeToThick}>太くする</button>
+     <button onClick={changeToThin}>細くする</button>
+     <canvas ref={canvasEl} width="1000" height="1000" />
+   </div>
+ );
};
これで、ペンの太さを太くもしくは細く、そしてペンの色を赤色と黒色に変えられるようになりました。

太さ・色の変更
消しゴム機能
次は消しゴム機能です。fabricのv6系から消しゴムは別リポジトリのerase2dに切り出されたので、以下のコマンドでインストールします。
yarn add @erase2d/fabric
バージョンは1.1.6を使用します。
ペン機能で、canvas.freeDrawingBrushにfabric.PencilBrushを指定したように、EraserBrushを指定します。
  useEffect(() => {
    if (canvasEl.current === null) {
      return;
    }
    const canvas = new fabric.Canvas(canvasEl.current);
    setCanvas(canvas);
    // 手書き機能を追加
    const pencil = new fabric.PencilBrush(canvas);
    pencil.color = "#000000";
    pencil.width = 10;
    canvas.freeDrawingBrush = pencil;
    canvas.isDrawingMode = true;
+   // 消しゴムで線を消せるようにするため
+   canvas.on("object:added", (e) => {
+    e.target.erasable = true;
+   });
    return () => {
      canvas.dispose();
    };
  }, []);
(省略)
+ const changeToEraser = () => {
+   if (canvas?.freeDrawingBrush === undefined) {
+     return;
+   }
+   const eraser = new EraserBrush(canvas);
+   canvas.freeDrawingBrush = eraser;
+   canvas.freeDrawingBrush.width = 20;
+ };
  return (
    <div>
      <button onClick={changeToRed}>赤色に変更</button>
      <button onClick={changeToBlack}>黒色に変更</button>
      <button onClick={changeToThick}>太くする</button>
      <button onClick={changeToThin}>細くする</button>
+     <button onClick={changeToEraser}>消しゴム</button>
      <canvas ref={canvasEl} width="1000" height="1000" />
    </div>
  );
消しゴムを使って、ペンで書いた線を消せるようにするために、
   canvas.on("object:added", (e) => {
    e.target.erasable = true;
   });
を追加します。これで消しゴム機能が使えるようになりました。

消しゴム機能
しかし、「消しゴム」ボタンを押した後に「赤色に変更」ボタンを押してもペンに切り替わらず消しゴムのままになっていると思います。なぜなら、changeToEraserでfreeDrawingBrushがEraserBrushのままになってしまっているためです。消しゴムからペンに切り替えられるようにコードを修正しましょう。
+const DEFAULT_COLOR = "#000000";
+const DEFAULT_WIDTH = 10;
export const App = () => {
  const canvasEl = useRef<HTMLCanvasElement>(null);
  const [canvas, setCanvas] = useState<fabric.Canvas | null>(null);
+ const [width, setWidth] = useState(DEFAULT_WIDTH);
+ const [color, setColor] = useState(DEFAULT_COLOR);
  useEffect(() => {
    if (canvasEl.current === null) {
      return;
    }
    const canvas = new fabric.Canvas(canvasEl.current);
    setCanvas(canvas);
    // 手書き機能を追加
    const pencil = new fabric.PencilBrush(canvas);
+   pencil.color = DEFAULT_COLOR;
+   pencil.width = DEFAULT_WIDTH;
    canvas.freeDrawingBrush = pencil;
    canvas.isDrawingMode = true;
    // 消しゴムで線を消せるようにするため
    canvas.on("object:added", (e) => {
      e.target.erasable = true;
    });
    return () => {
      canvas.dispose();
    };
  }, []);
  const changeToRed = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
+   canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
    canvas.freeDrawingBrush.color = "#ff0000";
+   canvas.freeDrawingBrush.width = width;
+   setColor("#ff0000");
  };
  const changeToBlack = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
+   canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
    canvas.freeDrawingBrush.color = "#000000";
+   canvas.freeDrawingBrush.width = width;
+   setColor("#000000");
  };
  const changeToThick = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
+   canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
    canvas.freeDrawingBrush.width = 20;
+   canvas.freeDrawingBrush.color = color;
+   setWidth(20);
  };
  const changeToThin = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
+   canvas.freeDrawingBrush = new fabric.PencilBrush(canvas);
    canvas.freeDrawingBrush.width = 10;
+   canvas.freeDrawingBrush.color = color;
+   setWidth(10);
  };
(省略)
freeDrawingBrushにfabric.PencilBrushを指定することで消しゴムからペンに切り替えることができるようになりました。また、色や太さは変更が保持されるように、stateで管理するようにしています。

ペンと消しゴムの切り替え
履歴機能
最後に履歴機能を作っていきましょう。履歴機能には、ペンで書いた線、消しゴムで消した線を復活できるundo機能、undoを取り消すredo機能を追加していきます。
undo機能
最初に、undo機能から作っていきましょう。
undo機能を作るためには、canvasに文字が書かれたタイミング、つまりobject:addedのイベントが実行されるごとに履歴を管理する必要があります。以下のコードを追加します。
  const [histories, setHistories] = useState<{
    undo: object[];
  }>({
    undo: [],
  });
  useEffect(() => {
    if (!canvas) {
      return;
    }
    // 空のcanvasを履歴の初期値に追加
    setHistories({
      undo: [canvas.toJSON()],
    });
    const onCanvasModified = (e: { target: fabric.FabricObject }) => {
      if (isCanvasLocked.current) {
        return;
      }
      const targetCanvas = e.target.canvas;
      if (targetCanvas) {
        setHistories((prev) => ({
          undo: [...prev.undo, targetCanvas.toJSON()],
        }));
      }
    };
    canvas.on("object:added", onCanvasModified);
    return () => {
      canvas.off("object:added", onCanvasModified);
    };
  }, [canvas]);
object:addedイベントが実行されるごとにonCanvasModifiedで履歴を追加しています。この履歴を使用してundo機能は以下のように実装できます。
(省略)
+ const undo = useCallback(async () => {
+   if (!canvas) {
+     return;
+   }
+
+   const lastHistory = histories.undo.at(-2);
+   const currentHistory = histories.undo.at(-1);
+   if (!lastHistory || !currentHistory) {
+     return;
+   }
+
+   await canvas.loadFromJSON(lastHistory);
+   canvas.renderAll();
+   setHistories((prev) => ({
+     undo: prev.undo.slice(0, -1),
+   }));
+ }, [canvas, histories.undo]);
(省略)
  return (
    <div>
      <button onClick={changeToRed}>赤色に変更</button>
      <button onClick={changeToBlack}>黒色に変更</button>
      <button onClick={changeToThick}>太くする</button>
      <button onClick={changeToThin}>細くする</button>
      <button onClick={changeToEraser}>消しゴム</button>
+     <button onClick={undo}>undo</button>
      <canvas ref={canvasEl} width="1000" height="1000" />
    </div>
  );
v6から、loadSomething系のメソッド(今回の場合はloadFromJSON)がcallbackを受け取るのではなく、Promiseベースで実装されるようになったことに注意が必要です。
これでundo機能が完成。。。。ではなく、実際に「undo」ボタンを押していただくと、上手く動作しないと思います。これは、undoしてcanvasの再描画を行ったタイミングでもobject:addedイベントが発火するためです。undoしたときにはこのイベントが発火しないように制御を加えましょう。
  const [histories, setHistories] = useState<{
    undo: object[];
  }>({
    undo: [],
  });
+ const isCanvasLocked = useRef(false);
  useEffect(() => {
    if (!canvas) {
      return;
    }
    // 空のcanvasを履歴の初期値に追加
    setHistories({
      undo: [canvas.toJSON()],
    });
    const onCanvasModified = (e: { target: fabric.FabricObject }) => {
+    if (isCanvasLocked.current) {
+       return;
+     }
      const targetCanvas = e.target.canvas;
      if (targetCanvas) {
        setHistories((prev) => ({
          undo: [...prev.undo, targetCanvas.toJSON()],
        }));
      }
    };
    canvas.on("object:added", onCanvasModified);
    return () => {
      canvas.off("object:added", onCanvasModified);
    };
  }, [canvas]);
  const undo = useCallback(async () => {
-   if (!canvas) {
+   if (!canvas || isCanvasLocked.current) {
      return;
    }
    const lastHistory = histories.undo.at(-2);
    const currentHistory = histories.undo.at(-1);
    if (!lastHistory || !currentHistory) {
      return;
    }
+   isCanvasLocked.current = true;
    await canvas.loadFromJSON(lastHistory);
    canvas.renderAll();
    setHistories((prev) => ({
      undo: prev.undo.slice(0, -1),
    }));
+   isCanvasLocked.current = false;
  }, [canvas, histories.undo]);
isCanvasLockedのフラグを使用することで、undoによってobject:addedのイベントで履歴が追加されないようにしています。

undo機能
消しゴムのundo機能
ペンのundo機能はできましたが、消しゴムで消したタイミングでobject:addedイベントは発火しないので、消しゴムのundoはまだできません。消しゴムで線を消す度に履歴に追加したいので、以下のように実装します。
  const changeToEraser = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
    const eraser = new EraserBrush(canvas);
+   eraser.on("end", async (e) => {
+     e.preventDefault();
+
+     await eraser.commit(e.detail);
+     setHistories((prev) => ({
+       undo: [...prev.undo, canvas.toJSON()],
+     }));
+   });
    canvas.freeDrawingBrush = eraser;
    canvas.freeDrawingBrush.width = 20;
  };
eraser.on("end", ...)によって、消しゴムによって線が消し終わったタイミングのイベントを取得することができるので、ここで履歴の追加処理を実装します。消しゴムによって消し終わった時点でのcanvasの情報を履歴に保存したいので、
await eraser.commit(e.detail);
を呼び出しているのがポイントです(awaitした後に履歴に追加しないと、消しゴムで消し終わった後のcanvasが履歴に保存されません。筆者はここで少し苦戦しました)。
なども参考になると思います。
これで消しゴムのundoもできるようになりました。

消しゴムのundo機能
redo機能
最後にredo機能の実装です。redo機能の実装のポイントは以下です。
- undoしたものをredoの配列に追加していく
- undoした後に、ペンや消しゴムによる操作が行われた場合はredoの履歴を削除する
以下のように実装できます。
(省略)
  const changeToEraser = () => {
    if (canvas?.freeDrawingBrush === undefined) {
      return;
    }
    const eraser = new EraserBrush(canvas);
    eraser.on("end", async (e) => {
      e.preventDefault();
      await eraser.commit(e.detail);
      setHistories((prev) => ({
        undo: [...prev.undo, canvas.toJSON()],
+       redo: [],
      }));
    });
    canvas.freeDrawingBrush = eraser;
    canvas.freeDrawingBrush.width = 20;
  };
  const [histories, setHistories] = useState<{
    undo: object[];
+   redo: object[];
  }>({
    undo: [],
+   redo: [],
  });
  const isCanvasLocked = useRef(false);
  useEffect(() => {
    if (!canvas) {
      return;
    }
    // 空のcanvasを履歴の初期値に追加
    setHistories({
      undo: [canvas.toJSON()],
+     redo: [],
    });
    const onCanvasModified = (e: { target: fabric.FabricObject }) => {
      if (isCanvasLocked.current) {
        return;
      }
      const targetCanvas = e.target.canvas;
      if (targetCanvas) {
        setHistories((prev) => ({
          undo: [...prev.undo, targetCanvas.toJSON()],
+         redo: [],
        }));
      }
    };
    canvas.on("object:added", onCanvasModified);
    return () => {
      canvas.off("object:added", onCanvasModified);
    };
  }, [canvas]);
  const undo = useCallback(async () => {
    if (!canvas || isCanvasLocked.current) {
      return;
    }
    const lastHistory = histories.undo.at(-2);
    const currentHistory = histories.undo.at(-1);
    if (!lastHistory || !currentHistory) {
      return;
    }
    isCanvasLocked.current = true;
    await canvas.loadFromJSON(lastHistory);
    canvas.renderAll();
    setHistories((prev) => ({
      undo: prev.undo.slice(0, -1),
+     redo: [...prev.redo, currentHistory],
    }));
    isCanvasLocked.current = false;
  }, [canvas, histories.undo]);
+ const redo = useCallback(async () => {
+   if (!canvas || isCanvasLocked.current) {
+     return;
+   }
+
+   const lastHistory = histories.redo.at(-1);
+   if (!lastHistory) {
+     return;
+   }
+
+   isCanvasLocked.current = true;
+
+   await canvas.loadFromJSON(lastHistory);
+   canvas.renderAll();
+   setHistories((prev) => ({
+     undo: [...prev.undo, lastHistory],
+     redo: prev.redo.slice(0, -1),
+   }));
+
+   isCanvasLocked.current = false;
+ }, [canvas, histories.redo]);
  return (
    <div>
      <button onClick={changeToRed}>赤色に変更</button>
      <button onClick={changeToBlack}>黒色に変更</button>
      <button onClick={changeToThick}>太くする</button>
      <button onClick={changeToThin}>細くする</button>
      <button onClick={changeToEraser}>消しゴム</button>
      <button onClick={undo}>undo</button>
+     <button onClick={redo}>redo</button>
      <canvas ref={canvasEl} width="1000" height="1000" />
    </div>
  );
};
これでredo機能を含めた全ての機能の実装が完了しました。

redo機能
まとめ
Reactとfabric.jsを使用した手書きアプリの作り方を説明してみました。手書きアプリやお絵描きアプリを作るときに、参考にしていただけたら幸いです。
完成したコードは以下になります。
終わりに
メドレーではエンジニアを募集しているので、興味がある方はぜひお声かけください。
Medley Advent Calendar 2024、明日は @morishio さんです!






Discussion