はじめに
前回の記事「プリザンターの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 |
各行は | 区切りで始まり、ヘッダー行の次に |---|---| 形式の区切り行が続きます。区切り行には列の整列指定(:---・---:・:---:)が入ることもあります。テーブルを抽出するには次の手順が必要です。
-
|で始まる行を連続したブロックとして収集する - ブロック内に区切り行(
-と:と空白だけからなる行)が存在するものを有効なテーブルと判定する - 区切り行を除いた各行からセルのテキストを取り出す
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/ に配置します。
/* コピー成否トースト */
.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/ に配置します。
$(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でコメント欄など後から動的に追加される要素にも自動対応