HTML の canvas 要素で手書きメモの undo / redo を再現する（React x TypeScript）

## はじめに
### この記事で書くこと
- HTML の canvas 要素上に文字などを書いたとき、undo / redo を実現する実装方法について
  - **undo ... 直前に描いた一筆の取り消し**
  - **redo ... 直前の undo の復元**

### この記事で書かないこと
- HTML の canvas 要素上に文字を書く方法の詳細（デモページのコードとしては公開します）
- React の話
- TypeScript の話


### 動作確認環境（2022/12/11時点）
- macOS
  - Monterey バージョン 12.5.1
- ブラウザ
  - Google Chromeバージョン: 108.0.5359.98（Official Build） （x86_64）

※ 2022/12/23 手持ちのタッチデバイス（iPhone, Safari）でも動くように修正しました。（iOS15.7.1で確認）


## サンプルページ
下のサンプルページで、undo / redo の体験ができます。
四角の中に適当にメモを書いてみて、四角の下にあるボタンで操作をしてみてください。

https://codesandbox.io/embed/canvas-redo-undo-37dpw7?fontsize=14&hidenavigation=1&theme=dark

※すべての異常系に対応できているわけではありません。
※万が一エラーが発生している場合は再読み込みをさせてみてください。

## 実装説明
今回のサンプルでは描画に関わる処理を `useStrokeCanvas.ts` として切り出しています。

### State
今回は undo / redo を再現するために、2つの State を用意しました。
ストローク情報は座標として表現します。

```tsx:useStrokeCanvas.ts
type CurrentStrokeType = { x: number; y: number }[] | [];

type CoordinatesArrayType = {
  coordinates: CurrentStrokeType;
}[];

type AllStrokesType = {
  strokes: CoordinatesArrayType | [];
};

// canvas に描いたストロークの座標情報をすべて保存
const [allStrokes, setAllStrokes] = useState<AllStrokesType>({
    strokes: []
  });

// 現在（今描いている）ストロークの座標情報を保存
const [currentStroke, setCurrentStroke] = useState<CurrentStrokeType>([]);

// undo したストローク情報を保存
const [undoStrokes, setUndoStrokes] = useState<AllStrokesType>({
    strokes: []
});
```

データの具体的なイメージとしては下記のようになります。

```ts
// currentStroke
// 1ストロークの座標の配列
[
   { x: 100, y: 200 },
   { x: 105, y: 200 },
   { x: 110, y: 200 },
]

// allStrokes, undoStrokes
// ユーザーの操作をすべて保存
 {
   // 1ストロークずつの配列
   strokes: [
     {
       // 1ストロークごとの座標をもつ配列
       coordinates: [
         { x: 100, y: 200 },
         { x: 105, y: 200 },
         { x: 110, y: 200 },
       ]
     },
    {
       coordinates: [
         { x: 110, y: 200 },
         { x: 110, y: 205 },
         { x: 110, y: 210 },
       ]
     },
   ]
 }
```

### State の更新タイミング
今回は canvas への単なる描画の実装方法の説明は省略しますが、
ユーザーがメモをしている間、`drawStart`, `drawMove`, `drawEnd` がそれぞれのタイミングで発火します。
それぞれの処理としては下記のようなことがなされています。
- `drawStart`... 描画開始のタイミングで発火。現在のストローク情報 `currentStroke` の座標の保存を開始。
- `drawMove`... 描画中発火し続ける。現在のストローク情報 `currentStroke` の座標の保存をし続ける。
- `drawEnd` ... 描画終了のタイミングで発火。現在のストローク情報 `currentStroke` をすべてのストローク情報 `allStrokes` の最新として登録。その後現在のストローク情報 `currentStroke`を削除。

```tsx:useStrokeCanvas.ts
// 描画開始
const drawStart: MouseOrTouchEventHandler = (e) => {
    const { offsetX: x, offsetY: y } = offsetPosition(e);
    // 現在のストローク情報として座標を保存していく
    setCurrentStroke([{ x, y }]);
    setIsDrawing(true);
};

// 描画中
const drawMove: MouseOrTouchEventHandler = (e) => {
    if (!isDrawing) return;
    const { offsetX: x, offsetY: y } = offsetPosition(e);
    // カーソルを動かして1ストロークを書き続ける間、現在のストローク情報として座標を保存し続ける
    setCurrentStroke([...currentStroke, { x, y }]);
    draw();
};

// 描画完了
const drawEnd: MouseOrTouchEventHandler = (e) => {
    setIsDrawing(false);
    
    // 1ストロークの描画が終わった時点で、現在のストローク情報をすべてのストローク情報の最新として保存
    const nowAllStrokes = allStrokes.strokes;
    setAllStrokes({
      strokes: [...nowAllStrokes, { coordinates: currentStroke }]
    });
    
    // 現在のストローク情報をクリア
    setCurrentStroke([]);
};
```

