0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

プリザンターのMarkdownテーブルをExcelにそのままコピーする機能を追加してみる

0
Posted at

はじめに

前回の記事「プリザンターのMarkdownエディタにExcel表をそのまま貼り付ける機能を追加してみる」では、ExcelやスプレッドシートでコピーしたデータをプリザンターのMarkdownフィールドへ変換挿入する機能を実装しました。

今回はその逆です。プリザンターのMarkdownフィールドに書かれたテーブルをクリップボードにコピーし、Excelに貼り付けると各セルに展開されるようにします。拡張スクリプトと拡張スタイルだけで以下の機能を実装します。

  • Markdownエディタの下ツールバーに「Excelにコピー」ボタンを追加
  • クリックするとフィールド内のMarkdownテーブルを自動検出
  • テーブルが1つなら即座にクリップボードへコピー、複数ある場合はモーダルで選択
  • TSV(タブ区切り)形式でコピーするためExcelでCtrl+Vするとセルに展開される
  • コピーの成否をトースト通知で表示

バージョン 1.5.1.0 以降を対象にしています

仕組みを整理する

Markdownテーブルの構造

プリザンターのMarkdownフィールドに書かれたテーブルは、Qiita Markdownと同じパイプ区切り形式です。

| 商品名 | 単価 | 数量 |
|---|---|---|
| りんご | 120 | 5 |
| みかん | 80 | 10 |

各行は | 区切りで始まり、ヘッダー行の次に |---|---| 形式の区切り行が続きます。区切り行には列の整列指定(:---・---:・:---:)が入ることもあります。テーブルを抽出するには次の手順が必要です。

  1. | で始まる行を連続したブロックとして収集する
  2. ブロック内に区切り行(- と : と空白だけからなる行)が存在するものを有効なテーブルと判定する
  3. 区切り行を除いた各行からセルのテキストを取り出す

Excelへのコピーフォーマット

ExcelはクリップボードからTSV(タブ区切り)のテキストを受け取ると、タブで列に、改行で行に分割して各セルへ展開します。

商品名[TAB]単価[TAB]数量
りんご[TAB]120[TAB]5
みかん[TAB]80[TAB]10

([TAB] はタブ文字を表しています)

つまり、Markdownテーブルのセルをタブ文字で結合し、行を改行で結合すればExcel対応のクリップボードデータになります。

クリップボードへの書き込み

クリップボードへのテキスト書き込みには navigator.clipboard.writeText() を使用します。この API はHTTPSまたはlocalhostでのみ動作します。HTTPで運用されているプリザンター環境向けに、document.execCommand('copy') によるフォールバックも実装します。

実装してみよう

拡張スクリプトと拡張スタイルの2ファイルで構成します。

拡張機能 役割
拡張スクリプト ボタン追加・テーブル抽出・TSV変換・クリップボードコピー・通知表示
拡張スタイル 複数テーブル選択モーダルとトースト通知の見た目を定義

モーダルとトーストのスタイル(拡張スタイル)

複数テーブルが存在するときの選択モーダルと、コピー成否を伝えるトースト通知のスタイルを定義します。拡張スタイルとして App_Data/Parameters/ExtendedStyles/ に配置します。

ExtendedStyles/MdTableCopy.css
/* コピー成否トースト */
.md-tbl-copy-toast {
  position: fixed;
  background: #333;
  color: #fff;
  font-size: 12px;
  padding: 4px 10px;
  border-radius: 4px;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s;
  pointer-events: none;
  z-index: 10000;
}

.md-tbl-copy-toast.is-error {
  background: #c0392b;
}

.md-tbl-copy-toast.is-visible {
  opacity: 1;
}

/* 複数テーブル選択モーダル */
#md-tbl-copy-modal .md-tbl-copy-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 9998;
}

#md-tbl-copy-modal .md-tbl-copy-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  z-index: 9999;
  width: min(480px, 90vw);
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  display: flex;
  flex-direction: column;
  gap: 12px;
}

#md-tbl-copy-modal .md-tbl-copy-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: bold;
  font-size: 14px;
}

#md-tbl-copy-modal .md-tbl-copy-header button {
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  display: flex;
  align-items: center;
}

#md-tbl-copy-modal .md-tbl-copy-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 240px;
  overflow-y: auto;
}

#md-tbl-copy-modal .md-tbl-copy-item {
  padding: 8px 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
  font-size: 13px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

#md-tbl-copy-modal .md-tbl-copy-item:hover {
  background: #f5f5f5;
}

#md-tbl-copy-modal .md-tbl-copy-item.is-selected {
  border-color: #007bff;
  background: #e8f0fe;
}

#md-tbl-copy-modal .md-tbl-copy-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

