LoginSignup
0
0

More than 1 year has passed since last update.

[Web開発] Async Clipboard APIについて

Last updated at Posted at 2022-11-08

初めに

最近Udemyで100 日プロジェクトの練習を始めて、このように実用なコンポーネントの仕組みが勉強できてすごく楽しいです。毎日充実で確実に進んでいる感じです。そして勉強になったこととそこから得た新しい知識をまとめてみたいと思います。

100 日プロジェクトのコース(有料コンテンツ)はこちらです。

以下はメモです。
(このメモはコースの内容ではなく調べたことを自分なりにまとめたものです。)

Memo

  • Async Clipboard APIを使う理由
    • 非同期Promiseで処理するのでクリップボードでのデータ読み書きはスレッドをブロッキングしない。
    • ユーザのプライバシー上の配慮。
    • scriptJavaScriptによる攻撃を普通の文字列に転換したり、PNG decompression bombを防止するなどセキュリティ上の配慮。
  • Permissions APIを使う理由
    • ユーザからクリップボードへのアクセス許可を得るため。
      (様々なPermissions APIが有しているが今回はAsync Clipboard APIに関わる一部だけまとめる。)
Navigator - Clipboard API
 |-ClipboardEvent → clipboardData → DataTransfer → DataTransferItemList
 |                                       |-setData()/getData()/...
Permissions API('clipboard-read' & 'clipboard-write' required)
 |
 |-Clipboard(**Async Clipboard API**)
 | |-write()/read()/writeText()/readText()
 |    ̄ ̄ ̄↓ ̄ ̄ ̄
 |-ClipboardItem
   |-ClipboardItem()/types/presentationStyle/getType()

// Clipboard methods and ClipboardItem.getType() return resolved Promise
Permissions API
 |-query(PermissionDescriptor)
 | |-PermissionDescriptor object: name/...
 |
 |-request() (currently not be supported in any browser)
 |
 |-revoke() (Deprecated)

// query() returns resolved Promise
// firefox
enum PermissionName {
  "geolocation",
  "notifications",
  "push",
  "persistent-storage"
  // Unsupported: "midi"
};

// safari
enum PermissionName {
    // FIXME: update the list to match spec when new features are implemented.
    "accelerometer",
    "background-fetch",
    "bluetooth",
    "camera",
    "display-capture",
    "geolocation",
    "gyroscope",
    "magnetometer",
    "microphone",
    "midi",
    "nfc",
    "notifications",
    "screen-wake-lock",
    "speaker-selection"
};

// chromium
enum PermissionName {
    "geolocation",
    "notifications",
    "push",
    "midi",
    "camera",
    "microphone",
    // "speaker",
    // "device-info",
    "background-fetch",
    "background-sync",
    // "bluetooth",
    "persistent-storage",
    "ambient-light-sensor",
    "accelerometer",
    "gyroscope",
    "magnetometer",
    // "clipboard",
    "screen-wake-lock",
    "nfc",
    "display-capture",
    // Non-standard:
    "accessibility-events",
    "clipboard-read",
    "clipboard-write",
    "payment-handler",
    "idle-detection",
    "periodic-background-sync",
    "system-wake-lock",
    "storage-access",
    "window-placement",
    "local-fonts",
};

FireFox PermissionName
WebKit PermissionName
Chromium PermissionName

ClipboardEvent

触発条件(イベント):

  • cut event
  • copy event
  • paste event

Clipboardと違って、ClipboardEventはこれらイベントの発火から自動的に触発される。(所属のメソッドがなく、ユーザ側Clipboard APIの承認もいりません。)

ClipboardEvent.clipboardData自体は読み取り専用のプロパティとしてDataTransferオブジェクトを所有し、cutcopyイベントではDataTransfer.setData()メソッドで選択範囲(MIME typeも含め)データを書き込んだり、pasteイベントではDataTransfer.getData()を呼び出し、書き込まれているデータをゲットして貼り付ける。

In a non-editable context, the clipboardData will be an empty list. Note that the cut event will still be fired in this case.

編集不可な環境(コンテキスト)なら、cutイベントは発火されるけどclipboardDataに空のDataTransferオブジェクトを返すだけ。(つまりpasteイベントではDataTransfer.getData()を呼び出し空の文字列をペストすることになる。)

The paste action has no effect in a non-editable context, but the paste event fires regardless.

ペスト行為は編集不可な環境に効果をもたらさない、作用しないけど、pasteイベントは通常通り発火する。
(デフォルト動作を止めるにはEvent.preventDefault()。)

下はcopyイベントの触発についての参考文章です。

Clipboard Event API

