5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-12-05

はじめに

この記事は 株式会社TRAILBLAZER Advent Calendar 2025 の5日目の記事です。

昨日に引き続きTRAILBLAZERでフロントエンドエンジニアをしている田原です。

連日ですが書かせてもらえることになったので試作を作ってみたけど使う機会に恵まれなかった機能について(手書き文字機能)簡単実装を紹介したいと思います!(業務上の機能ではなくあくまで個人的なヤツです)

機能について

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

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

使用ライブラリ

成果物

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

署名サンプル.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 機能を持つ署名入力機能を実装できるようになります。

余談

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

以下、SPでの画面撮影

スマホ画面撮影.gif

ログイン画面(ログインボタンを押すと何もしなくても入れます)

印影設定から試せます

署名時にLandscapeとして画面をレスポンシブで切り替えたくなかったので最初から横向きで表示されるようにしてユーザー側が端末を横向きにするだけで大丈夫なように基本的に縦画面の状態が続くようにしております。

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

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

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

最後に

本記事を最後まで読んで頂きありがとうございます:bow:

TRAILBLAZERでは一緒に働くメンバーを募集中です!!
皆さまからのご連絡お待ちしております:train:

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?