🧮

react-markdownでテーブルの結合セルを実現したい

に公開

はじめに

TypeScript + react-markdown + Tailwind CSSでMarkdownパーサーを作っていたら、「テーブルの縦結合セルを追加してほしい」と急な依頼が。

Markdownの標準仕様ではテーブルの結合セルはサポートされていないので、これは結構な難題(技術選定の段階でMDXが選ばれるはず)。でも締切は迫っているし、なんとかして対応しなければ...

今回はどのようなアイディアで乗り切ったかと、残る課題を紹介します。

基本的なアイデア

そこで考えたのが、特殊な記法を追加して、react-markdownのカスタムレンダラーでHTMLのrowSpan属性に変換する方法です。Markdown標準の記法とは外れてしまいますが、少ない記法変更で、かつパーサーをシンプルな実装で描けるよう工夫しました。

HTMLのrowSpan属性について

HTMLのテーブルのセル(<td><th>)に使う属性で、そのセルが縦方向に何行分をまたいで表示されるかを指定するものです。

<table border="1">
  <tr>
    <td rowSpan="2">縦に2行結合されたセル</td>
    <td>セル1</td>
  </tr>
  <tr>
    <td>セル2</td>
  </tr>
</table>

具体的には以下のような記法を考えました:

  • $数字$内容 : その数字分の行をまたぐセル
  • $hidden$ : 結合によって隠されるセル

実装コード

この実装を行うのに、以下のMarkdown関連のパッケージが必要です。

pnpm add react-markdown remark-gfm

Tailwind CSS関連のパッケージ

pnpm add -D tailwindcss postcss autoprefixer @tailwindcss/typography

似たような要件を抱えてる人は、例えば、こんなカスタムレンダラーを実装してるかもしれません。

import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

const markdown = `
# カテゴリ別データ

| 項目 | 値1 | 値2 |
|------|-----|-----|
| カテゴリA | データ1 | データ2 |
| カテゴリA | データ3 | データ4 |
| カテゴリB | データ5 | データ6 |

`;

import React from "react";
import ReactMarkdown from "react-markdown";

interface MarkdownStyledWithGFMProps {
  children: string;
}

function MarkdownStyled({ children }: MarkdownStyledWithGFMProps) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm]}
      components={{
        h1: () => <h1 className="text-2xl font-semibold text-blue-600 mb-4" />,
        table: () => (
          <table className="w-full border border-gray-200 bg-white mb-4" />
        ),
        th: () => (
          <th className="border border-gray-200 bg-blue-100 text-left px-3 py-2" />
        ),
        td: () => <td className="border border-gray-200 px-3 py-2" />,
      }}
    >
      {children}
    </ReactMarkdown>
  );
}

// 呼び出し元
// <MarkdownStyled>{markdown}</MarkdownStyled>

カスタムレンダラーをこう変更した

カスタムレンダラーのtd部分で、以下のように変更しました

  td: ({ children }) => {
    const baseClass = "border px-2 py-1 text-sm";
    if (typeof children !== "string") {
      return <td className={baseClass}>{children}</td>;
    }

    if (children === "$hidden$") {
      return null;
    }

    const match = children.match(/^\$(\d+)\$(.*)/);

    if (match) {
      const [, rowSpan, content] = match;
      return (
        <td className={baseClass} rowSpan={Number(rowSpan)}>
          {content}
        </td>
      );
    }

    return <td className={baseClass}>{children}</td>;
  },

使い方(カスタム記法)

Markdownでこんな風に書きます:

| 項目 | 値1 | 値2 |
|------|-----|-----|
| $2$カテゴリA | データ1 | データ2 |
| $hidden$ | データ3 | データ4 |
| カテゴリB | データ5 | データ6 |

これで「カテゴリA」のセルが2行分結合されて表示されます。

コードの解説

パターンマッチング

const match = typeof children === "string" && children.match(/^\$(\d+)\$(.*)/);

セルの内容が文字列で、かつ $数字$ から始まるパターンにマッチするかチェックしています。
正規表現のところが保守しずらい...💦

隠しセル処理

const isHiddenCell = typeof children === "string" && children === "$hidden$";
if (isHiddenCell) {
  return null;
}

$hidden$の場合はnullを返して、そのセル自体をレンダリングしません。

rowSpan適用

if (match) {
  const rowSpanValue = Number(match[1]);
  const content = children.slice(3 + match[1].length);
  return (
    <td rowSpan={rowSpanValue}>
      {content}
    </td>
  );
}

マッチした場合は、数字部分をrowSpan値として抽出し、残りの部分をセルの内容として使用します。

注意点とトレードオフ

良い点

  • 既存のMarkdownパーサーをそのまま使用可能
  • 比較的シンプルな実装
  • 緊急対応としては少し実用的

悪い点

  • Markdown標準ではないので他のパーサーでは使用不可
  • colSpan(列結合)に対応不可
  • 結合セル内での<br /><img>に対応不可
結合セル内でのタグに対応できない理由

td のカスタムレンダラーでは、children の型は以下のように定義されています。

children: React.ReactNode;

ReactNode は文字列(string)だけでなく、ReactElement や配列なども含むため、必ずしも単一の文字列とは限りません。

結合セルの判定で使っている以下のコードは、

if (typeof children === "string") {
  const match = children.match(/^\$(\d+)\$(.*)/);
  // ...
}

children が文字列の場合のみ正規表現でマッチさせています。

しかし、セル内に <br /> や画像タグ(<img />)が入ると、children

ReactElement | ReactNodeArray

という型となり、typeof children === "string"false になります。

こうなると、正規表現を適用できず、縦結合の判定ができません。

これを対応しようとすると、children の型をチェックして、

  • 配列なら要素を走査する
  • ReactElementなら props.children を再帰的に探す

などの処理が必要となり、実装がかなり複雑になるという訳です。
だから、結合セル内では<br/>が使えなくなる、という制約について合意していただく必要がありました。

まとめ

正直なところ、これは完璧とは程遠い解決策です。ですが、緊急事態においては「動くものを素早く作る」ことが重要で、そういう意味ではこの方法は十分に役立ちました。

もし時間に余裕があるなら、MDXを使ったりカスタムMarkdown拡張を作ったりするのがいいと思います。今回のような「とりあえずこのまま動かさなきゃいけない」シーンでは使えるテクニックだと思います。

同じような状況に陥った方の参考になれば幸いです。

Discussion