Markdown エディタと Excel の連携(表の貼り付け・コピー)
Markdown エディタの下部ツールバーにボタンを 2 つ追加し、Excel と Markdown テーブルを相互にやり取りできるようにします。どちらも拡張スクリプトと拡張スタイルだけで実装でき、本体の改修は不要です。
- 表貼り付け: Excel・スプレッドシート・Web ページ・CSV からコピーした表を、Markdown テーブルに変換してカーソル位置に挿入します。
- Excel にコピー: フィールド内の Markdown テーブルを TSV(タブ区切り)でクリップボードに入れ、Excel に貼り付けると各セルに展開されるようにします。
INFO
バージョン 1.5.1.0 以降が対象です。本文・説明・コメントのすべての Markdown エディタで動作します。Markdown モードそのものの仕様は Markdown の機能差異とリッチテキストへの移行 を参照してください。
共通の仕組み
Markdown エディタの構造
Markdown エディタは markdown-field カスタム要素として実装されており、内部は次の構造です。
<markdown-field>
<textarea class="md-editor ..."></textarea>
<div class="md-viewer md"></div>
<button type="button" class="md-btn is-editor">...</button>
<button type="button" class="md-btn is-viewer">...</button>
<ul class="bottom-tools">
<li>
<label class="md-btn is-files">
<span class="md-btn-icon material-symbols-outlined">imagesmode</span>
<input type="file" ...>
</label>
</li>
<li>
<button type="button" class="md-btn is-photo">
<span class="md-btn-icon material-symbols-outlined">add_a_photo</span>
</button>
</li>
</ul>
</markdown-field>画像ファイル選択ボタン(imagesmode)とカメラ撮影ボタン(add_a_photo)が並ぶ bottom-tools に、新しいボタンを li として追加します。
bottom-toolsは画像挿入が有効なフィールド(textareaにupload-imageクラスがある)に表示され、無効なフィールドではmarkdown-fieldの初期化時に削除されます(markdownField.ts#L100-L104)。画像挿入が無効なフィールドでは、スクリプト側でbottom-toolsを作ってからボタンを置きます。- 閲覧専用のフィールド(
textareaがdisabled、またはdata-readonly付き)は対象外にします。閲覧専用のフィールドでは、初期化時にtextareaがdisabledになるか削除されます(markdownField.ts#L114-L123)。
ボタンを追加するタイミング
$(function () { ... }) は HTML の解析と markdown-field の初期化が終わった後に実行されるので、ここで既存の markdown-field すべてにボタンを追加します。コメント欄のように後から動的に追加される要素には MutationObserver で対応し、markdown-field が追加されるたびに同じ処理を呼びます。保存後のコメント欄は、markdown-field を含むコメントの要素ごと ReplaceAll で差し替えられるため(ResponseComment.cs#L47-L54)、追加されたノード自身だけでなく子孫の markdown-field も探します。追加ノード自身が MARKDOWN-FIELD の場合だけを見ると、この差し替えを検出できません。
WARNING
確認したソースでは、コメント欄の bottom-tools は編集状態(markdown-field に data-editable 属性がある状態)のときだけ表示されます(markdownField.scss#L144-L172)。そのため、コメント欄に追加したボタンもコメントを編集している間だけ表示されます。ビューアタイプが disabled の場合は常に編集状態になるので、ボタンも常に表示されます(markdownField.ts#L92-L97)。
Excel とクリップボードのデータ形式
Excel やスプレッドシートでセルをコピーすると、クリップボードには TSV(タブ区切り)のテキストが入ります。逆に Excel は TSV を受け取ると、タブで列、改行で行に分けて各セルへ展開します。
商品名[TAB]単価[TAB]数量
りんご[TAB]120[TAB]5
みかん[TAB]80[TAB]10([TAB] はタブ文字)
これと対応する Markdown テーブルは次のとおりです。1 行目をヘッダー、以降をデータ行として扱います。
| 商品名 | 単価 | 数量 |
|---|---|---|
| りんご | 120 | 5 |
| みかん | 80 | 10 |つまり、TSV と Markdown テーブルは「タブ」と「| 区切り+区切り行」を相互に置き換えるだけで変換できます。
ファイル構成
| 機能 | 拡張スタイル(App_Data/Parameters/ExtendedStyles/) | 拡張スクリプト(App_Data/Parameters/ExtendedScripts/) |
|---|---|---|
| 表貼り付け | MdTablePaste.css(モーダルの見た目) | MdTablePaste.js(ボタン追加・モーダル表示・変換・挿入) |
| Excel にコピー | MdTableCopy.css(選択モーダルとトーストの見た目) | MdTableCopy.js(ボタン追加・テーブル抽出・TSV 変換・コピー・通知) |
拡張スクリプト・拡張スタイルの仕組みは 拡張スクリプトの仕組み と 拡張スタイルの仕組み を参照してください。
Excel などの表を貼り付ける
ボタン(アイコン table_chart)をクリックするとモーダルが開き、コピーした表を貼り付けて「挿入」を押すと Markdown テーブルに変換してカーソル位置に挿入します。

対応する入力形式と判定順
| 入力元 | クリップボードの形式 | 判定方法 |
|---|---|---|
| Web ページの表 | text/html(<table> を含む)+ TSV | 貼り付け時(paste イベント)に text/html を調べる |
| Excel・スプレッドシート | TSV | タブ文字を含むか |
| CSV(DB のエクスポートなど) | カンマ区切り | 上記に当てはまらないもの |
| Markdown テーブル | | で始まるテキスト | 先頭が | ならそのまま挿入 |
図を読み込み中…
Web ページの表は paste の時点で Markdown に変換済みになるため、「挿入」では「先頭が |」のルートで処理されます。貼り付け後、挿入前に内容を編集することもできます。
WARNING
colspan・rowspan で結合されたセルを含む複雑な表は正確に変換されない場合があります。Markdown テーブルはセル結合に対応していないため、結合前の個別セルの内容が展開されます。
CSV は RFC 4180 形式のクォートに対応しています。セル内にカンマを含む場合はダブルクォートで囲み、"" でクォート自体をエスケープできます。ただし行は \r?\n で分割してから解析するため、セル内改行を含む CSV には対応していません。
商品名,単価,備考
りんご,120,"甘い、美味しい"
みかん,80,普通挿入時の動作
- カーソル位置: ボタンをクリックした時点の
textarea.selectionStartを記録して挿入位置にします。ビューア表示中(テキストエリアが非表示)はselectionStartが意味を持たないため、テキスト末尾に挿入します。表示中かどうかはgetBoundingClientRect().width > 0で判定します。 [md]の自動付与: テキストが[md]で始まっていない(空欄やプレーンテキスト)場合は、先頭に[md]と改行を追加し、その 5 文字分だけ挿入位置をずらします。- 前後の改行: 挿入位置の前後が改行でなければ改行を補い、表が行の途中に入らないようにします。
- 反映: テキストエリアの値を書き換えた後に
changeイベントを発火します。markdown-fieldの内部ハンドラーがこれを受けて、$p.setによるデータの変更通知と、編集状態でなければビューアの再描画を行います(markdownField.ts#L614-L617)。
(挿入前)空欄、または [md] なしのテキスト
(挿入後)
[md]
| 列1 | 列2 |
|---|---|
| 値1 | 値2 |拡張スタイル
#md-table-modal .md-table-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
}
#md-table-modal .md-table-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 8px;
padding: 16px;
z-index: 9999;
width: min(600px, 90vw);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 12px;
}
#md-table-modal .md-table-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: bold;
font-size: 14px;
}
#md-table-modal .md-table-header button {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
#md-table-modal .md-table-hint {
margin: 0;
font-size: 12px;
color: #555;
}
#md-table-modal #md-table-input {
width: 100%;
height: 140px;
resize: vertical;
font-family: monospace;
font-size: 13px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-sizing: border-box;
}
#md-table-modal .md-table-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
#md-table-modal #md-table-insert {
background: #007bff;
color: #fff;
border: none;
border-radius: 4px;
padding: 6px 16px;
cursor: pointer;
font-size: 14px;
}
#md-table-modal #md-table-insert:hover {
background: #0056b3;
}
#md-table-modal #md-table-cancel {
background: #f0f0f0;
color: #333;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 16px;
cursor: pointer;
font-size: 14px;
}
#md-table-modal #md-table-cancel:hover {
background: #e0e0e0;
}拡張スクリプト
$(function () {
// ページ読み込み時に既存の markdown-field にボタンを追加
document.querySelectorAll('markdown-field').forEach(addMdTableBtn);
// コメント欄などの動的追加に対応
var observer = new MutationObserver(function (mutations) {
mutations.forEach(function (m) {
m.addedNodes.forEach(function (node) {
if (node.nodeType !== 1) return;
if (node.nodeName === 'MARKDOWN-FIELD') addMdTableBtn(node);
// 親要素ごと差し替えられた場合は子孫の markdown-field も探す
node.querySelectorAll('markdown-field').forEach(addMdTableBtn);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
});
var mdTableTargetId = null;
var mdTableSavedCaret = 0;
// 各 markdown-field にボタンを追加する
function addMdTableBtn(mdField) {
// 追加済みならスキップ
if (mdField.querySelector('.md-table-paste-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 controlId = textarea.id;
var $li = $('<li>').append(
$('<button>', {
type: 'button',
class: 'md-btn md-table-paste-btn',
title: 'Excelの表をMarkdownテーブルとして挿入'
}).append(
$('<span>', {
class: 'md-btn-icon material-symbols-outlined',
text: 'table_chart'
})
).on('click', function (e) {
e.preventDefault();
mdTableTargetId = controlId;
// textarea が表示中ならカーソル位置を記録、非表示なら末尾を使用
mdTableSavedCaret = textarea.getBoundingClientRect().width > 0
? textarea.selectionStart
: textarea.value.length;
openMdTableModal();
})
);
$bottomTools.append($li);
}
// モーダルを開く
function openMdTableModal() {
if (!$('#md-table-modal').length) {
$('body').append(
'<div id="md-table-modal" style="display:none">' +
'<div class="md-table-overlay"></div>' +
'<div class="md-table-dialog">' +
'<div class="md-table-header">' +
'<span>表をMarkdownテーブルとして挿入</span>' +
'<button type="button" id="md-table-close">' +
'<span class="material-symbols-outlined">close</span>' +
'</button>' +
'</div>' +
'<p class="md-table-hint">Excel・スプレッドシート・Webページの表をコピーして下に貼り付けてください。</p>' +
'<textarea id="md-table-input" placeholder="ここに貼り付け(Ctrl+V)"></textarea>' +
'<div class="md-table-actions">' +
'<button type="button" id="md-table-insert">挿入</button>' +
'<button type="button" id="md-table-cancel">キャンセル</button>' +
'</div>' +
'</div>' +
'</div>'
);
$('#md-table-close, .md-table-overlay').on('click', closeMdTableModal);
$('#md-table-cancel').on('click', closeMdTableModal);
$('#md-table-insert').on('click', doInsertMdTable);
// HTML テーブルの貼り付けをインターセプト(Webページの表コピーに対応)
document.getElementById('md-table-input').addEventListener('paste', function (e) {
var html = (e.clipboardData || window.clipboardData).getData('text/html');
var md = htmlTableToMarkdownTable(html);
if (md) {
e.preventDefault();
this.value = md;
}
// HTML テーブルでなければ通常の TSV 貼り付けをそのまま通す
});
}
$('#md-table-input').val('');
$('#md-table-modal').show();
setTimeout(function () { $('#md-table-input').focus(); }, 50);
}
// モーダルを閉じる
function closeMdTableModal() {
$('#md-table-modal').hide();
mdTableTargetId = null;
}
// 挿入処理
function doInsertMdTable() {
var pastedText = $('#md-table-input').val().trim();
if (!pastedText) { closeMdTableModal(); return; }
// 変換元の判定: 変換済み Markdown → TSV → CSV の順に確認
var mdTable;
if (pastedText.charAt(0) === '|') {
mdTable = pastedText; // すでに Markdown テーブル形式(そのまま挿入)
} else if (pastedText.indexOf('\t') !== -1) {
mdTable = tsvToMarkdownTable(pastedText); // タブ区切り(Excel・スプレッドシート)
} else {
mdTable = csvToMarkdownTable(pastedText); // カンマ区切り(CSV)
}
if (!mdTable) { closeMdTableModal(); return; }
var textarea = document.getElementById(mdTableTargetId);
if (!textarea) { closeMdTableModal(); return; }
var value = textarea.value;
var caret = mdTableSavedCaret;
// [md] プレフィックスがなければ先頭に追加してカーソル位置を補正
if (value.indexOf('[md]') !== 0) {
value = '[md]\n' + value;
caret += 5; // '[md]\n' の 5 文字分
}
// カーソル前後に改行を挿入してテーブルを自然に配置
var before = value.substr(0, caret);
var after = value.substr(caret);
if (before.length > 0 && before.charAt(before.length - 1) !== '\n') {
before += '\n';
}
if (after.length > 0 && after.charAt(0) !== '\n') {
after = '\n' + after;
}
textarea.value = before + mdTable + after;
// Pleasanter にデータの変更を通知(ビューア再描画も含む)
textarea.dispatchEvent(new Event('change', { bubbles: true }));
closeMdTableModal();
}
// TSV(タブ区切り)テキストを Markdown テーブルに変換する
function tsvToMarkdownTable(text) {
var rows = text.split(/\r?\n/).map(function (row) {
return row.split('\t');
});
// 空行を除去
rows = rows.filter(function (row) {
return row.some(function (cell) { return cell.trim() !== ''; });
});
if (rows.length === 0) return '';
var colCount = rows.reduce(function (max, row) {
return Math.max(max, row.length);
}, 0);
var lines = [];
// ヘッダー行
var header = rows[0].map(function (c) { return c.trim(); });
while (header.length < colCount) header.push('');
lines.push('| ' + header.join(' | ') + ' |');
// 区切り行
lines.push('|' + header.map(function () { return '---|'; }).join(''));
// データ行
for (var i = 1; i < rows.length; i++) {
var row = rows[i].map(function (c) { return c.trim(); });
while (row.length < colCount) row.push('');
lines.push('| ' + row.join(' | ') + ' |');
}
return lines.join('\n');
}
// HTML テーブルを Markdown テーブルに変換する(Webページの表コピーに対応)
function htmlTableToMarkdownTable(html) {
if (!html) return '';
var doc = new DOMParser().parseFromString(html, 'text/html');
var table = doc.querySelector('table');
if (!table) return '';
var lines = [];
var headerDone = false;
table.querySelectorAll('tr').forEach(function (tr) {
var cells = Array.from(tr.querySelectorAll('th, td')).map(function (cell) {
return cell.textContent.trim().replace(/\|/g, '\\|');
});
if (cells.length === 0) return;
lines.push('| ' + cells.join(' | ') + ' |');
if (!headerDone) {
lines.push('|' + cells.map(function () { return '---|'; }).join(''));
headerDone = true;
}
});
return lines.join('\n');
}
// CSV(カンマ区切り)テキストを Markdown テーブルに変換する
function csvToMarkdownTable(text) {
var rows = text.split(/\r?\n/).map(parseCsvRow);
rows = rows.filter(function (row) {
return row.some(function (cell) { return cell !== ''; });
});
if (rows.length === 0) return '';
var colCount = rows.reduce(function (max, row) {
return Math.max(max, row.length);
}, 0);
var lines = [];
// ヘッダー行
var header = rows[0].map(function (c) { return c.replace(/\|/g, '\\|'); });
while (header.length < colCount) header.push('');
lines.push('| ' + header.join(' | ') + ' |');
// 区切り行
lines.push('|' + header.map(function () { return '---|'; }).join(''));
// データ行
for (var i = 1; i < rows.length; i++) {
var row = rows[i].map(function (c) { return c.replace(/\|/g, '\\|'); });
while (row.length < colCount) row.push('');
lines.push('| ' + row.join(' | ') + ' |');
}
return lines.join('\n');
}
// RFC 4180 準拠の 1 行 CSV パーサー(クォートフィールド・エスケープ対応)
function parseCsvRow(line) {
var fields = [];
var i = 0;
while (i <= line.length) {
if (i === line.length) { fields.push(''); break; }
if (line[i] === '"') {
var field = '';
i++; // 開きクォートをスキップ
while (i < line.length) {
if (line[i] === '"' && i + 1 < line.length && line[i + 1] === '"') {
field += '"'; i += 2; // "" → " に展開
} else if (line[i] === '"') {
i++; break; // 閉じクォート
} else {
field += line[i++];
}
}
fields.push(field.trim());
if (i < line.length && line[i] === ',') i++;
} else {
var end = line.indexOf(',', i);
if (end === -1) { fields.push(line.substring(i).trim()); break; }
fields.push(line.substring(i, end).trim());
i = end + 1;
}
}
return fields;
}Markdown テーブルを Excel にコピーする
ボタン(アイコン content_copy)をクリックすると、フィールド内の Markdown テーブルを自動で検出し、TSV に変換してクリップボードへコピーします。Excel でセルを選択して Ctrl+V を押すと、ヘッダー行も含めて各セルに展開されます。Google スプレッドシートや LibreOffice Calc など、TSV を受け付けるアプリケーションでも同様です。
動作
| フィールドの状態 | 動作 |
|---|---|
| テーブルが 1 つ | 確認なしですぐにコピーし、ボタン付近に「コピーしました」とトースト表示 |
| テーブルが 2 つ以上 | モーダルを開き、「表 N:列1 / 列2 / ...」の形式でヘッダーを一覧表示。選択して「コピー」 |
| テーブルが見つからない | 「テーブルが見つかりません」と赤いトーストを表示 |
たとえば次の内容なら、モーダルには「表 1:商品名 / 単価 / 数量」「表 2:商品名 / 在庫数 / 倉庫」と表示されます。
[md]
## 売上テーブル
| 商品名 | 単価 | 数量 |
|---|---|---|
| りんご | 120 | 5 |
## 在庫テーブル
| 商品名 | 在庫数 | 倉庫 |
|---|---|---|
| みかん | 200 | A棟 |テーブルの抽出ルール
- 先頭の
[md]を取り除いたうえで、|で始まる行を連続したブロックとして集めます。 - ブロック内に区切り行があり、2 行以上あるものだけを有効なテーブルとみなします。これにより、
|を含む箇条書きなどを誤検知しません。 - 区切り行を除いた各行からセルのテキストを取り出し、タブで結合、行を改行で結合して TSV にします。
- 区切り行の判定: 正規表現
/^\|(\s*:?-+:?\s*\|)+$/で判定します。各セルが-・:・空白だけの行に一致し、左寄せ(:---)・右寄せ(---:)・中央寄せ(:---:)の整列指定も区切り行として扱われます。 \|エスケープ: セル内の\|は|に戻してからコピーするので、Excel にはエスケープのない素のテキストが入ります。
WARNING
テーブルとして認識されるには、ヘッダー行の直後に区切り行(|---|---|)が必要です。区切り行のないパイプ区切りの行は検出されません。
クリップボードへの書き込み
navigator.clipboard.writeText() は HTTPS または localhost でしか動作しません。HTTP で運用している環境向けに、非推奨ではあるものの document.execCommand('copy') によるフォールバックを用意しています。
図を読み込み中…
- トースト: ボタンの
getBoundingClientRect()で位置を取り、position: fixedの要素をボタン付近に出します。0.15 秒でフェードインし、1.8 秒後にフェードアウトして DOM から削除します。 - 選択モーダル: 初回表示時に一度だけ DOM に追加して再利用します。一覧は毎回
empty()で作り直すため、別のフィールドから何度呼び出しても正しく動作します。
拡張スタイル
/* コピー成否トースト */
.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;
}拡張スクリプト
$(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.nodeType !== 1) return;
if (node.nodeName === 'MARKDOWN-FIELD') addMdTblCopyBtn(node);
// 親要素ごと差し替えられた場合は子孫の markdown-field も探す
node.querySelectorAll('markdown-field').forEach(addMdTblCopyBtn);
});
});
});
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);
});
}