xshoji's blog

iOS Safari でブックマークレットからいきなりペースト許可 UI を出す方法

目次

はじめに

iOS Safari で動かすブックマークレットから、クリップボードに入っている JSON を読み取りたかった。

最初に書いた処理は単純で、ブックマークレットを実行したら入力用モーダルを作り、 navigator.clipboard.readText() を呼ぶというものだった。

ところが、新しいタブで最初に実行したときだけ、 iOS のペースト許可 UI が出ない。 iOS のペースト許可 UI というのは以下のようなやつのこと。

読み取りも失敗する。 同じページでもう一度ブックマークレットを実行すると、今度は動くことがある。

いくつかの方法を試した結果、次の順番にすると新しいタブの初回でも動作した。

  1. 編集可能な textarea を画面に表示する
  2. textarea へ focus() する
  3. タイマーや疑似クリックを挟まず、直後に navigator.clipboard.readText() を呼ぶ

この記事では、動かなかった実装と動いた実装の違いを整理する。

最初に起きていたこと

作っていたブックマークレットは、別ページで生成したテキストデータの JSON をクリップボードから取得し、現在のページへ反映するものだった。

JSON を手動で貼り付けられるよう、ブックマークレットの実行時に独自のモーダルを表示していた。 モーダルには textarea と「クリップボードから取得」ボタンがある。

ボタンを自分でタップすれば、読み取り処理は動くものの、いちいちボタンをタップするのが面倒だったので、ブックマークレットの実行と同時にペーストさせたくなった。 が、自動で同じ処理を始めようとすると、 iOS Safari の初回実行だけが安定しなかった。

確認できた症状は次のとおりだった。

条件結果
新しいタブで初回実行ペースト許可 UI が出ず、読み取りも失敗することがある
同じページで 2 回目を実行成功することがある
表示されたボタンを実際にタップ成功する

「 2 回目なら動く」という挙動がやっかいだった。 コードそのものは正しいように見えるし、実際に一度は成功するからだ。

モーダルの描画待ちを入れてみた

最初に疑ったのは、クリップボードの読み取りが早すぎて、モーダルの描画と競合している可能性だった。

そこで、モーダルを DOM へ追加してから 500 ミリ秒待ち、読み取り処理を始めるようにした。

document.body.appendChild(host);

setTimeout(() => {
  doPaste();
}, 500);

doPaste() の中では navigator.clipboard.readText() を呼んでいる。

しかし、この変更では直らなかった。 モーダルの描画を待てても、 iOS Safari がクリップボード読み取りを許可する条件は満たせていなかった。

DOM のボタンをクリックしても実タップにはならない

次に、処理関数を直接呼ぶのではなく、画面上の「クリップボードから取得」ボタンへクリックイベントを発生させてみた。

const pasteButton = document.createElement('button');
pasteButton.addEventListener('click', doPaste);

setTimeout(() => {
  pasteButton.click();
}, 500);

これも動かなかった。

.click() を呼べば、登録したイベントリスナーは実行される。 それでも、ユーザーが画面を指でタップしたことにはならない。 JavaScript が生成したクリックイベントの isTrustedfalse である。

同じことは dispatchEvent() にも当てはまる。

pasteButton.dispatchEvent(new MouseEvent('click', {
  bubbles: true
}));

ハンドラーを呼べるかどうかと、ブラウザがユーザー操作として認めるかどうかは別の話だった。

WebKit の Async Clipboard API の解説 にも、クリップボードの読み取りはユーザー操作と結び付けて制限されるとある。 iOS では、条件に応じてユーザーが操作するペースト用の UI が表示される。

別のブックマークレットとの違い

同じ iPhone で、クリップボード読み取りが毎回動く別のブックマークレットがあった。

そのブックマークレットは、 1 回目の実行であるURLへ移動する。 ページが表示された後、 2 回目の実行でクリップボードの文章を読み取る。

読み取り部分は次のような処理だった。

