絵文字をタップすると、そのままクリップボードへコピーできるUIを作っていました。
Chromeでは普通に動いていたので、
await navigator.clipboard.writeText("😊");
だけで終わると思っていました。
ところがiPhoneのSafariで試すと、一部の処理でコピーできない。
コンソールを見ると、
NotAllowedError
が出ることもありました。
今回ハマったのは、
Clipboard API自体が使えないのではなく、呼び出すタイミングに問題があった
という点でした。
この記事では、
- 絵文字コピーボタンがSafariで失敗した状況
-
navigator.clipboard.writeText()が失敗する理由 - HTTPSとuser activation
-
awaitを挟んだときに起きた問題 - 実際にどう修正したか
- Clipboard APIが使えない場合の扱い
をまとめます。
作っていたのは普通の絵文字コピーボタン
やりたかったこと自体は単純です。
<button class="emoji-copy" data-value="😊">
😊
</button>
<button class="emoji-copy" data-value="💕">
💕
</button>
<button class="emoji-copy" data-value="✨">
✨
</button>
クリックしたらコピーします。
document.addEventListener("click", async (event) => {
const button = event.target.closest(".emoji-copy");
if (!button) return;
await navigator.clipboard.writeText(
button.dataset.value
);
});
Chromeでは問題ありませんでした。
ユーザー側の操作も、
絵文字を探す
↓
タップ
↓
コピー
↓
SNSやチャットに貼り付ける
だけです。
絵文字一覧、かわいい絵文字、ハート、キラキラ、顔文字などをコピペするWebツールなら、ごく普通の実装だと思います。
問題が出たのは、コピー前に別処理を入れたあと
あとから、
- コピー履歴を取得
- ログを保存
- APIからデータ取得
- お気に入り状態を確認
といった処理を追加しました。
コードはだいたいこんな形です。
button.addEventListener("click", async () => {
const emoji = button.dataset.value;
await saveCopyLog(emoji);
await navigator.clipboard.writeText(emoji);
});
これでSafari側の挙動が不安定になりました。
Chromeだけ確認していると気づきにくい部分でした。
原因:Clipboard APIは自由なタイミングで呼べるわけではない
navigator.clipboard.writeText() は、セキュリティ上の制約があるAPIです。
まず、基本的にHTTPSなどのsecure contextが必要です。MDNでも writeText() はsecure context限定とされています。
さらにSafariでは、Clipboardへの書き込みはユーザー操作から直接発生する必要があります。
WebKitも、clipboard.write() / writeText() はclickやtouchなどのuser gestureの範囲で呼び出す必要があると説明しています。
つまり、
ユーザーがタップ
↓
すぐClipboard API
なら問題になりにくい。
一方、
ユーザーがタップ
↓
非同期処理
↓
待つ
↓
Clipboard API
になると、ブラウザから見ると「ユーザー操作から直接実行された処理」ではなくなる場合があります。
transient user activationという仕組み
ブラウザには、
ユーザーが今操作した
という一時的な状態があります。
これが transient user activation です。
MDNでは Clipboard.write() と Clipboard.writeText() も、このtransient activationが関係するAPIとして挙げられています。
問題なのは、この状態がずっと続くわけではないことです。
一定時間で消えたり、処理の流れによって失われたりします。
なので、
button.addEventListener("click", async () => {
await fetch("/api/log/");
await navigator.clipboard.writeText("😊");
});
のように、Clipboard APIまでに非同期処理を挟む設計は避けたほうが安全でした。
WebKitでも、非同期境界を挟んだClipboard操作について長く議論されているケースがあります。
修正:コピーを最初に実行する
最終的には順番を逆にしました。
button.addEventListener("click", async () => {
const emoji = button.dataset.value;
try {
await navigator.clipboard.writeText(emoji);
showCopiedToast(emoji);
saveCopyLog(emoji);
} catch (error) {
handleCopyError(emoji, error);
}
});
つまり、
クリック
↓
コピー
↓
成功表示
↓
ログ保存などの副処理
です。
コピーというユーザーが直接要求した処理を最優先にしました。
API通信はコピー後でいい
たとえばコピー回数を記録したい場合も、
async function saveCopyLog(value) {
try {
await fetch("/api/copy-log/", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value }),
});
} catch (error) {
console.error(error);
}
}
コピー前に待つ必要はありません。
button.addEventListener("click", async () => {
const value = button.dataset.value;
try {
await navigator.clipboard.writeText(value);
showCopiedToast(value);
void saveCopyLog(value);
} catch (error) {
handleCopyError(value, error);
}
});
ユーザーにとって重要なのはコピー成功です。
分析ログの保存に失敗しても、
😊をコピーできない
状態にする必要はありません。
navigator.clipboard があるかも確認する
Clipboard APIが存在する前提にもしたくなかったので、チェックを追加しました。
async function copyText(value) {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard API is unavailable");
}
await navigator.clipboard.writeText(value);
}
使用側は、
button.addEventListener("click", async () => {
const value = button.dataset.value;
try {
await copyText(value);
showCopiedToast(value);
} catch (error) {
showManualCopy(value);
}
});
です。
コピー失敗時は「何も起きない」をやめる
最初の実装では、失敗するとconsoleにエラーを出すだけでした。
catch (error) {
console.error(error);
}
でもユーザー側から見ると、
ボタンを押した
↓
何も起きない
だけです。
これはかなり分かりにくい。
そこで失敗時には、文字列を選択できるUIを出すことにしました。
<div id="manual-copy" hidden>
<p>自動コピーできませんでした。</p>
<input
id="manual-copy-value"
type="text"
readonly
>
<p>上の文字を選択してコピーしてください。</p>
</div>
JavaScript側は、
function showManualCopy(value) {
const box = document.getElementById(
"manual-copy"
);
const input = document.getElementById(
"manual-copy-value"
);
input.value = value;
box.hidden = false;
input.focus();
input.select();
}
これならClipboard APIが失敗しても、
コピーできません
で終わりません。
ユーザー自身がコピーできます。
document.execCommand("copy") は主実装にしなかった
昔のClipboard実装を調べると、
document.execCommand("copy");
がよく出てきます。
ただし現在のMDNでは execCommand() はdeprecated扱いで、新しい実装ではClipboard APIが推奨されています。
なので今回は、
Clipboard API
↓
失敗したら手動コピーUI
にしました。
新しいコードで、最初からdeprecated APIへ依存する理由はあまりないと判断しました。
コピー処理を1か所にまとめた
絵文字が増えてくると、
😊
💕
✨
🎉
🌸
それぞれにイベントを書くのは面倒です。
顔文字も、
(*´꒳`*)
(≧▽≦)
(ง •̀_•́)ง
と増えていきます。
そこでコピー処理は共通化しました。
async function copyValue(value) {
if (!window.isSecureContext) {
throw new Error(
"Clipboard API requires a secure context"
);
}
if (!navigator.clipboard?.writeText) {
throw new Error(
"Clipboard API is unavailable"
);
}
await navigator.clipboard.writeText(value);
}
イベント側は、
document.addEventListener("click", async (event) => {
const target = event.target.closest(
"[data-copy-value]"
);
if (!target) return;
const value = target.dataset.copyValue;
try {
await copyValue(value);
showCopiedToast(value);
void saveCopyLog(value);
} catch (error) {
console.error(error);
showManualCopy(value);
}
});
HTMLは、
<button data-copy-value="😊">
😊
</button>
<button data-copy-value="(*´꒳`*)">
(*´꒳`*)
</button>
だけです。
絵文字と顔文字でコピー処理を分ける必要はなかった
ここも実装してみて分かったことです。
絵文字なら、
😊
❤️
🎉
顔文字なら、
(*´꒳`*)
(´・ω・`)
(ง •̀_•́)ง
ですが、Clipboard APIから見ればどちらも文字列です。
なので、
navigator.clipboard.writeText(value);
は共通です。
違うのは検索や表示方法であって、コピー処理ではありません。
完成形のUIを考えると、この共通化が効く
実際の利用場面では、
かわいい絵文字を探してコピペしたい
ハートやキラキラをSNSプロフィールに使いたい
ありがとうや応援に合う顔文字をコピーしたい
というように、検索するものは違います。
たとえば WowEmoji 絵文字 のような、絵文字一覧から笑顔、ハート、キラキラ、花、動物などを探し、SNS投稿・Instagramプロフィール・コメント・チャットに使う絵文字をそのままコピペするUIでも、最後のClipboard処理は1本にできます。
顔文字についても、顔文字一覧 のように、かわいい顔文字、笑顔、泣く、猫、ありがとう、応援、挨拶などをシーンから探してコピーするUIでも、同じコピー関数を使えます。
検索UIは違う
↓
表示UIも違う
↓
コピー処理は共通
という分け方にしておくとかなり楽でした。
iframe内で使う場合も注意
もう一つ確認したのがiframeです。
Clipboard APIは、トップレベルページだけ考えていればいいとは限りません。
MDNではChromium系ブラウザについて、iframe内からClipboardへアクセスする場合、Permissions Policyの clipboard-read / clipboard-write が関係すると説明されています。SafariやFirefoxとは挙動も同じではありません。
なので、埋め込みWidgetとして実装するなら、
普通のページでは動く
iframeでは動かない
というケースも確認したほうがいいです。
デバッグ時に確認した項目
最終的に、Clipboard APIがおかしいときはこの順番で見るようにしました。
1. HTTPSか
console.log(window.isSecureContext);
false なら、まず実行環境を確認。
2. Clipboard APIが存在するか
console.log(navigator.clipboard);
3. writeText があるか
console.log(
typeof navigator.clipboard?.writeText
);
4. クリックから直接呼んでいるか
click
↓
writeText
になっているかを見る。
5. コピー前に重い非同期処理を挟んでいないか
click
↓
fetch
↓
await
↓
writeText
になっていないか確認。
6. エラーを握りつぶしていないか
catch (error) {
console.error(error.name);
console.error(error.message);
}
NotAllowedError が出ていないか確認します。
修正後の処理
最終的にはかなり単純になりました。
document.addEventListener("click", async (event) => {
const button = event.target.closest(
"[data-copy-value]"
);
if (!button) return;
const value = button.dataset.copyValue;
try {
await navigator.clipboard.writeText(value);
showCopiedToast(value);
void saveCopyLog(value);
} catch (error) {
showManualCopy(value);
}
});
基本思想は、
ユーザー操作
↓
まずコピー
↓
成功を表示
↓
それ以外の処理
です。
結果
修正後は、コピー処理とログ処理を分離できました。
さらに、
- 絵文字コピーボタン
- 顔文字コピーボタン
- かわいい絵文字一覧
- 顔文字一覧
- SNSプロフィール用絵文字
- ハート・キラキラの絵文字
- ありがとう・応援・挨拶の顔文字
すべて同じコピー関数を使えるようになりました。
UI側も、
タップ
↓
コピー完了
だけなのでシンプルです。
まとめ
今回の原因は、
navigator.clipboard.writeText()
そのものではなく、
いつ呼び出していたか
でした。
Clipboard APIを使うなら、
HTTPS
+
ユーザー操作
+
コピー処理を早い段階で実行
をまず確認する。
特にSafariまで考える場合は、
クリック
↓
いろいろawait
↓
最後にコピー
という処理を避けて、
クリック
↓
コピー
↓
その他の非同期処理
にしたほうが扱いやすいです。
そしてコピーに失敗する環境では、無理に古いAPIへ依存するのではなく、コピー対象を表示して手動コピーできる逃げ道を用意する。
絵文字・顔文字コピペ機能自体は小さな機能ですが、ブラウザのセキュリティ制約まで考えると、意外と実装でハマるポイントでした。