クリップボード アクセスのブロックを解除する

テキストと画像に対するクリップボード アクセスの安全性が向上し、ブロックが解除されました

システム クリップボードにアクセスする従来の方法は、クリップボード操作用の document.execCommand() を使用することでした。この切り取りと貼り付けの方法は広くサポートされていましたが、クリップボード アクセスが同期で、DOM の読み取りと書き込みしかできないという欠点がありました。

短いテキストの場合は問題ありませんが、クリップボード転送のためにページをブロックすると、ユーザー エクスペリエンスが低下するケースが多くあります。コンテンツを安全に貼り付ける前に、時間のかかるサニタイズや画像のデコードが必要になることがあります。ブラウザは、貼り付けられたドキュメントからリンクされたリソースを読み込むか、インラインで処理する必要がある場合があります。これにより、ディスクまたはネットワークの待機中にページがブロックされます。権限を追加して、クリップボードへのアクセスをリクエストしている間、ブラウザがページをブロックする必要がある場合を考えてみましょう。同時に、クリップボード操作に関する document.execCommand() の権限は緩やかに定義されており、ブラウザによって異なります。

非同期クリップボード API は、これらの問題に対処し、ページをブロックしない明確な権限モデルを提供します。Async Clipboard API は、ほとんどのブラウザでテキストと画像の処理に限定されていますが、サポートはブラウザによって異なります。以下の各セクションのブラウザ互換性の概要をよく確認してください。

コピー: データをクリップボードに書き込む

writeText()

テキストをクリップボードにコピーするには、writeText() を呼び出します。この API は非同期であるため、渡されたテキストが正常にコピーされたかどうかに応じて解決または拒否される Promise を writeText() 関数が返します。

async function copyPageUrl() {
  try {
    await navigator.clipboard.writeText(location.href);
    console.log('Page URL copied to clipboard');
  } catch (err) {
    console.error('Failed to copy: ', err);
  }
}

Browser Support

  • Chrome: 66.
  • Edge: 79.
  • Firefox: 63.
  • Safari: 13.1.

Source

write()

実際には、writeText() は汎用 write() メソッドの便利なメソッドにすぎません。このメソッドでは、画像をクリップボードにコピーすることもできます。writeText() と同様に、非同期で Promise を返します。

画像をクリップボードに書き込むには、画像を blob として取得する必要があります。この方法の 1 つは、fetch() を使用してサーバーからイメージをリクエストし、レスポンスで blob() を呼び出すことです。

さまざまな理由から、サーバーから画像をリクエストすることが望ましくない場合や、リクエストできない場合があります。幸いなことに、画像をキャンバスに描画して、キャンバスの toBlob() メソッドを呼び出すこともできます。

次に、ClipboardItem オブジェクトの配列をパラメータとして write() メソッドに渡します。現時点では一度に 1 つの画像しか渡すことができませんが、今後複数の画像をサポートする予定です。ClipboardItem は、画像の MIME タイプをキー、blob を値とするオブジェクトを受け取ります。fetch() または canvas.toBlob() から取得した blob オブジェクトの場合、blob.type プロパティには画像の正しい MIME タイプが自動的に含まれます。

try {
  const imgURL = '/images/generic/file.png';
  const data = await fetch(imgURL);
  const blob = await data.blob();
  await navigator.clipboard.write([
    new ClipboardItem({
      // The key is determined dynamically based on the blob's type.
      [blob.type]: blob
    })
  ]);
  console.log('Image copied.');
} catch (err) {
  console.error(err.name, err.message);
}

または、ClipboardItem オブジェクトに Promise を書き込むこともできます。このパターンでは、データの MIME タイプを事前に知っておく必要があります。

try {
  const imgURL = '/images/generic/file.png';
  await navigator.clipboard.write([
    new ClipboardItem({
      // Set the key beforehand and write a promise as the value.
      'image/png': fetch(imgURL).then(response => response.blob()),
    })
  ]);
  console.log('Image copied.');
} catch (err) {
  console.error(err.name, err.message);
}

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

コピー イベント

ユーザーがクリップボードのコピーを開始し、preventDefault() を呼び出さない場合、copy イベントには、すでに正しい形式のアイテムを含む clipboardData プロパティが含まれます。独自のロジックを実装する場合は、preventDefault() を呼び出して、独自の実装を優先してデフォルトの動作を回避する必要があります。この場合、clipboardData は空になります。テキストと画像を含むページを考えてみましょう。ユーザーがすべてを選択してクリップボードへのコピーを開始した場合、カスタム ソリューションはテキストを破棄し、画像のみをコピーする必要があります。これを実現するには、次のコードサンプルに示すようにします。この例では、Clipboard API がサポートされていない場合に以前の API にフォールバックする方法については説明していません。