#md-tbl-copy-modal #md-tbl-copy-exec {
  background: #007bff;
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 6px 16px;
  cursor: pointer;
  font-size: 14px;
}

#md-tbl-copy-modal #md-tbl-copy-exec:hover {
  background: #0056b3;
}

#md-tbl-copy-modal #md-tbl-copy-cancel {
  background: #f0f0f0;
  color: #333;
  border: 1px solid #ccc;
  border-radius: 4px;
  padding: 6px 16px;
  cursor: pointer;
  font-size: 14px;
}

#md-tbl-copy-modal #md-tbl-copy-cancel:hover {
  background: #e0e0e0;
}

ボタン追加とコピー処理(拡張スクリプト)

拡張スクリプトとして App_Data/Parameters/ExtendedScripts/ に配置します。

ExtendedScripts/MdTableCopy.js
$(function () {
  // ページ読み込み時に既存の markdown-field にボタンを追加
  document.querySelectorAll('markdown-field').forEach(addMdTblCopyBtn);

  // コメント欄などの動的追加に対応
  var observer = new MutationObserver(function (mutations) {
    mutations.forEach(function (m) {
      m.addedNodes.forEach(function (node) {
        if (node.nodeName === 'MARKDOWN-FIELD') addMdTblCopyBtn(node);
      });
    });
  });
  observer.observe(document.body, { childList: true, subtree: true });
});

var mdTblCopyTextarea = null;
var mdTblCopyBtn = null;

// 各 markdown-field にコピーボタンを追加する
function addMdTblCopyBtn(mdField) {
  // 追加済みならスキップ
  if (mdField.querySelector('.md-tbl-copy-btn')) return;

  var textarea = mdField.querySelector('textarea');
  if (!textarea) return;

  // 閲覧専用フィールドは対象外
  if (textarea.disabled || textarea.dataset.readonly) return;

  // bottom-tools が存在しない場合は新規作成
  var $field = $(mdField);
  var $bottomTools = $field.find('.bottom-tools');
  if (!$bottomTools.length) {
    $field.append('<ul class="bottom-tools"></ul>');
    $bottomTools = $field.find('.bottom-tools');
  }

  var $btn = $('<button>', {
    type: 'button',
    class: 'md-btn md-tbl-copy-btn',
    title: 'MarkdownテーブルをExcel用にコピー'
  }).append(
    $('<span>', {
      class: 'md-btn-icon material-symbols-outlined',
      text: 'content_copy'
    })
  );

  $btn.on('click', function (e) {
    e.preventDefault();
    mdTblCopyTextarea = textarea;
    mdTblCopyBtn = this;
    handleMdTblCopy();
  });

  $bottomTools.append($('<li>').append($btn));
}

// コピー処理のエントリポイント
function handleMdTblCopy() {
  var text = mdTblCopyTextarea.value || '';
  // [md] プレフィックスを除去してからテーブルを探す
  var mdText = text.replace(/^\[md\]\s*/, '');
  var tables = extractMarkdownTables(mdText);

  if (tables.length === 0) {
    showMdTblCopyToast('テーブルが見つかりません', true);
    return;
  }

  if (tables.length === 1) {
    // テーブルが1つなら即座にコピー
    var tsv = markdownTableToTsv(tables[0].lines);
    copyToClipboard(tsv, function () {
      showMdTblCopyToast('コピーしました', false);
    });
    return;
  }

  // テーブルが複数ある場合はモーダルで選択
  openMdTblCopyModal(tables);
}

// Markdown テキストからテーブルを抽出する
function extractMarkdownTables(text) {
  var tables = [];
  var lines = text.split(/\r?\n/);
  var i = 0;
  while (i < lines.length) {
    if (lines[i].trim().charAt(0) === '|') {
      // | で始まる行を連続して収集
      var tableLines = [];
      while (i < lines.length && lines[i].trim().charAt(0) === '|') {
        tableLines.push(lines[i]);
        i++;
      }
      // 区切り行を持つブロックのみ有効なテーブルとみなす
      var hasSeparator = tableLines.some(isSeparatorRow);
      if (tableLines.length >= 2 && hasSeparator) {
        var headerCells = parseMdTableRow(tableLines[0]);
        tables.push({
          lines: tableLines,
          preview: headerCells.join(' / ')
        });
      }
    } else {
      i++;
    }
  }
  return tables;
}

// 区切り行(|---|---| 形式)かどうかを判定する
function isSeparatorRow(line) {
  return /^\|(\s*:?-+:?\s*\|)+$/.test(line.trim());
}

// Markdown テーブルの1行からセルのテキスト配列を返す
function parseMdTableRow(line) {
  return line.trim().split('|').slice(1, -1).map(function (c) {
    // \| エスケープを | に戻す
    return c.trim().replace(/\\\|/g, '|');
  });
}

