Reactで、指やマウスで手書きできる署名欄を作る方法

​​React​Canvas API,Playwright,Pointer Events,TypeScript

手書きの署名欄は、canvas と Pointer Events だけで作れます。Pointer Events なら、マウスも指もペンも同じイベントで受け取れます。書いた線は canvas の絵としてではなく、点の座標の配列として state に持ちます。こうすると「1画戻す」「消す」、画面の幅が変わったときの描き直し、画像への書き出しを、どれも同じ配列をもとに行えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。マウスは Playwright の page.mouse で、指の操作は Chrome DevTools Protocol(CDP)でタッチを送って試しました。実機のスマートフォンやタブレット、ペンの筆圧、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。書いた署名をサーバーへ送る部分も、この記事では扱いません。

空の署名欄に輪が続く線を書き、続けて斜めの線を書き、「1画戻す」で斜めの線だけを消し、「画像にする」で線のある範囲だけの画像が下に出る
4回の操作で、署名欄と下の表示が変わる様子。変わった表示だけピンクにしている。書き出した画像の市松模様は、透明な部分を表す背景(カーソルと操作の表示は GIF 用に重ねたもの)

線を点の配列で持つ署名欄のコンポーネント

SignaturePad は、書かれた線の一覧 strokes を親から受け取ります。1画を書き終えるたびに、その1画を足した一覧を onChange で親に渡します。1画は、押してから離すまでに通った点の並びです。点は canvas の左上からの CSS px で持ちます。

tsx
import { useEffect, useRef } from 'react';
import type { PointerEvent } from 'react';

// 点は canvas の左上からの CSS px で持つ。1画は、押してから離すまでの点の並び
export type Point = { x: number; y: number };
export type Stroke = Point[];

type Props = {
  strokes: Stroke[];
  onChange: (strokes: Stroke[]) => void;
  label: string;
  height?: number;
};

const LINE_WIDTH = 2.5;
const INK = '#1f2a30';

// 線を描く。画面の canvas と、画像に書き出すときの canvas の両方で使う
export function drawStrokes(ctx: CanvasRenderingContext2D, strokes: Stroke[]) {
  ctx.lineWidth = LINE_WIDTH;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.strokeStyle = INK;
  ctx.fillStyle = INK;
  for (const stroke of strokes) {
    const [first, ...rest] = stroke;
    if (rest.length === 0) {
      // 押してすぐ離したときは、点を1つ打つ
      ctx.beginPath();
      ctx.arc(first.x, first.y, LINE_WIDTH / 2, 0, Math.PI * 2);
      ctx.fill();
      continue;
    }
    ctx.beginPath();
    ctx.moveTo(first.x, first.y);
    for (const p of rest) ctx.lineTo(p.x, p.y);
    ctx.stroke();
  }
}

export function SignaturePad({ strokes, onChange, label, height = 180 }: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  // 書いている途中の1画と、それを書いているポインター
  const current = useRef<{ pointerId: number; stroke: Stroke } | null>(null);

  // 表示の大きさと画面の倍率に合わせて canvas の画素数を決め、全部の線を描き直す
  useEffect(() => {
    const canvas = canvasRef.current!;
    const observer = new ResizeObserver(() => {
      const dpr = window.devicePixelRatio || 1;
      canvas.width = Math.round(canvas.clientWidth * dpr);
      canvas.height = Math.round(canvas.clientHeight * dpr);
      const ctx = canvas.getContext('2d')!;
      ctx.scale(dpr, dpr);
      drawStrokes(ctx, strokes);
    });
    observer.observe(canvas);
    return () => observer.disconnect();
  }, [strokes]);

  function pointFrom(e: PointerEvent<HTMLCanvasElement>): Point {
    const r = e.currentTarget.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  }

  function handlePointerDown(e: PointerEvent<HTMLCanvasElement>) {
    // 2本目の指や、マウスの左ボタン以外は無視する
    if (current.current || e.button !== 0) return;
    // ポインターが枠の外へ出ても、離すまでこの canvas にイベントが届くようにする
    e.currentTarget.setPointerCapture(e.pointerId);
    const p = pointFrom(e);
    current.current = { pointerId: e.pointerId, stroke: [p] };
    drawStrokes(e.currentTarget.getContext('2d')!, [[p]]);
  }

  function handlePointerMove(e: PointerEvent<HTMLCanvasElement>) {
    const c = current.current;
    if (!c || c.pointerId !== e.pointerId) return;
    const p = pointFrom(e);
    const prev = c.stroke[c.stroke.length - 1];
    c.stroke.push(p);
    // 書いている間は、前の点からの1区間だけを描き足す
    drawStrokes(e.currentTarget.getContext('2d')!, [[prev, p]]);
  }

  function handlePointerUp(e: PointerEvent<HTMLCanvasElement>) {
    const c = current.current;
    if (!c || c.pointerId !== e.pointerId) return;
    current.current = null;
    onChange([...strokes, c.stroke]);
  }

  return (
    <canvas
      ref={canvasRef}
      className="signature-pad"
      style={{ height }}
      role="img"
      aria-label={label}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
      onPointerCancel={handlePointerUp}
    />
  );
}

