初めに
最近Udemy
で100 日プロジェクトの練習を始めて、このように実用なコンポーネントの仕組みが勉強できてすごく楽しいです。毎日充実で確実に進んでいる感じです。そして勉強になったこととそこから得た新しい知識をまとめてみたいと思います。
100 日プロジェクトのコース(有料コンテンツ)はこちらです。
以下はメモです。
(このメモはコースの内容ではなく調べたことを自分なりにまとめたものです。)
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
// 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
オブジェクトを所有し、cut
、copy
イベントでは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()
-
cut
、copy
動作で触発されるClipboard Event
では、指定された型のデータをclipboardData
プロパティに設置することができる。
-
-
DataTransfer.getData()
-
paste
動作で触発されるClipboard Event
では、clipboardData
プロパティから指定された型のデータを取得する。データがない場合は空の文字列を返す。
-
Clipboard Event API
でcut
、copy
、paste
イベントをオーバーライドすることができる。(例えばどんなテキストをコピーしたとしても、.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にあります。
元々Clipboard
とAsync Clipboard API
を使わず、ボタン押しだけでコピペできるメソッドを探していたけど、ClipboardData
にはこれらのイベントがなければClipboardData
プロパティをもたないことに気づきました。
それにwindow.clipboardData
はセキュリティ上の問題があってClipboard
とAsync 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()
- クリップボードにアクセスして、
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
-
query(PermissionDescriptor)
-
PermissionDescriptor
オブジェクトでname
プロパティによってPermission
状態をチェックすることができる。 - 解決された
Promise
としてPermissionStatus
オブジェクトを返す。-
permissionStatus.state
ではgranted
、denied
、prompt
のいずれを返す。
-
-
-
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
参考文章のデモコードから一部変更したものです。
<section id="permissions"></section>
#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";
}
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
<input type="text" value="Hello world" id="input" />
<button id="btn">Copy</button>
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'
)}`
);
});
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
の使用には、ブラウザがサポートされてるかを確認、あるいはフォールバックする。
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
に所属するけれど、ClipboardEvent
のclipboardData
と、Clipboard
のclipboardItem
の仕組みは全然違います。
clipboardData
は読み取り専用のプロパティなので、Drag and Drop API
のDataTransfer
メソッドに頼っています。その上同期API
としてブロッキング問題を起こしうるかもしれません。
考えとしてはまず非同期関数で包んでブロッキング問題を解消したいと思います。
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()
がどうなるかは気になるので、様子はこんな感じです。
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
イベントで触発することはできないのでメモとして残しておきたいと思います。(触発できるかもしれないが、何か違う、一貫性がない気がして突き止めるのやめました。)
ここからは本番です。
<input type="url" placeholder="Image URL" id="image-url" />
<button id="btn-paste">paste</button>
<div id="paste-image-field"></div>
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}`);
}
});
// 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 {}
}