// Markdown テーブルの行配列を TSV 文字列に変換する
function markdownTableToTsv(tableLines) {
  var rows = [];
  tableLines.forEach(function (line) {
    // 区切り行はスキップ
    if (isSeparatorRow(line)) return;
    rows.push(parseMdTableRow(line).join('\t'));
  });
  return rows.join('\n');
}

// クリップボードにテキストを書き込む
function copyToClipboard(text, onSuccess) {
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(onSuccess, function () {
      fallbackCopy(text, onSuccess);
    });
  } else {
    fallbackCopy(text, onSuccess);
  }
}

// execCommand を使ったフォールバック(HTTP 環境向け)
function fallbackCopy(text, onSuccess) {
  var ta = document.createElement('textarea');
  ta.value = text;
  ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
  document.body.appendChild(ta);
  ta.focus();
  ta.select();
  try { document.execCommand('copy'); onSuccess(); } catch (e) {}
  document.body.removeChild(ta);
}

// ボタン付近にトースト通知を表示する
function showMdTblCopyToast(message, isError) {
  var $toast = $('<div>', {
    class: 'md-tbl-copy-toast' + (isError ? ' is-error' : ''),
    text: message
  }).appendTo('body');

  var rect = mdTblCopyBtn.getBoundingClientRect();
  $toast.css({
    top: Math.max(4, rect.top - 34),
    left: Math.max(4, rect.left - 20)
  });

  setTimeout(function () { $toast.addClass('is-visible'); }, 10);
  setTimeout(function () {
    $toast.removeClass('is-visible');
    setTimeout(function () { $toast.remove(); }, 200);
  }, 1800);
}

// 複数テーブル選択モーダルを開く
function openMdTblCopyModal(tables) {
  if (!$('#md-tbl-copy-modal').length) {
    $('body').append(
      '<div id="md-tbl-copy-modal" style="display:none">' +
        '<div class="md-tbl-copy-overlay"></div>' +
        '<div class="md-tbl-copy-dialog">' +
          '<div class="md-tbl-copy-header">' +
            '<span>コピーするテーブルを選択</span>' +
            '<button type="button" id="md-tbl-copy-close">' +
              '<span class="material-symbols-outlined">close</span>' +
            '</button>' +
          '</div>' +
          '<ul class="md-tbl-copy-list" id="md-tbl-copy-list"></ul>' +
          '<div class="md-tbl-copy-actions">' +
            '<button type="button" id="md-tbl-copy-exec">コピー</button>' +
            '<button type="button" id="md-tbl-copy-cancel">キャンセル</button>' +
          '</div>' +
        '</div>' +
      '</div>'
    );
    $('#md-tbl-copy-close, .md-tbl-copy-overlay, #md-tbl-copy-cancel')
      .on('click', closeMdTblCopyModal);
    $('#md-tbl-copy-exec').on('click', doMdTblCopy);
  }

  var $list = $('#md-tbl-copy-list').empty();
  tables.forEach(function (table, idx) {
    $list.append(
      $('<li>', {
        class: 'md-tbl-copy-item' + (idx === 0 ? ' is-selected' : ''),
        'data-idx': idx,
        text: '表 ' + (idx + 1) + ':' + table.preview
      }).on('click', function () {
        $list.find('.md-tbl-copy-item').removeClass('is-selected');
        $(this).addClass('is-selected');
      })
    );
  });

  $('#md-tbl-copy-modal').data('tables', tables).show();
}

// 複数テーブル選択モーダルを閉じる
function closeMdTblCopyModal() {
  $('#md-tbl-copy-modal').hide();
}

// モーダルで選択されたテーブルをコピーする
function doMdTblCopy() {
  var tables = $('#md-tbl-copy-modal').data('tables');
  var $sel = $('#md-tbl-copy-list .md-tbl-copy-item.is-selected');
  var idx = parseInt($sel.data('idx'), 10);
  if (!tables || isNaN(idx)) { closeMdTblCopyModal(); return; }

  var tsv = markdownTableToTsv(tables[idx].lines);
  copyToClipboard(tsv, function () {
    closeMdTblCopyModal();
    showMdTblCopyToast('コピーしました', false);
  });
}

各処理のポイントを見ていきましょう。

テーブルの抽出

extractMarkdownTables は | で始まる行を連続したブロックとして収集し、区切り行(|---|)を含むものだけを有効なテーブルとして返します。これにより、インラインの | を含む箇条書きなど、テーブル以外の行を誤検知しません。

ヘッダーのセル内容は preview としてモーダルのリストに表示するために利用します。

区切り行の判定

