Reactで、貼り付けた画像を受け取って表示する方法

​​React​Clipboard API,File API,Playwright,TypeScript

入力欄に貼り付けた画像は、onPaste で受け取った e.clipboardData.files から取り出せます。画像があるときだけ preventDefault() を呼べば、文字はこれまでどおり入力欄に貼り付けられます。画像の表示には URL.createObjectURL() で作った URL を使い、useEffect の後片付けで解放します。

コメント欄に画像の貼り付けを足したコンポーネントを作り、貼り付けたときの中身と表示を1つずつ確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。貼り付ける画像は、ページの中で canvas に描いた PNG を navigator.clipboard.write() でクリップボードに入れて用意し、貼り付けは Ctrl+V のキー操作で行いました。OS のスクリーンショットや、ほかのアプリからコピーした画像、Firefox と Safari、スマートフォンでの貼り付けは試していません。

コメント欄で Ctrl+V を3回押す。1回目と2回目は画像が一覧に加わり、3回目は文字が入力欄に入る
Ctrl+V で画像を2回、文字を1回貼り付けたときの動き(変わったところをピンクで強調)

貼り付けた画像は clipboardData.files に入る

React では、onPaste に渡すハンドラーが受け取るイベントから、e.clipboardData を読めます。中身は、ドラッグ&ドロップの dataTransfer と同じ DataTransfer です。MDN の DataTransfer.files の説明にも、paste のハンドラーでは clipboardData からファイルを読めると書かれています。

検証で、入力欄に画像と文字を貼り付けたときの clipboardData を出すと、次のようになりました。

text
--- 1. 画像を貼り付ける ---
paste イベントの中身: {"target":"textarea","types":["Files"],"files":["image.png:image/png:254"],"items":["file:image/png"]}
--- 3. 文字を貼り付ける ---
paste イベントの中身: {"target":"textarea","types":["text/plain"],"files":[],"items":["string:text/plain"]}

画像のときは、files に File が1つ入っています。items からも、kind が file の項目として見えます。文字のときは、files は空です。

検証した Chromium では、貼り付けた画像の名前は2枚目も image.png でした。名前では見分けられないので、この記事のコンポーネントでは、貼り付けるたびに番号を振って key に使っています。

入力欄に画像の貼り付けを足すコンポーネント

PasteImageBox は、コメントを書く textarea と、貼り付けた画像の一覧を持つコンポーネントです。画像は File のまま state に入れ、表示は一覧の1行ごとの Preview に任せています。

tsx
import { useEffect, useState, type ClipboardEvent } from 'react';

type Pasted = { id: number; file: File };

let nextId = 1;

// File から表示用の URL を作り、使わなくなったら解放する
function useObjectUrl(file: File) {
  const [url, setUrl] = useState<string>();
  useEffect(() => {
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    return () => URL.revokeObjectURL(objectUrl);
  }, [file]);
  return url;
}

function Preview({ file, onRemove }: { file: File; onRemove: () => void }) {
  const url = useObjectUrl(file);
  return (
    <li>
      {url && <img src={url} alt={`貼り付けた画像(${file.name})`} width={120} />}
      <span>
        {file.name}({file.type}、{file.size}バイト)
      </span>
      <button type="button" onClick={onRemove}>
        削除
      </button>
    </li>
  );
}

export function PasteImageBox() {
  const [text, setText] = useState('');
  const [images, setImages] = useState<Pasted[]>([]);
  const [message, setMessage] = useState('');

  const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
    const files = [...e.clipboardData.files].filter((file) => file.type.startsWith('image/'));
    // 画像がなければ何もしない。文字はふだんどおり入力欄に入る
    if (files.length === 0) return;
    // 画像のときは、入力欄に何も入らないように止める
    e.preventDefault();
    setImages((prev) => [...prev, ...files.map((file) => ({ id: nextId++, file }))]);
    setMessage(`画像を${files.length}枚貼り付けました`);
  };

  const remove = (id: number) => {
    setImages((prev) => prev.filter((image) => image.id !== id));
  };

  return (
    <div className="box">
      <label htmlFor="comment">コメント(画像も貼り付けられます)</label>
      <textarea
        id="comment"
        value={text}
        onChange={(e) => setText(e.target.value)}
        onPaste={handlePaste}
        rows={4}
      />
      <p role="status">{message}</p>
      <ul>
        {images.map(({ id, file }) => (
          <Preview key={id} file={file} onRemove={() => remove(id)} />
        ))}
      </ul>
    </div>
  );
}

handlePaste では、files のうち種類が image/ で始まるものだけを取り出します。検証で画像を2枚続けて貼り付けると、2つとも一覧に並び、img に読み込まれました。

text
貼り付けたあと: {"list":["image.png(image/png、254バイト)","image.png(image/png、170バイト)"],"imgs":[{"loaded":true,"w":80,"h":60},{"loaded":true,"w":40,"h":40}]}

削除ボタンは button なので、キーボードでも押せます。画像を貼り付けたあとに Tab を押すと「削除」にフォーカスが移り、Enter で一覧から消えました。

画像があるときだけ preventDefault を呼ぶ

MDN の paste イベントの説明によれば、textarea のような編集できる場所では、クリップボードの中身をカーソルの位置に入れるのが、ブラウザの既定の動きです。これを止めるには preventDefault() を呼びます。

handlePaste は、画像がなければ何もせずに戻ります。そのため、文字を貼り付けたときは既定の動きのまま、入力欄に入ります。検証でも「猫の写真です」を貼り付けると入力欄に入り、画像の一覧は変わりませんでした。

