Reactで、表の入力欄からMarkdownの表を作る方法

​​React​Clipboard API,GFM,Markdown,Playwright,TypeScript

見出しとセルを入力欄に入れると、その場で Markdown の表の文字列ができるようにします。React では、列と行を state に持ち、レンダーのたびに文字列を組み立てるだけで作れます。気をつけるのは、セルの中に | が入ったときです。そのまま書くと、表の区切りとみなされてセルが分かれてしまいます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Playwright で入力欄を操作して確かめました。できた Markdown は、remark-gfm 4.0.1、markdown-it 15.0.3、marked 18.1.3、cmark-gfm(Python の cmarkgfm 2025.10.22)の4つで HTML にして比べています。Firefox、Safari、スマートフォンのブラウザ、日本語入力(IME)での入力は試していません。

見出しが「商品」「値段」、1行目が「りんご」「120」の表の入力欄と、その下の Markdown の欄。「行を追加」を押すと Markdown に空の行が増え、2行目に「猫|犬の本」と入力すると Markdown では「猫\|犬の本」になる。2列目の寄せ方を「右」にすると区切りの行が「---:」に変わり、「コピー」を押すと「コピーしました」と出る。変わった行や「コピーしました」がその瞬間だけピンクになり、下の帯に何回目の操作かが出ている
行の追加、| を含む値の入力、寄せ方の変更、コピーの4つの操作。Markdown の変わった行が、その瞬間だけピンクになる

Markdown の表の決まり

GitHub などで使われる Markdown の表は、GitHub Flavored Markdown(GFM)の仕様にある表の拡張で決まっています。作る側が守ることは、次の3つです。

  • 1行目が見出しの行、2行目が区切りの行で、そのあとに本文の行が0行以上続く。
  • 区切りの行は --- のようにハイフンだけで書き、左端に : を付けると左寄せ、右端に付けると右寄せ、両方に付けると中央寄せになる。
  • 見出しの行と区切りの行は、セルの数が同じでなければならない。違うと表にならない。

セルの前後の空白は取り除かれ、列の幅を揃える必要もありません。仕様の例にも、| abc | defghi | の下に :-: | -----------: と、長さの揃っていない行が並んでいます。そこで、この記事では幅を揃えず、| りんご | 120 | のように1つの空白で区切って書きます。

見出しの行と区切りの行でセルの数が違う場合は、検証環境の4つの実装のどれでも表にならず、1つの段落として出ました。

セルの中の | の前に \ を足す

セルの値に | が入っていると、その位置でセルが分かれます。仕様には、\| と書けばセルの中に | を入れられ、`\|` のようにコードの中でも同じだと書かれています。

ただし、| を \| に置き換えるだけでは足りない場合がありました。入力した値がもともと a\|b のように \ と | の並びを含んでいると、置き換えたあとは a\\|b になります。4つの実装で HTML にしたところ、結果が分かれました([ ] は1つのセル、次のセルの値は「次」)。

text
入力:a\|b
  | を \| にしただけ:| a\\|b | 次 |
    remark-gfm  [a\][b]
    markdown-it [a|b][次]
    marked      [a\][b]
    cmark-gfm   [a|b][次]

remark-gfm と marked は \\ を \ という1文字として表示し、そのあとの | でセルを分けました。markdown-it と cmark-gfm はセルを分けず、表示は a|b でした。どちらでも、入力した a\|b のとおりには表示されていません。

そこで、| の直前にある \ の数を2倍にしてから、| の前に \ を足します。a\|b は a\\\|b になり、4つの実装とも [a\|b][次] と、入力したとおりに表示されました。

ts
export type Align = 'none' | 'left' | 'center' | 'right';
export type Column = { id: number; title: string; align: Align };
// セルは列の id をキーにして持つ(列を消しても、ほかの列の値がずれない)
export type Row = { id: number; cells: Record<number, string> };

const DELIMITER: Record<Align, string> = {
  none: '---',
  left: ':---',
  center: ':---:',
  right: '---:',
};

// | の前に \ を足す。直前に \ が続いていたら、その \ も2つずつにする
export function escapeCell(text: string): string {
  return text.trim().replace(/(\\*)\|/g, (_, bs: string) => `${bs}${bs}\\|`);
}

