ExcelからtextareaにコピペするとHTMLのtableに変換するスニペット

ユースケース

  • Excelから社内ブログやWikiに表をコピペしたい

実装

とりあえず paste イベントをうけて処理するのでそのようにする。今回はCodeMirrorで制御されているtextareaなので、CodeMirrorを使ってないない場合は適宜読み替えてください。

ClipboardData

pasteで発行されるClipBoardEventに clipboardData: DataTransfer プロパティがあります。

spec: https://www.w3.org/TR/clipboard-apis/

// Web IDL
dictionary ClipboardEventInit : EventInit {
  DataTransfer? clipboardData = null;
};

こいつが items: DataTransferItemList をもっています。

// Web IDL
interface DataTransfer {
  attribute DOMString dropEffect;
  attribute DOMString effectAllowed;

  [SameObject] readonly attribute DataTransferItemList items;

  void setDragImage(Element image, long x, long y);

  /* old interface */
  [SameObject] readonly attribute DOMString[] types;
  DOMString getData(DOMString format);
  void setData(DOMString format, DOMString data);
  void clearData(optional DOMString format);
  [SameObject] readonly attribute FileList files;
}

なぜクリップボードの中身がリスト状の items になっているかというと、コピーしたものの表現が単一ではないからですね。

たとえば、ブラウザからのコピペはスタイルが適用されているので、 text/plain なプレインテキストと text/html なHTMLテキスト両方を含みます。画像の場合、参照URLがtext/plainで、画像のバイナリデータがimage/*で来ます。ただし、詳細はブラウザごとの違いも多く、たとえばLibreOfficeのスプレッドシートからのペーストは、Chromeだと画像化したtext/pngなitemが含まれますが、IEやFirefoxではテキストデータのみ存在します。

// Web IDL
interface DataTransferItemList {
  readonly attribute unsigned long length;
  getter DataTransferItem (unsigned long index);
}

さて、このitemsの要素は DataTransferItemで、クリップボードの中身の実体です。kindは文字列かファイルかという情報で、typeがMIME typeです。ただし、Firefoxなどitemsの存在しないブラウザもあります。まだ仕様が標準化されていないからでしょう。

// Web IDL
interface DataTransferItem {
  readonly attribute DOMString kind; // "string" or "file"
  readonly attribute DOMString type; // "text/plain", "image/png", ...
  void getAsString(FunctionStringCallback? _callback);
  File? getAsFile();
};

callback FunctionStringCallback = void (DOMString data);

ここで、ExcelのシートからChromeにペーストすると、 items の中身は text/plain, text/html, image/png が入っており、最後の画像データは該当部分のスクショになっています。Excelの要素をGitHub issuesのtextareaにExcelからコピペすると、画像が貼り付けられるのはそのためです。

画像のコピーの場合、itemstext/plain, image/png なので、text/htmlの有無が画像コピーとの違いとなります。

なので、「画像のコピーは画像としてアップロードして貼り付けて、Excelからのコピペは<table>...</table>に変換して貼り付ける」とするならば、以下のロジックでよさそうです。

  • text/plaintext/html のデータが存在して、 text/html のなかに table 要素が含まれるとき、その table 要素をペーストする

以上を加味すると、ペーストイベントをハンドルするイベントリスナは以下のようになるでしょう。

// JavaScript
var editor = CodeMirror.fromTextArea(...);

editor.on('paste', function(_, e) {
  // see https://html.spec.whatwg.org/multipage/interaction.html
  const clipboardData = e.clipboardData;

  const plainTextItem = clipboardData.getData('text/plain');
  const rtfItem = clipboardData.getData('text/rtf');
  const htmlItem = clipboardData.getData('text/html');
  const imageItem = (function (items) {
    if (!items) {
      return null;
    }

    for (let i = 0; i < items.length; i++) {
      if (items[i].type.startsWith('image/')) {
        return items[i];
      }
    }
    return null;
  })(clipboardData.items);

  console.log(clipboardData.types);
  console.log([plainTextItem, rtfItem, htmlItem, imageItem]);
  if (htmlItem && plainTextItem) {

    // rich contents, such as Excel and Power Point
    const html = new DOMParser().parseFromString(htmlItem, 'text/html');
    const tables = html.getElementsByTagName('table');

    if (tables.length) { // includes <table> tags
      const content = html.getElementsByTagName('body')[0].innerHTML;
      editor.replaceSelection(content);
      e.preventDefault();
      e.stopPropagation();
    } else {
      // TODO: ask users what to paste (HTML or plain text)
      // fallback to default
    }

  } else if (imageItem) {
    const blob = imageItem.getAsFile();
    const matched = blob.type.match(/^image\/(\w+)$/);
    if (matched) {
      const ext = matched[1]; // e.g. "png", "jpeg", or "gif"
      const filename = 'clipboard.' + ext;
      const formData = new FormData();
      formData.append('file', blob, filename);
      upload(formData);

      e.preventDefault();
      e.stopPropagation();
    }
  }
});

というわけで実装してみたものの、だいぶ複雑だしブラウザによる挙動の違いもあるのでちょっと使いづらい部分があります。

TSVやCSVをコードスニペットとして貼り付けるとtableとして表示する、というほうがいいかもしれないですね。