この記事は約 5 分で読めます。
筆者プロフィール: ソフトウェアエンジニア。「知った気にならない。いつまでも学び続ける」を信条に、業務と個人開発の両輪で技術を磨いています。AI 駆動開発で複数の個人開発アプリを構築・運用中。
👉 ポートフォリオ: 筆者ホームページ
業務 SaaS で長文を書く textarea を Markdown + プレビュー + 差分ハイライト で実装した話を整理します。運用中の SaaS 「たすきば Knowledge Relay」 で実装している MarkdownTextarea コンポーネントベース。
サービスの機能紹介・画面イメージ・コンセプトは公式プロダクトページをご覧ください。
👉 たすきば Knowledge Relay — 公式プロダクトページ
なぜ Markdown を採用したか
業務 SaaS でテキストを書くとき:
| 選択肢 | 評価 |
|---|---|
| 単純な textarea | 貧弱 (改行 / 強調 / リンクが書けない) |
| リッチエディタ | UI 複雑 + bundle サイズ大 |
| Markdown | 書きやすさと表現力のバランス良 |
さらに、
- 編集中にプレビュータブで確認
- 既存値からの diff をハイライト
の 2 機能を追加して、編集体験を上げて います。
1. 適用箇所
| エンティティ | フィールド |
|---|---|
| ナレッジ | content |
| リスク / 課題 | description, resolution_note |
| 振り返り | keep / problem / try |
| メモ | content |
| プロジェクト | purpose, background, scope |
ユーザが「多めに文章を書く」場所すべてに適用。
2. 実装の核
'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function MarkdownTextarea({
value,
onChange,
previousValue,
}: {
value: string;
onChange: (v: string) => void;
previousValue?: string;
}) {
const [mode, setMode] = useState<'edit' | 'preview'>('edit');
return (
<div>
<div className="flex gap-2">
<button onClick={() => setMode('edit')}>編集</button>
<button onClick={() => setMode('preview')}>プレビュー</button>
</div>
{mode === 'edit' ? (
<textarea value={value} onChange={(e) => onChange(e.target.value)} />
) : (
<div className="prose">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{previousValue ? renderDiff(previousValue, value) : value}
</ReactMarkdown>
</div>
)}
</div>
);
}
react-markdown + remark-gfm で GFM (GitHub Flavored Markdown) 対応。
3. 差分ハイライトの実装
import { diff_match_patch } from 'diff-match-patch';
function renderDiff(before: string, after: string): string {
const dmp = new diff_match_patch();
const diffs = dmp.diff_main(before, after);
dmp.diff_cleanupSemantic(diffs);
return diffs.map(([op, text]) => {
if (op === 1) return `<mark class="added">${text}</mark>`;
if (op === -1) return ''; // 削除はプレビューに出さない
return text;
}).join('');
}
| 操作 | 表示 |
|---|---|
| 追加 (op=1) |
<mark> でハイライト |
| 削除 (op=-1) | プレビューに出さない (本文として読みづらくなる) |
| 不変 (op=0) | そのまま表示 |
4. 差分ハイライトは 20% 透過では見えない罠
最初の実装で、差分ハイライトを 黄色の 20% 透過 で表現。
mark.added { background-color: rgba(255, 235, 59, 0.2); }
ダーク背景で見えない。改善:
mark.added {
background-color: rgba(255, 235, 59, 0.3);
text-decoration: underline;
}
[data-theme="dark"] mark.added {
background-color: rgba(61, 198, 133, 0.3);
}
テーマごとに視認性を担保。
5. prose クラスの罠
Tailwind の prose クラスは、@tailwindcss/typography プラグインを必要とします。
pnpm install -D @tailwindcss/typography
// tailwind.config.ts
import typography from '@tailwindcss/typography';
export default { plugins: [typography] };
これを忘れると、prose がただの未定義クラスとして無視され、Markdown プレビューが「生 HTML がそのまま」表示 されます。「動くけど見た目が崩れている」状態は、E2E でも検知しにくい。
6. セキュリティ — XSS 対策
ユーザ入力の Markdown を表示するため、XSS 対策が必須。
<ReactMarkdown
remarkPlugins={[remarkGfm]}
allowedElements={[
'p', 'strong', 'em', 'code', 'pre',
'ul', 'ol', 'li', 'a', 'h1', 'h2', 'h3', 'blockquote'
]}
unwrapDisallowed
>
{markdown}
</ReactMarkdown>
| 防御 | 内容 |
|---|---|
| HTML タグ無効化 | react-markdown はデフォルトで Markdown のみ解釈 |
| script / on* | 無視 |
allowedElements |
明示的に許可した要素以外を弾く |
7. リンクの target='_blank'
外部リンクは新しいタブで開きたい。ただし、target="_blank" には reverse tabnabbing リスク。
<ReactMarkdown
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{markdown}
</ReactMarkdown>
rel="noopener noreferrer" を必ず付けます。
8. コードブロックのシンタックスハイライト
技術系ナレッジでは、コードを記載することが多い。
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { atomDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
<ReactMarkdown
components={{
code: ({ inline, className, children }) => {
const match = /language-(\w+)/.exec(className || '');
if (!inline && match) {
return (
<SyntaxHighlighter language={match[1]} style={atomDark}>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
);
}
return <code className={className}>{children}</code>;
},
}}
>
{markdown}
</ReactMarkdown>
```typescript のような言語指定があると、自動的にハイライト。
おわりに
| 機能 | 効果 |
|---|---|
| Markdown 入力 + プレビュー | リッチエディタなしで表現力確保 |
| 差分ハイライト | 編集箇所が一目で分かる |
| prose クラス + typography プラグイン | 美しい render |
| XSS 対策 (react-markdown + allowedElements) | セキュリティ |
| シンタックスハイライト | 技術系ナレッジに有用 |
| 共通コンポーネント化 | 横展開が容易 |
「書きたくなる textarea」を実現するための、地味だが効く実装 です。
本記事のコンポーネントは、運用中の SaaS 「たすきば Knowledge Relay」 で実装しています。
👉 たすきば Knowledge Relay — 公式プロダクトページ