export function toMarkdown(columns: Column[], rows: Row[]): string {
  const line = (cells: string[]) => `| ${cells.join(' | ')} |`;
  return [
    line(columns.map((c) => escapeCell(c.title))),
    line(columns.map((c) => DELIMITER[c.align])),
    ...rows.map((r) => line(columns.map((c) => escapeCell(r.cells[c.id] ?? '')))),
  ].join('\n');
}

前後の空白を取ることと、| の前に \ を足すこと以外は書き換えないので、**太字** のような Markdown の書き方は、そのまま太字として表示されました。`x|y` のようにコードの中にある | も、`x\|y` になって、4つの実装とも HTML は <code>x|y</code> になりました。

1つだけ、入力したとおりに表示されない値がありました。コードの中にもともと \| を書いた `x\|y` は、`x\\\|y` になり、4つの実装とも HTML は <code>x\\|y</code> になり、\ が2つ表示されます。GFM の仕様の、バックスラッシュエスケープの節にあるとおり、コードの中ではバックスラッシュによるエスケープが働きません。表のために足した \| だけが | に戻り、2つの \ はそのまま残ります。

列と行は、id を付けて state に持つ

コンポーネントでは、列の配列 columns と、行の配列 rows を state に持ちます。列は見出しと寄せ方、行はセルの値を持ちます。Markdown の文字列は state にせず、レンダーのたびに toMarkdown() で作ります。

tsx
import { useId, useRef, useState } from 'react';
import { toMarkdown, type Align, type Column, type Row } from './markdownTable';

const ALIGN_LABELS: Record<Align, string> = { none: '指定なし', left: '左', center: '中央', right: '右' };
const ALIGNS = Object.keys(ALIGN_LABELS) as Align[];