ここまでできたらあとはいよいよ undo / redo の実装です。

### undo の実装
「はじめに」で述べたように、「undo = 直前に描いた一筆の取り消し」です。
undo の実装でやることは下記です。
- canvas の見た目を一度すべてクリアする
- あとで undo したストロークを redo したい可能性もあるので、 State として用意していた `UndoStrokes` に undo するストロークの座標情報を保存しておく
- すべてのストローク情報 `allStrokes` から undo するストローク（＝最新のストローク）の座標情報を削除する
- 更新したすべてのストローク情報 `allStrokes` を元に、canvas への描画をおこなう


```tsx:useStrokeCanvas.ts
const undo = () => {
    const ctx = getContext();
    if (!ctx || !canvasRef.current) return;
    // 一度描画をすべてクリア
    ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
    
    // redo 用に最新のストロークを保存しておく
    const lastStroke = allStrokes.strokes.slice(-1)[0];
    const nowUndoStrokes = undoStrokes.strokes;
    setUndoStrokes({
      strokes: [...nowUndoStrokes, lastStroke]
    });
    
    // すべてのストローク情報から最後の配列を取り除く
    const newAllStrokes: AllStrokesType = {
      strokes: allStrokes.strokes.slice(0, -1)
    };
    
    // 最新のすべてのストローク情報を canvas に描画させる
    ctx.beginPath();
    for (let i = 0; i < newAllStrokes.strokes.length; i += 1) {
      const operation = newAllStrokes.strokes[i];
      for (let j = 0; j < operation.coordinates.length; j += 1) {
        const xy = operation.coordinates[j];
        if (j === 0) {
          ctx.moveTo(xy.x, xy.y);
        } else {
          ctx.lineTo(xy.x, xy.y);
        }
      }
    }
    ctx.stroke();

    // 最新のすべてのストローク情報を保存
    setAllStrokes({ strokes: [...newAllStrokes.strokes] });
};
```

これで直前に描いた一筆が取り消されているように見えるはずです。
何画も描いて undo を繰り返しおこなうと、新しいストロークから消えていきます。

では次はこの undo の復元、redo を実装してみます。

### redo の実装
「redo = 直前の undo の復元」です。
redo の実装でやることは下記です。

- undo したストローク情報 `undoStrokes` から最新のストロークの座標情報を取得し、canvas に描画する
- redo（再描画）したストロークは undo したストローク情報 `undoStrokes` から削除する
- redo したストロークの座標情報をすべてのストローク情報 `allStrokes` の最新に登録する

```tsx:useStrokeCanvas.ts
const redo = () => {
    const ctx = getContext();
    if (!ctx || !canvasRef.current) return;
    
    // undo したストローク情報から最新のストロークを復元（描画）させる
    const lastUndoOperation = undoStrokes.strokes.slice(-1)[0];
    ctx.beginPath();
    if (lastUndoOperation && lastUndoOperation.coordinates.length > 0) {
      for (let i = 0; i < lastUndoOperation.coordinates.length; i += 1) {
        const xy = lastUndoOperation.coordinates[i];
        if (i === 0) {
          ctx.moveTo(xy.x, xy.y);
        } else {
          ctx.lineTo(xy.x, xy.y);
        }
      }
    }
    ctx.stroke();
    
    // undoStrokes の一番後ろのストロークを削除する
    const newUndoStrokes = undoStrokes.strokes.slice(0, -1);
    setUndoStrokes({ strokes: [...newUndoStrokes] });
    
    // redo したストロークをすべてのストローク情報に戻す
    setAllStrokes({
      strokes: [...allStrokes.strokes, lastUndoOperation]
    });
};

```

これで undo / redo を canvas で再現させることができるようになりました🎉🎉🎉

## おわりに
今まであまり触ってこなかった canvas でしたが、実装を工夫してあげることで様々な使い方をすることができそうだなと感じられました。
そのうち消しゴム機能についても挑戦してみたいです。