はじめに
iOS Safari で動かすブックマークレットから、クリップボードに入っている JSON を読み取りたかった。
最初に書いた処理は単純で、ブックマークレットを実行したら入力用モーダルを作り、 navigator.clipboard.readText() を呼ぶというものだった。
ところが、新しいタブで最初に実行したときだけ、 iOS のペースト許可 UI が出ない。 iOS のペースト許可 UI というのは以下のようなやつのこと。

読み取りも失敗する。 同じページでもう一度ブックマークレットを実行すると、今度は動くことがある。
いくつかの方法を試した結果、次の順番にすると新しいタブの初回でも動作した。
- 編集可能な textarea を画面に表示する
- textarea へ
focus()する - タイマーや疑似クリックを挟まず、直後に
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 が生成したクリックイベントの isTrusted は false である。
同じことは 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() を開始する方法は、新しいタブの初回でも動作した。