⌨️
Clipboard APIでJSオブジェクト丸見えでペーストしたくないしカスタムMIMEタイプを使いたい
問題
ウェブアプリ上で内部的にはJavaScriptのオブジェクトを使っていることがよくあります。
オブジェクト保ったままコピー&ペーストしたいのもよくあります。
たとえば:
最近シーケンサのUIデザインとかをやらせていただいているVOICEVOXのノートは内部的にオブジェクトでコピー&ペーストを実現しています。

しかしオブジェクトなので普通の入力欄にペーストすると丸見えで残念なことになります。

内部オブジェクトを見せたくない
ClipboardAPIの書き込みにおいて
-
text/htmlにオブジェクトを埋め込んでおく -
text/plainに空文字などを入れておく
たいていのアプリでtext/html側が無視されてtext/plain側の空文字がペーストされます。
トリッキーですこし気持ち悪いですが……。
クリップボードに書き込む側
writeToClipboard
async function writeCustomDataToClipboard(serializedData: string): Promise<void>
// HTML要素のdata属性にオブジェクトを埋め込む
const encodedHtmlData = `<i data-custom-type="${encodeURIComponent(serializedData)}" />`;
// ノートデータを持つtext/html
const textHtmlBlob = new Blob([encodedHtmlData], {
type: "text/html",
});
// 多くの場合に表示される空文字
const emptyTextBlob = new Blob([""], {
type: "text/plain",
});
const clipboardItem = new ClipboardItem({
"text/html": textHtmlBlob,
"text/plain": emptyTextBlob,
});
await navigator.clipboard.write([clipboardItem]);
}
}
クリップボードから読み込む側
readFromClipboard
export async function readCustomDataFromClipboard(): Promise<string> {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
// text/html
if (item.types.includes("text/html")) {
const blob = await item.getType("text/html");
const htmlText = await blob.text();
const domParser = new DOMParser();
const doc = domParser.parseFromString(htmlText, "text/html");
// data-custom-typeデータ属性を持つ要素を取得
const elementCandidate = doc.querySelector(
"[data-custom-type]",
);
// 要素が取得できないなら次のClipboardItemへ
if (!elementCandidate) continue;
// data-custom-typeデータ属性値を取得
const encodedData = elementCandidate.getAttribute(
"data-custom-type",
);
// 属性値がないなら次のClipboardItemへ
if (!encodedData) continue;
const decodedData = decodeURIComponent(encodedData);
return decodedData;
}
// text/plainでなにかやりたいならこのあたりで
}
return "";
}
カスタムMIMEタイプを使う
いろいろと気持ち悪いのですが、ClipboardAPIでのMIMEタイプは実質text/plainとせいぜいtext/htmlしか使えません。
セキュリティもあるため仕方なしです……と思いきや、特定の状況ではChromeではカスタムフォーマットが使えます。
Async Clipboard API のウェブ カスタム フォーマット
web image/jpegなど、頭にwebをつければ使えます。
これで特定のアプリの特定の形式をMIMEタイプを見てどうにかすることができることがあります。
クリップボードにカスタムMIMEタイプで書き込む側
writeWithMimeType
// カスタムMIMEタイプ
const YOUR_CUSTOM_MIME_TYPE = "web application/vnd.your-app.custom-type";
async function writeCustomDataToClipboard(serializedData: string): Promise<void> {
try {
// 1. カスタムMIMEタイプを利用してコピー
const customTypeBlob = new Blob([serializedData], {
type: YOUR_CUSTOM_MIME_TYPE,
});
const clipboardItem = new ClipboardItem({
[YOUR_CUSTOM_MIME_TYPE]: notesBlob,
});
await navigator.clipboard.write([clipboardItem]);
} catch {
// 2. カスタムMIMEタイプが利用できない(Chrome以外のブラウザ環境)の場合フォールバック
// web xxx形式で書き込んでみて失敗したかで判定するのが今のところ妥当そう
const encodedHtmlData = `<i data-custom-type="${encodeURIComponent(serializedData)}" />`;
// ノートデータを持つtext/html
const textHtmlBlob = new Blob([encodedHtmlData], {
type: "text/html",
});
// 多くの場合に表示される空文字
const emptyTextBlob = new Blob([""], {
type: "text/plain",
});
const clipboardItem = new ClipboardItem({
"text/html": textHtmlBlob,
"text/plain": emptyTextBlob,
});
await navigator.clipboard.write([clipboardItem]);
}
}
クリップボードにカスタムMIMEタイプで読み込む側
readWithCustomMimeType
export async function readCustomDataFromClipboard(): Promise<string> {
const clipboardItems = await navigator.clipboard.read();
for (const item of clipboardItems) {
// 1. カスタムMIMEタイプがあればそれを優先してパース
if (item.types.includes(YOUR_CUSTOM_MIME_TYPE)) {
const blob = await item.getType(YOUR_CUSTOM_MIME_TYPE);
const dataText = await blob.text();
return dataText;
}
// 2. ない場合はtext/html
if (item.types.includes("text/html")) {
const blob = await item.getType("text/html");
const htmlText = await blob.text();
const domParser = new DOMParser();
const doc = domParser.parseFromString(htmlText, "text/html");
// data-custom-typeデータ属性を持つ要素を取得
const elementCandidate = doc.querySelector(
"[data-custom-type]",
);
// 要素が取得できないなら次のClipboardItemへ
if (!elementCandidate) continue;
// data-voicevox-song-notesデータ属性値を取得
const encodedData = elementCandidate.getAttribute(
"data-custom-type",
);
// 属性値がないなら次のClipboardItemへ
if (!encodedData) continue;
const decodedData = decodeURIComponent(encodedData);
return decodedData;
}
// text/plainでなにかやりたいならこのあたりで
}
return "";
}
※ エラー処理やバリデーションなどは省いています。
Clipboard関係は考えることが多くて思っているより大変です。
なにか問題などありましたら&もっといい方法あればお知らせいただけると助かります!
読んでいただきありがとうございました。
Discussion