iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📝

Building a Digital Signature UI for Deliveries in React 🚛✒️

に公開

Introduction

I would like to introduce a simple implementation of a feature (handwritten character feature) that I created as a prototype but didn't have many chances to use!

I wrote some tips about opening and closing the signature area in the following article 🙇

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

About the feature

This is a free-hand signature feature for implementing electronic signatures in web applications.

  • Signature input
  • Undo functionality
  • Redo functionality
  • Clear functionality
  • Management of button enable/disable states

Library used

https://github.com/szimek/signature_pad

Outcome

*Screen layout details are omitted in the following explanation.

Signature Sample.gif

Full code for the 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,
  };
};

Implementation details

State management

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: Reference value for the ref to the Canvas element.
  • signaturePadRef: Reference value for the SignaturePad instance.
  • signatureRedoArray: An array to store history, managing the state of each stroke.
  • isRedoable: A flag for the button's disabled state.

Updating button states

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

It updates the "Undo" button state based on whether signature data exists and the "Redo" button state based on whether history exists.

Handling the input viewport

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();
    }
  }
}, []);

This is used as a handler for the resize event to get the effective dimensions of the input screen. However, note that if the window is resized during input, the signature state will be cleared.

Initialization process

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]);
  • Instantiates signaturePad (setting the background color).
  • Registers the resize event handler.
  • Updates the button states every time a stroke is completed.

Implementing Undo functionality

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]);
- Retrieves the current signature data
- Removes the last stroke
- Saves the removed stroke to the Redo array
- Updates the button states

The above steps are performed in this function.

Implementing Redo functionality

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]);
- Retrieves the last stroke from the Redo array
- Adds it to the current signature data
- Updates the button states

The above steps are performed in this function.

Implementing Clear functionality

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

It clears the signature and resets all history and button states.

Usage Example

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>
  );
}

Summary

By turning the signature_pad library into a Hook, you can implement a signature input feature with undo/redo functionality.

Side Note

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

You can actually try out the screen at the link above.

Below is a screen recording from a mobile device:

Smartphone Screen Recording.gif

Login screen (You can enter without doing anything just by pressing the login button)

You can try it from "Signature Settings" (印影設定).

By the way, since this was a PWA verification prototype, if you open the URL above in a browser and save it to your home screen (or click "Install" in the search bar), it will behave like an app.

I was conducting various technical verifications to make it feel like a native app, such as testing Push notifications and enabling viewing and registration of content even when offline using indexedDB, but it's now just a memory of a project I had to give up on for "grown-up reasons" (?).

As a side note, I have also verified Push notifications as a PWA, but that feature is currently disabled.
(I'll write about that in another post lol)
*I remember being so excited when Push notifications actually worked 🤣

Discussion