📝

🚛で荷物受取りの際にする電子サインっぽい機能(画面)をReactで作る✒️

に公開

はじめに

試作を作ってみたけど使う機会に恵まれなかった機能について(手書き文字機能)簡単実装を紹介したいと思います!

サインできる部分の開閉については以下記事でTips書いてます🙇

https://zenn.dev/dk_/articles/300fe4a047d18d

機能について

Webアプリケーションで電子署名を実装する際の自由署名できる機能になります

  • 署名入力
  • Undo(元に戻す)機能
  • Redo(やり直し)機能
  • Clear(クリア)機能
  • ボタンの有効/無効状態の管理

使用ライブラリ

https://github.com/szimek/signature_pad

成果物

※画面レイアウトについては以下の説明では割愛しております。

署名サンプル.gif

Hooksについての全容
import { useRef, useEffect, useState, useCallback } from "react";
import SignaturePad, { PointGroup } from "signature_pad";

export const useSignature = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const signaturePadRef = useRef<SignaturePad | null>(null);
  const signatureRedoArray = useRef<PointGroup[]>([]);
  const [isUndoable, setIsUndoable] = useState(false);
  const [isRedoable, setIsRedoable] = useState(false);

  const updateButtonStates = useCallback(() => {
    if (signaturePadRef.current) {
      setIsUndoable(signaturePadRef.current.toData().length > 0);
      setIsRedoable(signatureRedoArray.current.length > 0);
    }
  }, []);

  const resizeCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    if (canvas) {
      const ratio = Math.max(window.devicePixelRatio || 1, 1);
      canvas.width = window.innerWidth * ratio;
      canvas.height = window.innerHeight * ratio;
      canvas.getContext("2d")!.scale(ratio, ratio);
      if (signaturePadRef.current) {
        signaturePadRef.current.clear();
      }
    }
  }, []);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas) {
      const signaturePad = new SignaturePad(canvas, {
        backgroundColor: "rgb(255, 255, 255)",
      });
      signaturePadRef.current = signaturePad;

      window.addEventListener("resize", resizeCanvas);
      resizeCanvas();

      const handleEndStroke = () => {
        updateButtonStates();
      };

      signaturePad.addEventListener("endStroke", handleEndStroke);

      return () => {
        window.removeEventListener("resize", resizeCanvas);
        signaturePad.removeEventListener("endStroke", handleEndStroke);
      };
    }
  }, [resizeCanvas, updateButtonStates]);

  const undo = useCallback(() => {
    if (signaturePadRef.current) {
      const signatureData = signaturePadRef.current.toData();
      if (signatureData.length > 0) {
        const lastState = signatureData.pop();
        signaturePadRef.current.fromData(signatureData);
        if (lastState) {
          signatureRedoArray.current.push(lastState);
        }
        updateButtonStates();
      }
    }
  }, [updateButtonStates]);

  const redo = useCallback(() => {
    if (signaturePadRef.current && signatureRedoArray.current.length > 0) {
      const lastRedo = signatureRedoArray.current.pop();
      if (lastRedo) {
        const signatureData = signaturePadRef.current.toData();
        signatureData.push(lastRedo);
        signaturePadRef.current.fromData(signatureData);
        updateButtonStates();
      }
    }
  }, [updateButtonStates]);

  const clear = useCallback(() => {
    if (signaturePadRef.current) {
      signaturePadRef.current.clear();
      signatureRedoArray.current = [];
      setIsUndoable(false);
      setIsRedoable(false);
    }
  }, []);

  return {
    canvasRef,
    clear,
    redo,
    undo,
    isUndoable,
    isRedoable,
  };
};

実装について

状態関連

const canvasRef = useRef<HTMLCanvasElement>(null);
const signaturePadRef = useRef<SignaturePad | null>(null);
const signatureRedoArray = useRef<PointGroup[]>([]);
const [isUndoable, setIsUndoable] = useState(false);
const [isRedoable, setIsRedoable] = useState(false);
  • canvasRefでCanvas要素へのrefに参照値
  • signaturePadRefでSignaturePadのインスタンスへの参照値
  • signatureRedoArrayは履歴を保存する配列でこの中、文字の一筆の状態を管理
  • isRedoableはボタンのdisableフラグ

ボタン状態の更新

const updateButtonStates = useCallback(() => {
  if (signaturePadRef.current) {
    setIsUndoable(signaturePadRef.current.toData().length > 0);
    setIsRedoable(signatureRedoArray.current.length > 0);
  }
}, []);