区切り行は /^\|(\s*:?-+:?\s*\|)+$/ という正規表現で判定します。各セルが -・:・空白だけで構成されている行に一致します。左寄せ(:---)・右寄せ(---:)・中央寄せ(:---:)の整列指定もすべて正しくスキップされます。

\| エスケープの処理

Markdownではセル内に | を書きたい場合に \| とエスケープします。parseMdTableRow ではセルのテキストを取り出した後に replace(/\\\|/g, '|') でエスケープを元に戻しています。Excelにはエスケープのない素のテキストとして貼り付けられます。

Clipboard API とフォールバック

navigator.clipboard.writeText() はモダンブラウザで利用できる非同期のクリップボード書き込み API です。HTTPS または localhost でのみ動作するため、HTTP 環境用に document.execCommand('copy') を使ったフォールバック関数 fallbackCopy も用意しています。execCommand は非推奨ですが、HTTP のプリザンター環境ではこちらが機能します。

複数テーブルの選択UI

テーブルが2つ以上存在する場合はモーダルが開き、各テーブルのヘッダー列名を「表 N:列1 / 列2 / ...」の形式でリスト表示します。クリックで選択状態を切り替え、「コピー」ボタンで選択中のテーブルをコピーします。

モーダルは初回表示時に1度だけ DOM に追加され、以降は再利用します。テーブルリストは $('#md-tbl-copy-list').empty() で毎回クリアして再構築するため、フィールドをまたいで何度呼び出しても正しく動作します。

トースト通知

ボタンの getBoundingClientRect() でビューポート内の位置を取得し、position: fixed のトースト要素をボタン付近に配置します。0.15秒のフェードインの後、1.8秒後にフェードアウトして DOM から削除します。テーブルが見つからなかった場合は赤色(.is-error)で表示します。

使ってみる

テーブルが1つの場合

Markdownフィールドに1つのテーブルがある状態でボタンをクリックすると、確認なしに即座にクリップボードへコピーされます。ボタン付近に「コピーしました」というトーストが表示されます。

(フィールドの内容)
[md]
| 商品名 | 単価 | 数量 |
|---|---|---|
| りんご | 120 | 5 |
| みかん | 80 | 10 |

Excelのセルを選択してCtrl+Vで貼り付けると次のようにセルに展開されます。

商品名 単価 数量
りんご 120 5
みかん 80 10

テーブルが複数ある場合

Markdownフィールドに2つ以上のテーブルが書かれているとき、ボタンをクリックするとモーダルが開きます。各テーブルのヘッダー列名が一覧表示されるので、コピーしたいテーブルをクリックして選択し「コピー」をクリックします。

(フィールドの内容)
[md]
## 売上テーブル

| 商品名 | 単価 | 数量 |
|---|---|---|
| りんご | 120 | 5 |

## 在庫テーブル

| 商品名 | 在庫数 | 倉庫 |
|---|---|---|
| みかん | 200 | A棟 |

モーダルには「表 1:商品名 / 単価 / 数量」「表 2:商品名 / 在庫数 / 倉庫」のように表示されます。

Excelへの貼り付け結果

コピーしたデータはTSV形式のため、Excelのセルを選択してCtrl+Vを押すと以下のように各セルへ展開されます。ヘッダー行もデータ行と同様にセルに入るため、Excelでそのままフィルターや集計に使えます。

GoogleスプレッドシートやLibreOffice Calcなど、TSVを受け付けるアプリケーションでも同様にセルへ展開できます。

テーブルが見つからない場合

フィールドにMarkdownテーブルが書かれていないとき、またはMarkdownモードでない([md] がない)場合は「テーブルが見つかりません」という赤いトーストが表示されます。

テーブルの構文として正しく認識されるには、ヘッダー行の直後に区切り行(|---|---|)が必要です。区切り行のないパイプ区切りの行はテーブルとして検出されません。

まとめ

プリザンターのMarkdownエディタからExcel用のクリップボードデータを生成する機能を、拡張スクリプトと拡張スタイルだけで実装しました。

  • | で始まる連続行を収集し、区切り行を含むブロックのみを有効なテーブルとして抽出
  • 区切り行は /^\|(\s*:?-+:?\s*\|)+$/ で判定し、整列指定(:---・---:・:---:)にも対応
  • \| エスケープを解除してセルの素のテキストを取得し、タブ文字で結合してTSV形式に変換
  • navigator.clipboard.writeText() を優先し、HTTP 環境では document.execCommand('copy') にフォールバック
  • テーブルが1つなら即コピー、複数ならモーダルでヘッダーを見て選択できるUI
  • コピーの成否をトーストで通知し、テーブルが見つからない場合は赤色で表示
  • MutationObserver でコメント欄など後から動的に追加される要素にも自動対応
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?