DataTransfer

clipboardDataプロパティはDataTransferのインスタンスが内蔵している、DataTransferのプロパティやメソッドを頼っています。

よく使われているプロパティやメソッドたち:

  • DataTransfer.types
    • データのフォーマット(文字列)を格納した配列を返す。
  • DataTransfer.items
    • DataTransferItemListオブジェクトを返す。
      • → DataTransferItem: .type/.getAsFile()/.getAsString()
  • DataTransfer.setData()
    • cutcopy動作で触発されるClipboard Eventでは、指定された型のデータをclipboardDataプロパティに設置することができる。
  • DataTransfer.getData()
    • paste動作で触発されるClipboard Eventでは、clipboardDataプロパティから指定された型のデータを取得する。データがない場合は空の文字列を返す。

Clipboard Event APIcutcopypasteイベントをオーバーライドすることができる。(例えばどんなテキストをコピーしたとしても、.setData()で特定のテキストで上書きする。)

下は参考文章から引用した例です。

EXAMPLE 4

// Overwrite what is being copied to the clipboard.
document.addEventListener('copy', function(e) {
  // e.clipboardData is initially empty, but we can set it to the
  // data that we want copied onto the clipboard.
  e.clipboardData.setData('text/plain', 'Hello, world!');
  e.clipboardData.setData('text/html', '<b>Hello, world!</b>');

  // This is necessary to prevent the current document selection from
  // being written to the clipboard.
  e.preventDefault();
});

自分で試しに書いた例はCopy Text: Clipboard Event API vs. Async Clipboard APIにあります。


元々ClipboardAsync Clipboard APIを使わず、ボタン押しだけでコピペできるメソッドを探していたけど、ClipboardDataにはこれらのイベントがなければClipboardDataプロパティをもたないことに気づきました。

それにwindow.clipboardDataはセキュリティ上の問題があってClipboardAsync Clipboard APIを使った方がベターと思いました。

Clipboard

Document.execCommandメソッドが廃止され、クリップボード関連の操作は代わりにAsync Clipboard APIを実装したClipboardインターフェースで行うようになりました。(Navigator.clipboardのメソッドたちを通してシステムクリップボードへのアクセスはユーザ側のAsync Clipboard API許可が必要です。)

Clipboardインターフェースのメソッドたちは解決されたPromiseを返し、thenメソッドで後の処理を行うことができる。

Clipboard Interface

  • navigator.clipboard.readText()

    • クリップボードにアクセスしてデータ(テキスト)を読み取る。
  • navigator.clipboard.read()

    • クリップボードにアクセスし、ClipboardItemからMIME typeのデータを読み取る。
  • navigator.clipboard.writeText()

    • クリップボードにアクセスしてデータ(テキスト)を書き込む。
  • navigator.clipboard.write()

    • クリップボードにアクセスして、ClipboardItemMIME typeのデータをを書き込む。

ClipboardItem

  • ClipboardItem()
    • ClipboardItemオブジェクト、キーはMIME type、値はBlob
  • types
    • ClipboardItemオブジェクトに所有するデータのMIME typeの配列を返す。
  • presentationStyle
    • データの表現形式という文字列を返す。
  • getType()
    • 指定されたMIME typeClipboardItemオブジェクトに存在したら、値がBlobで解決されたPromiseを返す。

ClipboardItemオブジェクトはclipboard.write()clipboard.read()で読み書きを行う。(その前にクリップボードのアクセス権限が要求される。)

Permissions API

  • query(PermissionDescriptor)
    • PermissionDescriptorオブジェクトでnameプロパティによってPermission状態をチェックすることができる。
    • 解決されたPromiseとしてPermissionStatusオブジェクトを返す。
      • permissionStatus.stateではgranteddeniedpromptのいずれを返す。
  • request()
    • すべてのブラウザにおいてサポートされていません。
  • revoke()
    • 非推奨。

今のところではブラウザ側にユーザのPermission状態確認のクエリだけ送ることができる。

こちらの例からテストのため一部を変更しました。

document.getElementById('btn').addEventListener('click', async () => {
  const queryOpts = { name: 'clipboard-read', allowWithoutGesture: false };
  const permissionStatus = await navigator.permissions.query(queryOpts);
  // Will be 'granted', 'denied' or 'prompt':
  console.log(permissionStatus.state);

  // Listen for changes to the permission state
  permissionStatus.onchange = () => {
    console.log(permissionStatus.state);
  };
});

Understand Permission State

参考文章のデモコードから一部変更したものです。

