1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdown textarea + プレビュー + 差分ハイライト — 業務 SaaS で「書きたくなる」入力体験を作る

1
Posted at

この記事は約 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-gfmGFM (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 — 公式プロダクトページ

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?