クリップボード API を使ってコピー&ペースト時のフォーマットを変換する
はじめに
みなさんは Markdown 対応のテキストエディタにコピー&ペーストする際、次のような体験をしたことはないでしょうか?
- 範囲選択した箇所にリンクをペーストすると、自動的にリンクテキストに変換された
- HTML からコピーした文書が、対応する Markdown 形式として貼り付けられた
このような挙動を実現するために、Clipboard API を使った実装方法を紹介します。
前提知識
そもそもクリップボードがどのように扱われており、JavaScript からどう操作するかはあまり知られていないと思うので、本筋に入る前に軽く前提知識について説明します。
クリップボードにはどんなデータが保存されているか
クリップボードには単純なテキストデータだけでなく、HTML や画像など様々なデータ形式が保存されます。
また、クリップボードにはデータ内容だけでなく、保存するデータの形式を示す MIME タイプ(例: text/plain
, text/html
)も含まれており、これらのデータセットが連想配列のような形式で管理されています。
例えば、Web ブラウザを使用して HTML 文書をコピーした場合、クリップボードには text/plain
(プレーンテキスト形式) と text/html
(HTML 形式) の両方でデータが保存されます。
これにより、ペースト先のアプリケーションが対応するデータ形式を以下のように選択できるようになっています。
const plainText = clipboardData.getData('text/plain');
const htmlText = clipboardData.getData('text/html');
JavaScript でクリップボードを扱う方法
JavaScript では、Clipboard API を使ってクリップボードにアクセスし、コピー&ペーストの処理を制御できます。Clipboard API を使用する方法には次の 2 種類があります。
- 関数で直接操作する方法:
navigator.clipboard.writeText()
やnavigator.clipboard.readText()
などの関数を使用して、クリップボードにデータを読み書きできます。- セキュリティ対策により、これを使うにはユーザーがウェブサイトやアプリにクリップボードへのアクセスを許可する必要がある
-
イベントを使った方法: クリップボード操作に応じて
copy
、cut
、paste
イベントをハンドリングできます。ペースト時にデータの内容や形式を確認し、加工してから貼り付けることが可能です。
今回は太字の「イベントを使った方法」での実装例を紹介します。
実装例
ここからは、ペースト時にフォーマットを変換する具体的な方法について説明します。
実例として、「選択範囲にリンクがペーストされたとき、自動でリンクテキスト化するケース」を扱います。
この基本的な仕組みを応用することで、他の様々なフォーマット変換にも対応することができます。
クリップボードイベントからペースト時のデータを取得
ペースト時のデータを取得するために、以下のような paste
イベントリスナーを追加します。
// 事前に任意の <input> または <textarea> 要素を取得
const editor = document.querySelector('#editor');
// エディタ要素に paste イベントリスナーを追加
editor.addEventListener('paste', (event) => {
// クリップボードからテキストを取得
const clipboardData = event.clipboardData;
const plainText = clipboardData?.getData('text/plain');
// TODO: 選択範囲をリンクテキスト化する
});
上記の clipboardData
にはクリップボードに置かれている様々なデータが管理されており、.getData(format)
メソッドで指定したフォーマットのデータを取得できます。
ここでは簡単に、text/plain
でプレーンテキストを取得しています。
テキストボックスの選択範囲をリンクテキスト化する
テキストボックスの選択範囲をリンクテキスト化するために以下のような関数を作成します。
(以下の target
は先ほどのコードの editor
、link
は plainText
にそれぞれ対応する引数です)
function embedLinkText(target, link) {
// テキストボックスの選択範囲を取得
const selectionStart = target.selectionStart;
const selectionEnd = target.selectionEnd;
// リンクテキストを生成
const selectedText = target.value.substring(selectionStart, selectionEnd);
const linkText = `[${selectedText}](${link})`;
// 選択範囲をリンクテキスト化する
// ('end' オプションは置き換え後の選択範囲を挿入テキスト直後に移動)
target.setRangeText(linkText, selectionStart, selectionEnd, 'end');
}
ただし、この関数をそのまま使っても以下の問題点が残ってしまいます。
- ペースト内容がリンク以外でもリンクテキスト化される
- リンクテキスト化した直後に同じ内容がペーストされる
これを解消するために、embedLinkText()
関数からリンクテキスト化の成否を返すようにし、その結果に応じてデフォルトのペースト動作を制御するようにします。
以下はコードの全文となります。
(比較のため、上記の問題点への対応箇所を差分表示しています)
// 事前に任意の <input> または <textarea> 要素を取得
const editor = document.querySelector('#editor');
// エディタ要素に paste イベントリスナーを追加
editor.addEventListener('paste', (event) => {
// クリップボードからテキストを取得
const clipboardData = event.clipboardData;
const plainText = clipboardData?.getData('text/plain');
- // TODO: 選択範囲をリンクテキスト化する
+ // クリップボードに text/plain がない場合は `plainText = undefined` になる
+ if (plainText != null && embedLinkText(editor, plainText)) {
+ // リンクテキスト化に成功したらデフォルトのペースト動作を防ぐ
+ event.preventDefault();
+ }
+ // リンクテキスト化されなかったら、デフォルトのペースト動作を行う
});
function embedLinkText(target, link) {
+ if (!link.startsWith('http://') && !link.startsWith('https://')) {
+ // リンク以外はそのまま貼り付ける
+ return false;
+ }
+
// テキストボックスの選択範囲を取得
const selectionStart = target.selectionStart;
const selectionEnd = target.selectionEnd;
+ if (selectionStart === selectionEnd) {
+ // 範囲による選択ではない場合はそのままリンクとして貼り付ける
+ return false;
+ }
+
// リンクテキストを生成
const selectedText = target.value.substring(selectionStart, selectionEnd);
const linkText = `[${selectedText}](${link})`;
// 選択範囲をリンクテキスト化する
// ('end' オプションは置き換え後の選択範囲を挿入テキスト直後に移動)
target.setRangeText(linkText, selectionStart, selectionEnd, 'end');
+
+ return true;
}
これにより、選択範囲にリンクがペーストされたとき、自動でリンクテキスト化することができます。
さいごに
Clipboard API が提供するクリップボードイベントを上書きすることで、コピー&ペースト時のフォーマットを変換する方法を初回しました。
これにより、テキストエディタ内でのペースト挙動を制御することで、ユーザーによりスムーズで一貫性の編集体験を提供することができます。
また、より発展的な内容として、例えば Notion の同期ブロックのような機能を実現するためには、paste
イベントだけでなく cut
, copy
イベントも上書きする必要があります。
この仕組みについても、機会があれば別の記事で解説しようと思います。
Discussion