書いている途中の1画は state ではなく useRef に置いています。ポインターが動くたびに state を変えると、そのたびに再レンダリングが起きるためです。ref の current を書き換えても、React は再レンダリングしません。書いている間は、前の点から今の点までの1区間だけを canvas に描き足します。離したときに1画を確定して onChange を呼び、そこで初めて state が変わります。

pointerdown では、書いている途中の1画があれば何もしません。こうすると、2本目の指が触れても線は増えません。検証環境で2本の指を同時に動かしたところ、記録されたのは最初の指の1画だけで、2本目の指がなぞった高さの行には色の付いた画素がありませんでした。

text
## 2本の指で同時に触れたとき
1画 / y=60 の行 202 画素 / y=140 の行 0 画素

押してすぐ離したときは、点が1つだけの1画になります。drawStrokes はこれを小さな円として描くので、「i」の点のような書き方も残ります。

親の側では、strokes を useState で持ちます。「1画戻す」は配列の最後を取り除くだけ、「消す」は空の配列にするだけです。

tsx
import { useEffect, useState } from 'react';
import { SignaturePad } from './SignaturePad';
import type { Stroke } from './SignaturePad';
import { exportSignature } from './exportSignature';

export function App() {
  const [strokes, setStrokes] = useState<Stroke[]>([]);
  const [saved, setSaved] = useState<{ url: string; width: number; height: number } | null>(null);
  const empty = strokes.length === 0;

  // 前に作った画像の URL は、置き換えたときと画面を離れるときに解放する
  useEffect(() => () => { if (saved) URL.revokeObjectURL(saved.url); }, [saved]);

  async function handleSave() {
    const blob = await exportSignature(strokes);
    const bitmap = await createImageBitmap(blob);
    setSaved({ url: URL.createObjectURL(blob), width: bitmap.width, height: bitmap.height });
  }

  return (
    <main>
      <p className="pad-label">署名欄</p>
      <SignaturePad label="署名欄" strokes={strokes} onChange={setStrokes} />
      <div className="buttons">
        <button type="button" onClick={() => setStrokes(strokes.slice(0, -1))} disabled={empty}>
          1画戻す
        </button>
        <button type="button" onClick={() => setStrokes([])} disabled={empty}>
          消す
        </button>
        <button type="button" onClick={handleSave} disabled={empty}>
          画像にする
        </button>
      </div>
      <p className="status" aria-live="polite">{empty ? 'まだ書かれていません' : `${strokes.length}画`}</p>
      {saved && (
        <figure>
          <img className="result" src={saved.url} alt="書き出した署名" />
          <figcaption>書き出した画像:{saved.width}×{saved.height}px</figcaption>
        </figure>
      )}
    </main>
  );
}

マウスで2本の線を書き、続けて1回クリックしてから、「1画戻す」と「消す」を押しました。クリックで打った点は12画素ぶんで、「1画戻す」でちょうどその分が消えています。空のときは3つのボタンが押せなくなります。

text
## マウスで書く・1画戻す・消す
はじめ: まだ書かれていません / 色の付いた画素 0
1画目(線): 1画 / 色の付いた画素 490
2画目(線): 2画 / 色の付いた画素 1010
3画目(押してすぐ離す): 3画 / 色の付いた画素 1022
「1画戻す」: 2画 / 色の付いた画素 1010
「消す」: まだ書かれていません / 色の付いた画素 0 / 1画戻す:押せない 消す:押せない 画像にする:押せない

