[Tiptap] Markdown Editor 実装メモ

[Tiptap] Markdown Editor 実装メモ

公開
[Tiptap] Markdown Editor 実装メモ

注: 自分の備忘のため Github Copilot (Claude Sonnet 4.5) と作成した内容です。 Github Copilot による記述も含まれます。

はじめに・概要

個人で利用するノートアプリを開発(with AI)した際、アプリ内のマークダウンエディターとして Tiptap をベースに実装した。実装の際のポイントや工夫を備忘として残す。

Tiptapは拡張性の高いWYSIWYGエディターで、Markdownとの相互変換をサポートしている。

基本的なエディター機能はライブラリの組み合わせで実現でき、特有の要件(Wiki-link機能など)については独自実装を追加している。

使用ライブラリ

コアライブラリ

  • @tiptap/react (v3.19.0) - Reactベースのエディター本体
  • @tiptap/starter-kit - 基本的なエディター機能のバンドル(見出し、リスト、テキスト装飾など)
  • tiptap-markdown (v0.9.0) - Markdown形式との相互変換

追加エクステンション

  • @tiptap/extension-image - 画像の埋め込み
  • @tiptap/extension-link - ハイパーリンク
  • @tiptap/extension-placeholder - プレースホルダー表示
  • @tiptap/extension-task-list / task-item - チェックリスト
  • @tiptap/extension-code-block-lowlight - シンタックスハイライト付きコードブロック
  • lowlight (v3.3.0) - コードハイライトエンジン

これらのライブラリを組み合わせることで、一般的なMarkdown記法は標準機能として対応できた。

ライブラリのみで実現できた機能

以下の機能はライブラリの設定のみで実装。

  • 基本的なMarkdown記法 - 見出し、リスト(箇条書き、番号、チェック)、引用、コードブロック
  • インライン装飾 - 太字、斜体、取り消し線、インラインコード
  • リンク - URL自動リンク、手動リンク追加/削除
  • 画像 - base64対応、CSSクラスによるレスポンシブ対応
  • 編集履歴 - Undo/Redo
  • Markdown変換 - エディターコンテンツとMarkdownの双方向変換

独自実装で追加した機能

1. Wiki-link機能

[[ノート名]] 形式のWiki-link機能(他のノートへのリンク)を実装。

カスタムノードの実装

Tiptapのカスタムノードとして実装。

export const WikiLink = Node.create({
  name: "wikiLink",
  group: "inline",
  inline: true,
  atom: true,

  addAttributes() {
    return {
      noteTitle: { default: "" },
      noteId: { default: null },
    };
  },

  addNodeView() {
    return ReactNodeViewRenderer(WikiLinkView);
  },
});

ポイント

  • atom: true でノードを不可分な単位として扱う
  • noteTitlenoteId の両方を保持し、リンク先が存在しない場合にも対応
  • ReactNodeViewRenderer でReactコンポーネントとしてレンダリング

インタラクティブなサジェスト機能

