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

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

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

https://www.udemy.com/course/100-days-of-javascript/


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

# Memo
- `Async Clipboard API`を使う理由
    - 非同期`Promise`で処理するのでクリップボードでのデータ読み書きはスレッドをブロッキングしない。
    - ユーザのプライバシー上の配慮。
    - `script`か`JavaScript`による攻撃を普通の文字列に転換したり、`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
```
```js
// 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](https://searchfox.org/mozilla-central/source/dom/webidl/Permissions.webidl#10)
[WebKit PermissionName](https://github.com/WebKit/WebKit/blob/main/Source/WebCore/Modules/permissions/PermissionName.idl)
[Chromium PermissionName](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/third_party/blink/renderer/modules/permissions/permission_descriptor.idl)

# ClipboardEvent
触発条件（イベント）：
- `cut event`
- `copy event`
- `paste event`

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

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

https://www.w3.org/TR/clipboard-apis/#clipboard-event-cut

>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`イベントの触発についての参考文章です。

https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript

https://stackoverflow.com/questions/36270886/event-clipboarddata-setdata-in-copy-event

## Clipboard Event API
### DataTransfer

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

https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer

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

https://w3c.github.io/clipboard-apis/#clipboard-event-api

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

下は参考文章から引用した例です。
> EXAMPLE 4
```js
// 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](https://qiita.com/hu-yu/items/cbdb848c08529425722e#copy-text-clipboard-event-api-vs-clipboard-api)にあります。

<hr>

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

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

https://stackoverflow.com/questions/20509061/window-clipboarddata-getdatatext-doesnt-work-in-chrome

https://stackoverflow.com/questions/13825987/in-chrome-using-the-window-clipboard-object-is-there-a-way-to-capture-pasted-t

# Clipboard
https://developer.mozilla.org/ja/docs/Web/API/Clipboard

https://developer.mozilla.org/ja/docs/Web/API/Clipboard_API

https://w3c.github.io/clipboard-apis/

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

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

# Clipboard Interface

https://w3c.github.io/clipboard-apis/#async-clipboard-api

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

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

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

- `navigator.clipboard.write()`
    - クリップボードにアクセスして、`ClipboardItem`へ`MIME type`のデータをを書き込む。

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

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

# Permissions API

https://w3c.github.io/permissions/#permissions-api

https://developer.mozilla.org/ja/docs/Web/API/Permissions_API

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

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

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

https://web.dev/i18n/en/async-clipboard/#security-and-permissions

```js
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
https://glitch.com/edit/#!/async-clipboard-text?path=script.js%3A43%3A24

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

```html: html
<section id="permissions"></section>
```
```css: 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: 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:html
<input type="text" value="Hello world" id="input" />
<button id="btn">Copy</button>
```

```js: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'
    )}`
  );
});
```

```js: 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()`メソッドを使用。
リスナーが`document`（`DOM`の一番上）に設置すれば、これで`Clipboard Event API`オーバーライドの特性を利用してサイト内容のコピーを防ぐことができる。
（単にイベントを止めるなら`e.preventDefault()`だけでできます。）

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

## Fall back
新しい非同期`Async Clipboard API`の使用には、ブラウザがサポートされてるかを確認、あるいはフォールバックする。
```jsx
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);
});
```
https://web.dev/i18n/ja/async-clipboard/#%E7%89%B9%E5%BE%B4%E3%81%AE%E6%A4%9C%E5%87%BA


# Make `clipboardData` Asynchronously
`clipboardData`を非同期操作に変えようと思っています。
その前に`clipboardData`の構造？を見ていきたいです。
```js
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`に所属するけれど、`ClipboardEvent`の`clipboardData`と、`Clipboard`の`clipboardItem`の仕組みは全然違います。**
`clipboardData`は読み取り専用のプロパティなので、`Drag and Drop API`の`DataTransfer`メソッドに頼っています。その上同期`API`としてブロッキング問題を起こしうるかもしれません。

考えとしてはまず非同期関数で包んでブロッキング問題を解消したいと思います。
```js: 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: 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: html
<input type="url" placeholder="Image URL" id="image-url" />
<button id="btn-paste">paste</button>
<div id="paste-image-field"></div>
```
```js: 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`には新しい動きもあるらしいです。

https://developer.chrome.com/blog/web-custom-formats-for-the-async-clipboard-api/

# Notice
```js
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 {}
}
```