画面の倍率に合わせて canvas の画素数を決める

canvas の width と height は、表示の大きさとは別に持つ、絵の画素数です。表示が 364×176 の canvas でも、画素数が同じ 364×176 のままだと、高精細な画面では1画素が引き伸ばされます。MDN の devicePixelRatio の説明にも、Retina の画面では canvas がぼやけて見えることがあると書かれています。

そこで ResizeObserver のコールバックで、表示の大きさに devicePixelRatio を掛けた値を画素数にしています。そのあと ctx.scale(dpr, dpr) をかけておけば、描くときの座標は CSS px のままで済みます。Playwright で deviceScaleFactor を変えて開くと、表示の大きさは同じまま、画素数が倍率に合わせて変わりました。

text
## 画面の倍率と canvas の画素数
devicePixelRatio 1: 表示 364×176 / canvas の画素 364×176
devicePixelRatio 2: 表示 364×176 / canvas の画素 728×352
devicePixelRatio 3: 表示 364×176 / canvas の画素 1092×528

幅が変わったら、点の配列から描き直す

canvas の width に値を入れると、描いた絵はすべて消えます。MDN の HTMLCanvasElement.width の説明によると、今と同じ値を入れ直しても消えます。スマートフォンを横向きにするなどで署名欄の幅が変われば、画素数を決め直すので、そのたびに線が消えることになります。

この署名欄は、ResizeObserver のコールバックで画素数を決め直したあと、strokes から全部の線を描き直します。画面の幅を 400 から 320 に縮めると、canvas の画素数は変わりましたが、色の付いた画素の数は変わりませんでした。比べるために canvas.width に同じ値を代入し直すと、その場で 0 になりました。

text
## 表示の幅が変わったとき
幅 400 の画面で書いた後: canvas の画素 364×176 / 色の付いた画素 812
画面の幅を 320 にした後: canvas の画素 284×176 / 色の付いた画素 812 / 1画
canvas.width に同じ値を代入し直した直後: 色の付いた画素 0

useEffect の依存配列に strokes を入れているので、strokes が変わるたびに ResizeObserver が作り直されます。検証環境では、作り直した直後にもコールバックが1回呼ばれ、「1画戻す」や「消す」の結果がそのまま描き直されました(マウスで書いて「1画戻す」「消す」を押した検証の、1010 画素と 0 画素)。消す処理を別に書く必要はありません。

枠の外へはみ出したときと、指でなぞったとき

pointerdown で呼んでいる setPointerCapture() は、離すまでのポインターのイベントを、この canvas に届けるためのものです。署名は勢いで枠の外まで書いてしまうことがあります。これがないと、枠の外で離したことを canvas が受け取れません。

マウスで線を書きながら枠の下の外へ出て離し、そのあとマウスのボタンを押さずに枠の中へ戻しました。setPointerCapture() を何もしない関数に置き換えた場合と比べています。

text
## 枠の外へはみ出したとき(setPointerCapture の有無)
あり: 枠の外で離した後: 1画 / 色の付いた画素 743 / 押さずに枠の中へ戻った後: 1画 / 色の付いた画素 743 / canvas に届いた pointerup・pointercancel: pointerup
なし: 枠の外で離した後: まだ書かれていません / 色の付いた画素 650 / 押さずに枠の中へ戻った後: まだ書かれていません / 色の付いた画素 1401 / canvas に届いた pointerup・pointercancel: なし

setPointerCapture() がないと、枠の外で離したときの pointerup が canvas に届きません。そのため1画が確定せず、表示は「まだ書かれていません」のままです。さらに、書いている途中の1画が残っているので、マウスのボタンを押さずに枠の中へ戻っただけで線が描き足されました(色の付いた画素が 650 から 1401 に増えています)。なお、指で触れる画面では、ブラウザが pointerdown の時点で自動的にキャプチャすることが、MDN の Pointer events の説明に書かれています。この比較が効いてくるのは、主にマウスのように、押した時点で自動的にはキャプチャされないポインターです。

指でなぞるときは、もう一つ、ページのスクロールとの取り合いがあります。何も指定しないと、canvas の上で指を動かしてもブラウザがページをスクロールしてしまいます。これを止めるのが CSS の touch-action です。