html
<section id="permissions"></section>
style
#permissions button[data-state="granted"] {
  background: #495;
  border-color: #051;
}
#permissions button[data-state="granted"]::before {
  content: "\2705";
}
#permissions button[data-state="denied"] {
  background: #945;
  border-color: #501;
}
#permissions button[data-state="denied"]::before {
  content: "\1f6ab";
}
js
const permissionSection = document.getElementById('permissions');

const permissionNames = [
  { name: 'clipboard-read', allowWithoutGesture: false },
  { name: 'clipboard-write', allowWithoutGesture: false },
];

Promise.all(
  permissionNames.map((description) => navigator.permissions.query(description))
).then((result) => {
  // console.log(result);
  // [PermissionStatus, PermissionStatus]
  // PermissionStatus {name: 'clipboard_read', state: 'granted', onchange: null}
  result.forEach((status, index) => {
    let descriptorObj = permissionNames[index];
    let descriptionName = descriptorObj.name;
    let requestPermissionBtn = document.createElement('button');

    requestPermissionBtn.title = 'Click to request Permission';
    requestPermissionBtn.textContent = descriptionName;

    requestPermissionBtn.onclick = () => {
      // permissions.request is currently not supported in any browser
      navigator.permissions
        .request(descriptorObj)
        .then((status) => {
          console.log(`Permission ${status.state}`);
        })
        .catch((err) => {
          console.log(`Permission was denied: ${err}`);
        });
    };

    // set css attribute to understand Permission state
    status.checker = () => {
      requestPermissionBtn.setAttribute('data-state', status.state);
    };
    status.checker();
    permissionSection.appendChild(requestPermissionBtn);
  });
});

デモコードではnavigator.permissions.requestを使用したが、MDNでは今サポートされてない、実際devToolsでテストした結果でもエラーになっています。
最初少し戸惑っていましたが、いまの段階ではクリックしても機能しないので、とりあえずユーザにクリップボードのアクセス許可は許可されていないように色で示すのが直観的に分かりやすいと思います。

ブラウザ側がユーザプライバシー重視するため、これからPermission APIをどんどん使う傾向が見えるので、とてもいい練習だと思います。

Copy Text: Clipboard Event API vs. Async Clipboard API

html
<input type="text" value="Hello world" id="input" />
<button id="btn">Copy</button>
Clipboard Event API - setData()/getData()
document.addEventListener('copy', function (e) {
  e.clipboardData.setData('text/plain', 'foo');
  // default behavior is to copy any selected text
  e.preventDefault();
  alert(
    `Any copy by using "Ctrl + C" will be ${e.clipboardData.getData(
      'text/plain'
    )}`
  );
});
Async Clipboard API - writeText()/readText()
btn.addEventListener('click', function (e) {
  navigator.clipboard.writeText(input.value).then(
    () => {
      navigator.clipboard
        .readText()
        .then((data) => alert(`Your string: ${data}`));
    },
    (err) => alert(`Something wrong.`)
  );
});

Clipboard Event API
e.clipboardData.setData()というのはeventからのclipboardDataプロパティ設置、setData()メソッドを使用。
リスナーがdocumentDOMの一番上)に設置すれば、これでClipboard Event APIオーバーライドの特性を利用してサイト内容のコピーを防ぐことができる。
(単にイベントを止めるならe.preventDefault()だけでできます。)

Async Clipboard API
navigator.clipboard.writeText()は、BOMnavigatorclipboardにアクセスし、writeText()メソッドで指定データを書き込んだり、.readText()で読み取ったりする。

Fall back

新しい非同期Async Clipboard APIの使用には、ブラウザがサポートされてるかを確認、あるいはフォールバックする。

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  }
  else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('Got pasted text: ', text);
});

Make clipboardData Asynchronously

clipboardDataを非同期操作に変えようと思っています。
その前にclipboardDataの構造?を見ていきたいです。

document.addEventListener('paste', (e) => {
  e.preventDefault();
  console.log(e.clipboardData.types);
  console.log(e.clipboardData.items);
  // if it is text
  // ['text/plain', 'text/html']
  // DataTransferItemList {0: DataTransferItem, 1: DataTransferItem, length: 2}
  // NOTE: use DataTransfer.getData() to get data as string

  // if it is image
  // ['text/html', 'Files']
  // DataTransferItemList {0: DataTransferItem, length: 1}
  // NOTE: use DataTransferItem.getAsFile() to get blob
});

同じくClipboard APIに所属するけれど、ClipboardEventclipboardDataと、ClipboardclipboardItemの仕組みは全然違います。
clipboardDataは読み取り専用のプロパティなので、Drag and Drop APIDataTransferメソッドに頼っています。その上同期APIとしてブロッキング問題を起こしうるかもしれません。