<!-- The image we want on the clipboard. -->
<img src="kitten.webp" alt="Cute kitten.">
<!-- Some text we're not interested in. -->
<p>Lorem ipsum</p>
document.addEventListener("copy", async (e) => {
  // Prevent the default behavior.
  e.preventDefault();
  try {
    // Prepare an array for the clipboard items.
    let clipboardItems = [];
    // Assume `blob` is the blob representation of `kitten.webp`.
    clipboardItems.push(
      new ClipboardItem({
        [blob.type]: blob,
      })
    );
    await navigator.clipboard.write(clipboardItems);
    console.log("Image copied, text ignored.");
  } catch (err) {
    console.error(err.name, err.message);
  }
});

copy イベントの場合:

Browser Support

  • Chrome: 1.
  • Edge: 12.
  • Firefox: 22.
  • Safari: 3.

Source

ClipboardItem:

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

貼り付け: クリップボードからデータを読み取る

readText()

クリップボードからテキストを読み取るには、navigator.clipboard.readText() を呼び出し、返された Promise が解決されるのを待ちます。

async function getClipboardContents() {
  try {
    const text = await navigator.clipboard.readText();
    console.log('Pasted content: ', text);
  } catch (err) {
    console.error('Failed to read clipboard contents: ', err);
  }
}

Browser Support

  • Chrome: 66.
  • Edge: 79.
  • Firefox: 125.
  • Safari: 13.1.

Source

read()

navigator.clipboard.read() メソッドも非同期で、Promise を返します。クリップボードから画像を読み取るには、ClipboardItem オブジェクトのリストを取得し、それらを反復処理します。

ClipboardItem は異なる型でコンテンツを保持できるため、for...of ループを再度使用して、型のリストを反復処理する必要があります。タイプごとに、現在のタイプを引数として getType() メソッドを呼び出し、対応する BLOB を取得します。以前と同様に、このコードは画像に関連付けられておらず、将来の他のファイル形式でも機能します。

async function getClipboardContents() {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        const blob = await clipboardItem.getType(type);
        console.log(URL.createObjectURL(blob));
      }
    }
  } catch (err) {
    console.error(err.name, err.message);
  }
}

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 127.
  • Safari: 13.1.

Source

貼り付けたファイルの操作

ユーザーが ctrl+cctrl+v などのクリップボードのキーボード ショートカットを使用できると便利です。 Chromium は、以下に示すように、クリップボード上の読み取り専用ファイルを公開します。これは、ユーザーがオペレーティング システムのデフォルトの貼り付けショートカットを押したとき、またはブラウザのメニューバーで [編集]、[貼り付け] の順にクリックしたときにトリガーされます。これ以上の配管コードは必要ありません。

document.addEventListener("paste", async e => {
  e.preventDefault();
  if (!e.clipboardData.files.length) {
    return;
  }
  const file = e.clipboardData.files[0];
  // Read the file's contents, assuming it's a text file.
  // There is no way to write back to it.
  console.log(await file.text());
});

Browser Support

  • Chrome: 3.
  • Edge: 12.
  • Firefox: 3.6.
  • Safari: 4.

Source

貼り付けイベント

前述のとおり、Clipboard API と連携するイベントを導入する予定ですが、現時点では既存の paste イベントを使用できます。クリップボードのテキストを読み取る新しい非同期メソッドと連携して動作します。copy イベントと同様に、preventDefault() の呼び出しも忘れないでください。

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  const text = await navigator.clipboard.readText();
  console.log('Pasted text: ', text);
});

Browser Support

  • Chrome: 1.
  • Edge: 12.
  • Firefox: 22.
  • Safari: 3.

Source

複数の MIME タイプの処理

ほとんどの実装では、1 回の切り取りまたはコピー オペレーションで複数のデータ形式がクリップボードに配置されます。これには 2 つの理由があります。アプリ デベロッパーは、ユーザーがテキストや画像をコピーしたいアプリの機能を把握できないことと、多くのアプリが構造化データをプレーン テキストとして貼り付けることをサポートしていることです。通常、これは「スタイルを貼り付けて一致させる」や「書式なしで貼り付ける」などの名前の [編集] メニュー項目としてユーザーに表示されます。

次の例はその方法を示しています。この例では fetch() を使用して画像データを取得していますが、<canvas> または File System Access API から取得することもできます。

async function copy() {
  const image = await fetch('kitten.png').then(response => response.blob());
  const text = new Blob(['Cute sleeping kitten'], {type: 'text/plain'});
  const item = new ClipboardItem({
    'text/plain': text,
    'image/png': image
  });
  await navigator.clipboard.write([item]);
}

セキュリティと権限

