テキストと画像のクリップボードへのアクセスを安全にブロック解除
従来、システム クリップボードにアクセスするには、クリップボード操作用の document.execCommand()
を使用していました。広くサポートされているものの、このカットと貼り付けの方法は、クリップボードへのアクセスが同期的に行われ、DOM に対する読み取りと書き込みしか行われていなかったために、代償が伴いました。
短いテキストの場合は問題ありませんが、クリップボードへの転送のためにページをブロックすると、操作性が低下するケースがよくあります。コンテンツを安全に貼り付けるには、時間のかかるサニタイズや画像のデコードが必要になる場合があります。ブラウザは、貼り付けたドキュメントからリンクされたリソースを読み込むか、インライン化する必要があります。これにより、ディスクまたはネットワークを待機している間、ページがブロックされます。権限を追加して、クリップボードへのアクセスをリクエストする際にブラウザがページをブロックするようにするとします。一方、クリップボード操作用の document.execCommand()
に設定されている権限は、厳密に定義されておらず、ブラウザによって異なります。
Async Clipboard API は、これらの問題に対処し、ページをブロックしない、明確に定義された権限モデルを提供します。Async Clipboard API は、ほとんどのブラウザでテキストと画像の処理に限定されていますが、サポートは異なります。次の各セクションで、ブラウザの互換性に関する概要をよく確認してください。
コピー: クリップボードへのデータの書き込み
writeText()
テキストをクリップボードにコピーするには、writeText()
を呼び出します。この API は非同期であるため、writeText()
関数は、渡されたテキストが正常にコピーされたかどうかに応じて解決または拒否される Promise を返します。
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);
}
}
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
オブジェクトにプロミスを書き込むこともできます。このパターンでは、データの 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);
}
コピー イベント
ユーザーがクリップボードのコピーを開始し、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
イベントの場合:
ClipboardItem
:
貼り付け: クリップボードからデータを読み取っている
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);
}
}
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);
}
}
貼り付けたファイルの操作
ユーザーが ctrl+c や ctrl+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());
});
貼り付けイベント
前述のように、Clipboard API と連携するイベントを導入する予定ですが、現時点では既存の paste
イベントを使用できます。これは、クリップボードのテキストを読み取る新しい非同期メソッドとうまく連携します。copy
イベントと同様に、preventDefault()
を呼び出すことを忘れないでください。
document.addEventListener('paste', async (e) => {
e.preventDefault();
const text = await navigator.clipboard.readText();
console.log('Pasted text: ', text);
});
複数の 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 /
や圧縮爆弾画像をクリップボードにサイレントでコピーするウェブページがあるとします。
ウェブページにクリップボードへの無制限の読み取りアクセス権を付与するのは、さらに厄介です。ユーザーはパスワードや個人情報などの機密情報をクリップボードに日常的にコピーしており、それがユーザーの知らないうちにどのページからでも読み取られる可能性があります。
多くの新しい 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
オプションを使用して、カットまたは貼り付けを呼び出すためにユーザー ジェスチャーが必要かどうかを制御することもできます。この値のデフォルトはブラウザによって異なるため、常に含める必要があります。
ここで、Clipboard API の非同期性がとても役に立ちます。クリップボード データを読み書きしようとすると、まだ権限が付与されていない場合、ユーザーに自動的に許可を求めるプロンプトが表示されます。この API は Promise ベースであるため、これは完全に透過的です。ユーザーがクリップボードの権限を拒否すると Promise が拒否されるため、ページが適切に応答できるようになります。
ブラウザでは、ページがアクティブなタブの場合にのみクリップボードへのアクセスが許可されます。そのため、一部の例はブラウザのコンソールに直接貼り付けると実行されません。デベロッパー ツール自体がアクティブなタブであるためです。setTimeout()
を使用してクリップボードへのアクセスを延期し、関数が呼び出される前にページ内をクリックしてフォーカスを合わせるという方法があります。
setTimeout(async () => {
const text = await navigator.clipboard.readText();
console.log(text);
}, 2000);
権限ポリシーの統合
iframe で API を使用するには、権限ポリシーで API を有効にする必要があります。権限ポリシーは、さまざまなブラウザ機能と 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 は、以下のデモで試すことができます。Glitch では、テキストのデモまたは画像のデモをリミックスして試すことができます。
最初の例では、クリップボードにテキストを移動する方法を紹介します。
画像で API を試すには、このデモを使用します。サポートされているのは PNG のみで、一部のブラウザでのみ使用できます。
関連リンク
謝辞
非同期クリップボード API は、Darwin Huang と Gary Kačmarčík によって実装されました。Darwin もデモを提供しました。この記事の一部を確認してくれた Kyarik と Gary Kačmarčík に感謝します。
Unsplash の Markus Winkler によるヒーロー画像。