考えとしてはまず非同期関数で包んでブロッキング問題を解消したいと思います。

js
const textField = document.getElementById('paste-text-field');
const imageField = document.getElementById('paste-image-field');

// clipboardData(synchronous) => resolved Promise
// text
document.addEventListener('paste', (e) => {
  e.preventDefault();
  e.clipboardData.types.forEach(async (item) => {
    if (item.match('text/plain')) {
      const data = await e.clipboardData.getData('text/plain');
      textField.textContent = data;
    }
  });
});
// image
document.addEventListener('paste', (e) => {
  e.preventDefault();
  // e.clipboardData.items is array-like object
  Array.from(e.clipboardData.items, async (item) => {
    if (item.type.includes('image')) {
      // get blob file // DataTransferItem.getAsFile()
      const blob = await item.getAsFile();
      const reader = new FileReader();
      // convert to Base64
      reader.readAsDataURL(blob);
      // when reader is loaded, create new img element with result
      reader.addEventListener('load', () => {
        let img = document.createElement('img');
        img.src = reader.result;
        imageField.appendChild(img);
      });
    }
  });
});
// NOTE: this way can use with image/png, image/jpeg, image/webp...

これではPermissions APIを通さずデータの読み書きができ、指定したエレメントのところに再現できますが。
プライバシー保護や攻撃を防ぐことができない、たぶん未来では配慮のため使えなくなるかもしれません。

Copy image: Async Clipboard API

Ctrl + Cや右クリックでのコピーイベントでは、navigator.clipboard.read()がどうなるかは気になるので、様子はこんな感じです。

js
btn.addEventListener('click', async () => {
  console.log(await navigator.clipboard.read());
  console.log(await navigator.clipboard.readText());

  // text
  // [ClipboardItem]
  // Hello world

  // image
  // [ClipboardItem]
  // *empty*
});

色々と試しにしてきてから分かったのですが、pasteイベントで使うclipboardDataは、clickイベントで触発することはできないのでメモとして残しておきたいと思います。(触発できるかもしれないが、何か違う、一貫性がない気がして突き止めるのやめました。)

ここからは本番です。

html
<input type="url" placeholder="Image URL" id="image-url" />
<button id="btn-paste">paste</button>
<div id="paste-image-field"></div>
js
const imgInput = document.getElementById('image-url');
const pasteBtn = document.getElementById('btn-paste');
const imageField = document.getElementById('paste-image-field');

// click to paste by using External Link
pasteBtn.addEventListener('click', async () => {
  try {
    const imgURL = imgInput.value;
    const data = await fetch(imgURL);
    const blob = await data.blob();
    await navigator.clipboard.write([
      new ClipboardItem({
        [blob.type]: blob,
      }),
    ]);
    const reader = new FileReader();
    // convert to Base64
    reader.readAsDataURL(blob);
    // when reader is loaded, create new img element with result
    reader.addEventListener('load', () => {
      let img = document.createElement('img');
      img.src = reader.result;
      imageField.appendChild(img);
    });
  } catch (err) {
    console.log(`Error: ${err.message}`);
  }
});
test
// png
https://upload.wikimedia.org/wikipedia/commons/5/53/Google_Chrome_Material_Icon-450x450.png
// webp
https://upload.wikimedia.org/wikipedia/commons/c/c0/Calico_arabian_mau_kitten.webp
// jpg/jpeg
https://upload.wikimedia.org/wikipedia/commons/thumb/7/75/Cute_grey_kitten.jpg/800px-Cute_grey_kitten.jpg?20110625205814
// Error: Type image/webp not supported on write.
// Error: Type image/jpeg not supported on write.

Blobタイプがimage/pngでは成功しました。しかし今のところではほかのタイプはまだ支援されていません。

感想

今回もいろんな試行錯誤してやってきました。Permission APIとも絡んでいままでよりテーマが大きすぎて一部を取り切るしかなかったが、ここまで頑張ってよかった気もします。
気になるけど補完できなかったMIME typeデータの扱い方はまだよくわからない。これからもっと知りたいと思います。

Async Clipboard APIには新しい動きもあるらしいです。

Notice

document.addEventListener('copy', (e) => {
  e.preventDefault();
  console.log(e.clipboardData.items);
  // text
  // DataTransferItemList {length: 0}

  // image
  // *nothing happened*
}

document.addEventListener('paste', (e) => {
  e.preventDefault();
  console.log(e.clipboardData.items);
  // text
  // DataTransferItemList {}

  // image
  // DataTransferItemList {}
}
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