0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Safariでnavigator.clipboard.writeText()が失敗した:絵文字コピーボタンのNotAllowedErrorを直した記録

0
Posted at

絵文字をタップすると、そのままクリップボードへコピーできる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へ依存するのではなく、コピー対象を表示して手動コピーできる逃げ道を用意する。

絵文字・顔文字コピペ機能自体は小さな機能ですが、ブラウザのセキュリティ制約まで考えると、意外と実装でハマるポイントでした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?