入力補完を実装。

  • [[ の入力を検知して自動的にサジェストポップアップを表示
  • Supabaseから既存ノートをリアルタイム検索(部分一致)
  • 矢印キー(↑↓)での選択、Enterで確定、Escapeでキャンセル
  • 存在しないノートを入力した場合は「新規作成」オプションを表示

実装の工夫

エディター本体の handleKeyDown[[ 入力を検知する際、1文字目の [ を一時保存し、2文字目の [ が来たらサジェストを表示する方式を採用。

handleKeyDown: (_view, event) => {
  if (event.key === "[") {
    const text = wikiInputRef.current;
    if (text === "[") {
      // 2文字目の [ → サジェスト表示
      wikiInputRef.current = "";
      const { from } = editor!.state.selection;
      const coords = editor!.view.coordsAtPos(from);
      setWikiSuggestion({
        query: "",
        position: { top: coords.bottom + 5, left: coords.left },
      });
      return false;
    }
    wikiInputRef.current = "[";
    setTimeout(() => {
      wikiInputRef.current = "";
    }, 500);
  }
};

タイムアウトを使って500ms以内に2つ目の [ が来なければリセットする仕組みにしている。

サジェストが開いている間は、ProseMirrorのデフォルトキーハンドラより優先してサジェスト側でキー操作を処理する。これにより、矢印キーやEnterキーがエディターではなくサジェストメニューに効くようになっている。

2. 画像アップロード機能の強化

ライブラリの画像機能に加えて以下を実装。

ドラッグ&ドロップ対応

handleDrop: (...args: unknown[]) => {
  const handler = handleImageDrop(editor!, noteId);
  return handler(args[0], args[1] as DragEvent, args[2], args[3] as boolean);
};

画像ファイルをエディターにドロップするだけでアップロードと埋め込みが完了する。

ペースト対応

handlePaste: (...args: unknown[]) => {
  const handler = handleImagePaste(editor!, noteId);
  return handler(args[0], args[1] as ClipboardEvent);
};

クリップボードから画像を直接ペーストできる。スクリーンショットなどをそのまま貼り付け可能。

アップロードAPI連携

uploadImage 関数で /api/upload エンドポイントにFormData形式でアップロード。noteId を付与することで、ノートごとに画像を紐付け管理している。

実装の利点

  • ユーザーはMarkdown記法を意識せずに画像を挿入できる
  • アップロード中のエラーハンドリングも含まれる
  • ツールバーからのファイル選択、D&D、ペーストの3つの方法に対応

3. エディター状態管理の工夫

初期コンテンツ設定時のonChange無限ループ回避

Markdownコンテンツをエディターに読み込む際、setContentonUpdate を発火してしまい、それが onChange を呼び出すという循環が発生する問題があった。

これを防ぐために、以下の2つのフラグを使用。

const initialContentSetRef = useRef(false);
const suppressOnChangeRef = useRef(false);

useEffect(() => {
  if (editor && !initialContentSetRef.current) {
    if (content) {
      suppressOnChangeRef.current = true;
      editor.commands.setContent(content);
      suppressOnChangeRef.current = false;
    }
    initialContentSetRef.current = true;
  }
}, [editor, content]);

onUpdate: ({ editor }) => {
  if (onChange && !suppressOnChangeRef.current) {
    const markdown = editor.storage.markdown?.getMarkdown() ?? editor.getHTML();
    onChange(markdown);
  }
};
  • initialContentSetRef - 初回のコンテンツ設定が完了したかを記録
  • suppressOnChangeRef - プログラム的なコンテンツ変更時にonChangeを抑制

Wiki-linkサジェストの状態管理

サジェストの表示状態を複数のRefで管理。

  • wikiSuggestion (state) - サジェストUI表示用
  • wikiSuggestionRef - キーボードイベントハンドラ内で同期的に参照するため
  • wikiInputRef - [[ 検知のための一時バッファ

Reactのstateだけだと、イベントハンドラ内で最新値が取れないケースがあるため、Refと併用している。

ツールバーの実装

lucide-reactのアイコンとshadcn/uiのTooltipを組み合わせたツールバーを実装。

  • sticky配置で常に上部に固定
  • アクティブな装飾/ブロックタイプをハイライト表示
  • 各ボタンにツールチップで操作説明を付与
  • セパレーターで機能をグルーピング

UX上の工夫

  • editor.can().undo() などで、実行不可能な操作はボタンを無効化
  • editor.isActive() で現在の選択範囲の装飾状態を反映し、視覚的フィードバックを提供

まとめ

Tiptapのエクステンションにより、基本的なMarkdown機能はライブラリの組み合わせで実現できた。一方で、特有の機能(Wiki-link、画像の柔軟なアップロード、一部の状態管理)については独自実装を追加することで実現している。

Wiki-link機能は、カスタムノードの実装、キーボードイベントの制御、非同期検索との連携など、いくつかの技術要素を組み合わせた実装になっており、Tiptapの拡張性の高さを活かせた部分だと感じる。

今後は画像のリサイズ機能や、より高度なMarkdown記法(表、数式など)のサポートなど対応していきたい。