const textarea = findSourceTextarea();

textarea.focus();

navigator.clipboard.readText().then((text) => {
  startTranslation(text, textarea);
});

ここでは、 focus() から readText() までに setTimeout() も疑似クリックもない。 ブックマークレットを選んだ同じ同期処理の中で、クリップボード読み取りを開始している。

もちろん、動作の違いを focus() だけで説明することはできない。 このブックマークレットの 2 回目は、ページの読み込みが終わった後に行う新しいユーザー操作でもある。

それでも、 textarea にフォーカスした直後に readText() を呼ぶ順番は試す価値があった。

表示した textarea へ focus してから readText を呼ぶ

問題のあったブックマークレットにも、同じ処理順を移した。

ここで使う textarea は display: none の隠し要素ではない。 JSON 入力モーダルの中へ配置し、画面上に表示した編集可能な textarea である。

実装の要点を抜き出すと次のようになる。

const host = document.createElement('div');
const modal = document.createElement('div');
const textarea = document.createElement('textarea');

host.appendChild(modal);
modal.appendChild(textarea);
document.body.appendChild(host);

textarea.focus();

navigator.clipboard.readText().then((text) => {
  textarea.value = text;
  applyText(text);
}).catch((error) => {
  showPasteError(error);
});

まず、 textarea を含むモーダルを document.body へ追加する。 次に、その textarea へ focus() する。 その直後に navigator.clipboard.readText() を呼ぶ。

readText() は Promise を返す非同期 API だが、ここで問題になるのは結果を待つことではなかった。 実際のコードでは、読み取り処理を doPaste() へまとめていたため、最後の部分は次の形になった。

document.body.appendChild(host);

textarea.addEventListener('focus', () => textarea.select());

textarea.focus();
doPaste();

doPaste() は呼ばれた直後に navigator.clipboard.readText() を実行する。

この変更を iOS Safari の実機で確認したところ、新しいタブの初回実行でもペースト許可 UI が表示され、その後の貼り付けまで成功した。

正式な仕様まで調べたわけではないのであくまでTipsとして覚えておく

実機で動いたのは、次の条件を一続きにした実装である。

  • ブックマークレットをユーザーが実行する
  • 編集可能な textarea を画面へ表示する
  • textarea へフォーカスする
  • 同じ同期処理内で readText() を開始する

textarea のフォーカスは、 iOS がペースト先を扱うための助けになっていると考えられる。 ただし、 WebKit の内部実装まで確認したわけではないため、 focus() だけが成功理由だとは断定しない。 この記事で再現できたのは、上記の処理順をまとめて適用した結果である。

まとめ

iOS Safari のブックマークレットからペースト許可 UI を出す動作するスクリプトを最小構成にすると以下の通り。

javascript:(function () {
  var textarea = document.createElement('textarea');

  textarea.placeholder = 'クリップボードの内容を読み取り中...';
  textarea.style.cssText =
    'position:fixed;' +
    'inset:16px;' +
    'z-index:2147483647;' +
    'padding:12px;' +
    'font-size:16px;' +
    'background:#fff;' +
    'color:#111;' +
    'border:2px solid #2563eb;' +
    'border-radius:8px;' +
    'box-sizing:border-box;';

  document.body.appendChild(textarea);
  textarea.focus();

  if (!navigator.clipboard || !navigator.clipboard.readText) {
    textarea.value = 'このページではClipboard APIを利用できません';
    return;
  }

  navigator.clipboard.readText().then(function (text) {
    textarea.value = text;
  }).catch(function (error) {
    textarea.value = '読み取り失敗: ' + error;
  });
})();

textarea は画面上に表示した編集可能な要素を使うようにすればOK。 検証では、遅延後の .click() はイベントリスナーを実行できても、ユーザーの実タップにはならなかった。 一方、表示済みの textarea へフォーカスし、ブックマークレット実行と同じ同期処理内で readText() を開始する方法は、新しいタブの初回でも動作した。