⌨️

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 のウェブ カスタム フォーマット
https://developer.chrome.com/blog/web-custom-formats-for-the-async-clipboard-api?hl=ja

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