export function TableEditor() {
  const nextId = useRef(3);
  const [columns, setColumns] = useState<Column[]>([
    { id: 0, title: '', align: 'none' },
    { id: 1, title: '', align: 'none' },
  ]);
  const [rows, setRows] = useState<Row[]>([{ id: 2, cells: {} }]);
  const [copiedText, setCopiedText] = useState<string | null>(null);
  const outputId = useId();

  const markdown = toMarkdown(columns, rows);

  function updateColumn(id: number, patch: Partial<Column>) {
    setColumns(columns.map((c) => (c.id === id ? { ...c, ...patch } : c)));
  }
  function updateCell(rowId: number, colId: number, value: string) {
    setRows(rows.map((r) => (r.id === rowId ? { ...r, cells: { ...r.cells, [colId]: value } } : r)));
  }
  function addColumn() {
    setColumns([...columns, { id: nextId.current++, title: '', align: 'none' }]);
  }
  function removeColumn(id: number) {
    setColumns(columns.filter((c) => c.id !== id));
  }
  function addRow() {
    setRows([...rows, { id: nextId.current++, cells: {} }]);
  }
  function removeRow(id: number) {
    setRows(rows.filter((r) => r.id !== id));
  }
  async function copy() {
    await navigator.clipboard.writeText(markdown);
    setCopiedText(markdown);
  }

  return (
    <div className="table-editor">
      <table>
        <thead>
          <tr>
            {columns.map((c, i) => (
              <th key={c.id}>
                <input
                  aria-label={`${i + 1}列目の見出し`}
                  placeholder="見出し"
                  value={c.title}
                  onChange={(e) => updateColumn(c.id, { title: e.target.value })}
                />
                <select
                  aria-label={`${i + 1}列目の寄せ方`}
                  value={c.align}
                  onChange={(e) => updateColumn(c.id, { align: e.target.value as Align })}
                >
                  {ALIGNS.map((a) => (
                    <option key={a} value={a}>{ALIGN_LABELS[a]}</option>
                  ))}
                </select>
                <button
                  type="button"
                  aria-label={`${i + 1}列目を削除`}
                  disabled={columns.length === 1}
                  onClick={() => removeColumn(c.id)}
                >
                  ×
                </button>
              </th>
            ))}
            <th />
          </tr>
        </thead>
        <tbody>
          {rows.map((r, ri) => (
            <tr key={r.id}>
              {columns.map((c, ci) => (
                <td key={c.id}>
                  <input
                    aria-label={`${ri + 1}行目・${ci + 1}列目`}
                    value={r.cells[c.id] ?? ''}
                    onChange={(e) => updateCell(r.id, c.id, e.target.value)}
                  />
                </td>
              ))}
              <td>
                <button type="button" aria-label={`${ri + 1}行目を削除`} onClick={() => removeRow(r.id)}>
                  ×
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <p className="actions">
        <button type="button" onClick={addRow}>行を追加</button>
        <button type="button" onClick={addColumn}>列を追加</button>
      </p>
      <label htmlFor={outputId}>Markdown</label>
      <textarea id={outputId} readOnly rows={rows.length + 2} value={markdown} />
      <p className="actions">
        <button type="button" onClick={copy}>コピー</button>
        <span role="status">{copiedText === markdown ? 'コピーしました' : ''}</span>
      </p>
    </div>
  );
}

行と列には、追加するときに nextId から番号を振ります。行のセルは、列の位置(何列目か)ではなく列の id をキーにした Record に入れます。こうしておくと、列を消しても、行のデータを書き換えずに済みます。消した列の値は行の中に残りますが、toMarkdown() は columns にある列しか読まないので、表には出ません。

state の配列は直接書き換えず、map() で作った新しい配列や、filter() で要素を除いた新しい配列、スプレッド構文([...rows, 新しい行])で末尾に足した新しい配列を渡します。React の公式ドキュメントにある、state の配列を書き換えずに更新する方法と同じ書き方です。

見出しの行と区切りの行のセルの数は、どちらも columns から作るので、いつも同じになります。列が0になると表を作れないので、列が1つのときは列の「×」のボタンを押せないようにしました。行の「×」のボタンはいつでも押せるので、行は0行まで消せます。見出しと区切りの行だけの Markdown も、4つの実装とも、見出しだけの表として HTML になりました。

Chromium で操作したときの Markdown

Playwright で入力欄を操作し、そのつど Markdown の欄の値を読みました。見出しに「名前」「値段」、1行目に「りんご」「120」を入れ、行と列を1つずつ足して値を入れると、次のようになりました。

text
| 名前 | 値段 | メモ |
| --- | --- | --- |
| りんご | 120 | 赤\|青 |
| みかん | 80 |  |

まだ何も入れていないセルは、| | と空のセルになります。2列目の寄せ方を「右」、3列目を「中央」にすると、区切りの行が | --- | ---: | :---: | に変わりました。

続けて1行目の「×」のボタンを押すと、Markdown から「りんご」の行が消え、入力欄も「みかん」「80」の行だけになりました。2列目の「×」のボタンを押したあとは、見出しの入力欄が「名前」「メモ」になり、Markdown も2列になっています。そのあと「列を追加」を押すと、新しい列は空のままでした。新しい列には新しい id が振られるので、消した列の値が出てくることはありません。

コピーのボタンと、改行の入った値

コピーには navigator.clipboard.writeText() を使います。MDN によると、クリップボードに書き込めるのは安全なコンテキスト(HTTPS で配信されたページや、localhost のページなど)だけです。検証環境では、localhost で開いたページに Playwright でクリップボードの読み書きを許可し、「コピー」を押しました。すると、Markdown の欄と同じ文字列がクリップボードに入り、「コピーしました」と出ました。

「コピーしました」は、コピーした文字列を copiedText に入れておき、今の markdown と同じときだけ出しています。コピーのあとでセルの値を変えると、表示は消えました。表示を消すためのタイマーや Effect は使っていません。

セルは <input type="text"> なので、値に改行は入りません。HTML の仕様では、テキストの入力欄の値から改行を取り除くと決められています。検証環境の Chromium で試したところ、value に 1行目\n2行目 を入れると 1行目2行目 になり、改行を含む文字をクリップボードから貼り付けると 1行目 2行目 と空白に変わりました。

Markdown の表は、テキストの1行が表の1行になります。セルの値に改行が入った Markdown を4つの実装で HTML にすると、どれも改行の位置で表の行が分かれ、[1行目][] と [2行目][次] の2行になりました。セルが <input> なら、値に改行が入る心配はありません。

​Posted by 86