css
.signature-pad {
  /* 指でなぞったときに、ページのスクロールや拡大をさせない */
  touch-action: none;
}

ページを縦に長くしてスクロールできるようにし、CDP で送ったタッチで、canvas の上を下から上へなぞりました。touch-action: auto に戻した場合と比べています。

text
## 指でなぞったとき(CDP でタッチを送る。ページは縦に長くしてスクロールできるようにした)
touch-action: none: 1画 / 色の付いた画素 612 / ページのスクロール量 0px / canvas のイベント: pointerdown, pointerup
touch-action: auto: 1画 / 色の付いた画素 74 / ページのスクロール量 144px / canvas のイベント: pointerdown, pointercancel

auto のときは、ページが 144px スクロールし、canvas には pointerup ではなく pointercancel が届きました。ブラウザがスクロールを始めた時点で、ポインターのイベントが打ち切られたためです。この署名欄は pointercancel でも1画を確定するので「1画」と出ていますが、残ったのは書き始めの短い線だけでした(色の付いた画素は 74)。

ポイント

署名欄では、setPointerCapture() と touch-action: none の両方が要ります。前者は枠の外へはみ出したときのため、後者は指でなぞったときにページがスクロールしないようにするためです。

書いた署名を画像にする

画面の canvas をそのまま画像にすると、画像の大きさが画面の幅と倍率で変わってしまいます。そこで書き出し用の canvas を別に作り、線のある範囲だけを決まった倍率で描き直します。描く処理には、画面と同じ drawStrokes を使います。

ts
import { drawStrokes } from './SignaturePad';
import type { Stroke } from './SignaturePad';

type Options = {
  type?: 'image/png' | 'image/jpeg';
  // 指定しなければ背景は透明のまま
  background?: string;
  // 1 CSS px を何画素にするか
  scale?: number;
  // 線のまわりに残す余白(CSS px)
  padding?: number;
};

// 線のある範囲だけを、新しい canvas に描いて画像にする
export function exportSignature(
  strokes: Stroke[],
  { type = 'image/png', background, scale = 2, padding = 8 }: Options = {},
): Promise<Blob> {
  const points = strokes.flat();
  let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
  for (const p of points) {
    left = Math.min(left, p.x);
    top = Math.min(top, p.y);
    right = Math.max(right, p.x);
    bottom = Math.max(bottom, p.y);
  }
  left -= padding;
  top -= padding;
  const canvas = document.createElement('canvas');
  canvas.width = Math.ceil((right + padding - left) * scale);
  canvas.height = Math.ceil((bottom + padding - top) * scale);
  const ctx = canvas.getContext('2d')!;
  if (background) {
    ctx.fillStyle = background;
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  }
  ctx.scale(scale, scale);
  ctx.translate(-left, -top);
  drawStrokes(ctx, strokes);
  return new Promise((resolve, reject) => {
    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('画像にできませんでした'))), type);
  });
}

toBlob() は、形式を指定しなければ PNG で書き出します。画面の倍率を 2 にして開き、横 200px・縦 70px の範囲に線を書いて書き出しました。結果は、余白 8px を足して scale の既定値 2 を掛けた 432×172px の画像でした。exportSignature は画面の倍率を使わないので、画像の大きさは scale だけで決まります。ほかの倍率の画面で書き出して比べてはいません。背景を指定しなかった PNG は、左上の画素の不透明度(A)が 0 で、透明のままです。

text
## 画像に書き出す
2画 / 書き出した画像:432×172px
PNG の左上の画素(R, G, B, A): 0, 0, 0, 0
JPEG、background なし: image/jpeg / 左上の画素(R, G, B, A): 0, 0, 0, 255
JPEG、background '#fff': image/jpeg / 左上の画素(R, G, B, A): 255, 255, 255, 255

JPEG で書き出すときは、背景の指定を忘れないようにします。JPEG には透明がないので、透明な部分は黒になります。HTML の仕様でも、透明を持てない形式では、不透明な黒の背景に重ねて書き出すと決められています。exportSignature と同じ手順(先に背景を塗ってから線を描き、toBlob() で JPEG にする)を別の canvas で試したところ、塗らないと左上の画素は黒(0, 0, 0)、#fff で塗ると白になりました。黒い背景に黒い線では、署名が読めません。

​Posted by 86