[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でノードを不可分な単位として扱うnoteTitleとnoteIdの両方を保持し、リンク先が存在しない場合にも対応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コンテンツをエディターに読み込む際、setContent が onUpdate を発火してしまい、それが 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記法(表、数式など)のサポートなど対応していきたい。