クリップボードへのアクセスは、ブラウザにとって常にセキュリティ上の懸念事項でした。適切な権限がないと、ページはあらゆる種類の悪意のあるコンテンツをユーザーのクリップボードに密かにコピーし、貼り付けると壊滅的な結果が生じる可能性があります。rm -rf / または解凍爆弾画像をクリップボードに密かにコピーするウェブページを想像してみてください。

クリップボードの権限をユーザーに求めるブラウザのプロンプト。
Clipboard API の権限プロンプト。

ウェブページにクリップボードへの無制限の読み取りアクセス権を付与することは、さらに問題があります。ユーザーはパスワードや個人情報などの機密情報をクリップボードにコピーすることがよくありますが、その情報はユーザーの知らないうちにどのページからでも読み取られる可能性があります。

多くの新しい API と同様に、Clipboard API は HTTPS 経由で提供されるページでのみサポートされます。不正使用を防ぐため、クリップボードへのアクセスは、ページがアクティブなタブである場合にのみ許可されます。アクティブなタブのページは、権限をリクエストせずにクリップボードに書き込むことができますが、クリップボードから読み取るには常に権限が必要です。

コピー&ペーストの権限が Permissions API に追加されました。clipboard-write 権限は、ページがアクティブなタブになると自動的に付与されます。clipboard-read 権限をリクエストする必要があります。これは、クリップボードからデータを読み取ろうとすることで行えます。次のコードは後者を示しています。

const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
const permissionStatus = await navigator.permissions.query(queryOpts);
// Will be 'granted', 'denied' or 'prompt':
console.log(permissionStatus.state);

// Listen for changes to the permission state
permissionStatus.onchange = () => {
  console.log(permissionStatus.state);
};

allowWithoutGesture オプションを使用して、切り取りや貼り付けを呼び出すためにユーザー ジェスチャーが必要かどうかを制御することもできます。この値のデフォルトはブラウザによって異なるため、常に含める必要があります。

クリップボード API の非同期性が真価を発揮するのは、まさにこの点です。クリップボード データの読み取りまたは書き込みを試みると、まだ許可されていない場合は、ユーザーに権限の付与を求めるプロンプトが自動的に表示されます。この API は Promise ベースであるため、この処理は完全に透過的です。ユーザーがクリップボードの権限を拒否すると、Promise が拒否され、ページが適切に応答できるようになります。

ブラウザでは、ページがアクティブなタブの場合にのみクリップボードへのアクセスが許可されるため、ここに記載されている例の一部は、ブラウザのコンソールに直接貼り付けても実行されません。これは、デベロッパー ツール自体がアクティブなタブであるためです。ここで、ちょっとした工夫があります。setTimeout() を使用してクリップボード アクセスを遅延させ、関数が呼び出される前にページ内をすばやくクリックしてフォーカスを当てます。

setTimeout(async () => {
  const text = await navigator.clipboard.readText();
  console.log(text);
}, 2000);

権限に関するポリシーの統合

iframe で API を使用するには、Permissions Policy で有効にする必要があります。Permissions Policy は、さまざまなブラウザ機能と API を選択的に有効または無効にするメカニズムを定義します。具体的には、アプリのニーズに応じて、clipboard-read または clipboard-write のいずれかまたは両方を渡す必要があります。

<iframe
    src="index.html"
    allow="clipboard-read; clipboard-write"
>
</iframe>

特徴検出

すべてのブラウザをサポートしながら Async Clipboard API を使用するには、navigator.clipboard をテストして、以前のメソッドにフォールバックします。たとえば、他のブラウザを含めるように貼り付けを実装する方法は次のとおりです。

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  }
  else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('Got pasted text: ', text);
});

しかし、これだけではありません。Async Clipboard API の登場以前は、ウェブブラウザごとにコピー&ペーストの実装が異なっていました。ほとんどのブラウザでは、document.execCommand('copy')document.execCommand('paste') を使用してブラウザ独自のコピー&ペーストをトリガーできます。コピーするテキストが DOM に存在しない文字列の場合は、DOM に挿入して選択する必要があります。

button.addEventListener('click', (e) => {
  const input = document.createElement('input');
  input.style.display = 'none';
  document.body.appendChild(input);
  input.value = text;
  input.focus();
  input.select();
  const result = document.execCommand('copy');
  if (result === 'unsuccessful') {
    console.error('Failed to copy text.');
  }
  input.remove();
});

デモ

以下のデモで Async Clipboard API を試すことができます。最初の例は、クリップボードとの間でテキストを移動する方法を示しています。

画像で API を試すには、このデモを使用します。PNG のみがサポートされており、一部のブラウザでのみサポートされていることを思い出してください。

謝辞

非同期クリップボード API は Darwin HuangGary Kačmarčík によって実装されました。Darwin もデモを提供しました。この記事の一部を推敲してくれた Kyarik と Gary Kačmarčík に改めて感謝します。

ヒーロー画像: Markus WinklerUnsplash