text
貼り付けたあと: {"textarea":"猫の写真です","list":["image.png(image/png、254バイト)","image.png(image/png、170バイト)"]}

画像だけを貼り付けたときは、preventDefault() を呼ばない版でも、入力欄には何も入りませんでした。違いが出るのは、クリップボードに文字と画像が一緒に入っているときです。MDN の ClipboardItem の説明にあるとおり、クリップボードの1つの項目は、同じ内容を複数の形式で持てます。検証では、文字と PNG を1つの ClipboardItem に入れて貼り付けました。

text
--- 4. 文字と画像が両方入ったクリップボードを貼り付ける ---
paste イベントの中身: {"target":"textarea","types":["text/plain","Files"],"files":["image.png:image/png:137"],"items":["string:text/plain","file:image/png"]}
貼り付けたあと: {"textarea":"","list":["image.png(image/png、254バイト)","image.png(image/png、170バイト)","image.png(image/png、137バイト)"]}
--- 6. 画像を貼り付けても preventDefault を呼ばない版 ---
画像だけのとき: 入力欄="" 一覧=["image.png"]
文字と画像が両方のとき: 入力欄="表のセルの文字" 一覧=["image.png","image.png"]

PasteImageBox では、画像だけが一覧に加わり、入力欄は空のままでした。preventDefault() を呼ばない版では、先に画像だけを貼り付けてから、文字と画像が両方入ったものを貼り付けました。2回目は、画像が一覧に加わったうえに、文字も入力欄に入りました。どちらがよいかは、作る画面によります。画像として受け取ったものを文字でも入れたくないときは、この記事のように止めます。両方とも入れたいときは、preventDefault() の行を消します。

表示用の URL は effect の中で作って解放する

img の src には URL を渡すので、URL.createObjectURL() で blob: で始まる URL を作ります。MDN の blob URL の説明にある、メモリーの扱いの節によれば、この URL は作るたびに新しくでき、有効なあいだは元のデータがメモリーから消えません。ページを閉じれば解放されますが、使い終わった時点で URL.revokeObjectURL() を呼ぶよう勧められています。

useObjectUrl は、useEffect の中で URL を作り、後片付けの関数で解放します。一覧から画像を消すと Preview がアンマウントされ、そのときに後片付けが動きます。検証で1枚目を削除してから、それぞれの URL を fetch で読むと、削除したほうだけが読めなくなっていました。

text
削除した画像の URL: 読めない(TypeError)
残っている画像の URL: 読める(170バイト)

レンダー中に URL を作ると、開発中に画像が出ない

URL を useMemo でレンダー中に作り、useEffect の後片付けで解放する書き方も考えられます。

tsx
function MemoPreview({ file }: { file: File }) {
  const url = useMemo(() => URL.createObjectURL(file), [file]);
  useEffect(() => () => URL.revokeObjectURL(url), [url]);
  return <img src={url} alt="memo" width={120} />;
}

この書き方は、StrictMode の中で開発用ビルドを動かすと画像が出ません。StrictMode のリファレンスにあるとおり、開発中はすべての effect で、準備と後片付けがもう1回ずつ余分に動きます。1回目の後片付けで URL が解放され、2回目の準備では新しい URL を作らないので、img には解放済みの URL が残ります。検証で同じ画像を両方の書き方で表示すると、次のようになりました。

text
--- 7. 開発用ビルド(StrictMode)で、URL を作る場所を比べる ---
画像の読み込み: ["memo: 表示されない(naturalWidth=0)","effect: 表示された(naturalWidth=50)"]
本番用ビルドでは: ["memo: 表示された(naturalWidth=50)","effect: 表示された(naturalWidth=50)"]

本番用ビルドでは両方とも表示されるので、開発中だけの問題に見えます。それでも、URL を作る処理と解放する処理を同じ effect に入れておけば、後片付けのあとの準備で URL を作り直すので、解放済みの URL が表示に残ることはありません。

ページのどこで貼り付けても受け取るには

onPaste を付けた textarea が受け取れるのは、そこにフォーカスがあるときの貼り付けだけです。検証で、ページの何もないところをクリックしてから Ctrl+V を押すと、paste イベントは起きましたが、PasteImageBox の一覧には何も加わりませんでした。

MDN の paste イベントの説明によれば、このイベントは Document まで伝わってきます。ページのどこで貼り付けても受け取りたいときは、PasteImageBox の onPaste の代わりに、useEffect で document にリスナーを付けます。この版では、受け取った画像を files の state に足します。

tsx
useEffect(() => {
  const onPaste = (e: globalThis.ClipboardEvent) => {
    if (!e.clipboardData) return;
    const images = imagesOf(e.clipboardData);
    if (images.length === 0) return;
    e.preventDefault();
    setFiles((prev) => [...prev, ...images]);
  };
  document.addEventListener('paste', onPaste);
  return () => document.removeEventListener('paste', onPaste);
}, []);

imagesOf は、handlePaste と同じく clipboardData.files から画像だけを取り出す関数です。この版では、何もないところで貼り付けた画像も一覧に加わりました。画像がなければ何もしないので、この版のコンポーネントに置いた件名の入力欄に文字を貼り付けると、そのまま入りました。

text
document で受ける版の一覧: [ 'image.png' ]
件名の入力欄に文字を貼り付けたあと: "お知らせ"
ポイント

貼り付けで受け取った画像は、ふつうの File です。PasteImageBox は一覧の state に File のまま入れているので、以前の記事「Reactでファイルをアップロードし、進み具合を表示する方法」と同じように、FormData に File を足してサーバーに送れます。

​Posted by 86