署名データが存在するかどうかで戻すボタンの判定を更新し履歴が存在するかどうかで進むボタンの状態を判定を更新します。

入力画角の対応について

const resizeCanvas = useCallback(() => {
  const canvas = canvasRef.current;
  if (canvas) {
    const ratio = Math.max(window.devicePixelRatio || 1, 1);
    canvas.width = window.innerWidth * ratio;
    canvas.height = window.innerHeight * ratio;
    canvas.getContext("2d")!.scale(ratio, ratio);
    if (signaturePadRef.current) {
      signaturePadRef.current.clear();
    }
  }
}, []);

resizeイベントのhandlerとしており、入力画面の有効値を取っていますが入力中にresizeされた場合は署名の状態は削除されます。

初期化処理

useEffect(() => {
 const canvas = canvasRef.current;
 if (canvas) {
   const signaturePad = new SignaturePad(canvas, {
     backgroundColor: 'rgb(255, 255, 255)',
   });
   signaturePadRef.current = signaturePad;

   window.addEventListener('resize', resizeCanvas);
   resizeCanvas();

   updateButtonStates();

   signaturePad.addEventListener('endStroke',  updateButtonStates);

   return () => {
    window.removeEventListener('resize', resizeCanvas);
    signaturePad.removeEventListener('endStroke', updateButtonStates);
  };
 }
}, [resizeCanvas, updateButtonStates]);
  • signaturePadをインスタンス化(背景設定)
  • リサイズイベントをhandlerに登録
  • 一画(一筆)が終わるたびに

Undo 機能の実装

const undo = useCallback(() => {
  if (signaturePadRef.current) {
    const signatureData = signaturePadRef.current.toData();
    if (signatureData.length > 0) {
      const lastState = signatureData.pop();
      signaturePadRef.current.fromData(signatureData);
      if (lastState) {
        signatureRedoArray.current.push(lastState);
      }
      updateButtonStates();
    }
  }
}, [updateButtonStates]);
- 現在の署名データを取得
- 最後のストロークを削除
- 削除したストロークを Redo 用配列に保存
- ボタンの状態を更新

を行っています。

Redo 機能の実装

const redo = useCallback(() => {
  if (signaturePadRef.current && signatureRedoArray.current.length > 0) {
    const lastRedo = signatureRedoArray.current.pop();
    if (lastRedo) {
      const signatureData = signaturePadRef.current.toData();
      signatureData.push(lastRedo);
      signaturePadRef.current.fromData(signatureData);
      updateButtonStates();
    }
  }
}, [updateButtonStates]);
- Redo用配列から最後のストロークを取得
- 現在の署名データに追加
- ボタンの状態を更新

を行っています。

Clear 機能の実装

const clear = useCallback(() => {
  if (signaturePadRef.current) {
    signaturePadRef.current.clear();
    signatureRedoArray.current = [];
    setIsUndoable(false);
    setIsRedoable(false);
  }
}, []);

署名をクリアし、すべての履歴とボタンの状態をリセットしています。

使い方イメージ

import { useSignature } from "./hooks/useSignature";

function SignatureComponent() {
  const { canvasRef, clear, redo,
          undo, isUndoable, isRedoable } = useSignature();

  return (
    <div>
      <canvas ref={canvasRef} />
      <div>
        <button onClick={undo} disabled={!isUndoable}>
          元に戻す
        </button>
        <button onClick={redo} disabled={!isRedoable}>
          やり直し
        </button>
        <button onClick={clear}>
          クリア
        </button>
      </div>
    </div>
  );
}

まとめ

signature_pad ライブラリをHooks化することで、undo/redo 機能を持つ署名入力機能を実装できるようになります。

余談

https://pwa-git-main-ssp0727lncs-projects.vercel.app

実際にこの画面については上記にて触って頂けます。

以下、SPでの画面撮影

スマホ画面撮影.gif

ログイン画面(ログインボタンを押すと何もしなくても入れます)
↓
印影設定から試せます

ちなみにPWAの検証試作だったので上記URLをブラウザから開いてホーム画面(または検索バーのインストールを押下すれば)に保存すればアプリっぽくなります。

Push通知の検証やindexedDBを使ったオフライン時でも内容の閲覧や登録ができるようにするなどネイティブアプリに寄せた作りになるように色々と技術検証していましたが大人の理由(?)で諦めた過去の思い出です。

ちなみにPWAとしてPush通知も検証できてますが今は機能を切ってます。
(また、別のネタとして書きますw)
※Push通知できたときの方がテンション上がった思い出🤣

Discussion