Tauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
近年、Electron に代わる軽量なデスクトップアプリケーションフレームワークとして Tauri が注目を集めています。本記事では、Tauri を使って実用的な Markdown エディタを開発する方法を解説します。リアルタイムでプレビューが更新されるライブプレビュー機能や、シンタックスハイライト・数式表示などの拡張プラグインに対応した、本格的なエディタの実装をステップバイステップで学んでいきましょう。
背景
Tauri とは何か
Tauri は Rust で構築されたデスクトップアプリケーションフレームワークで、Web 技術(HTML、CSS、JavaScript)を使ってネイティブアプリケーションを開発できます。Electron と比較して以下のような特徴があります。
| # | 項目 | Tauri | Electron |
|---|---|---|---|
| 1 | バイナリサイズ | 約 3-5MB | 約 80-150MB |
| 2 | メモリ使用量 | 軽量 | 重量 |
| 3 | セキュリティ | 高セキュリティ(Rust ベース) | 標準的 |
| 4 | ランタイム | OS 標準の WebView | Chromium 内蔵 |
| 5 | パフォーマンス | 高速 | 標準的 |
Markdown エディタに必要な機能
実用的な Markdown エディタには以下の機能が求められます。
以下の図は、Markdown エディタが持つべき主要機能の関係性を示しています。
mermaidflowchart TB
editor["エディタコア"]
preview["ライブプレビュー"]
plugins["拡張プラグイン"]
editor -->|入力テキスト| preview
preview -->|リアルタイム変換| display["HTML 表示"]
plugins -->|シンタックス<br/>ハイライト| preview
plugins -->|数式レンダリング| preview
plugins -->|目次生成| preview
editor -->|ファイル保存| fs["ファイルシステム"]
fs -->|ファイル読込| editor
図からわかるように、エディタコア・プレビュー・プラグインの 3 つのコンポーネントが連携して動作します。
主要機能は以下の通りです。
- リアルタイム編集: テキストエディタでの Markdown 入力
- ライブプレビュー: 入力内容を即座に HTML として表示
- シンタックスハイライト: コードブロックの色分け表示
- 数式表示: LaTeX 形式の数式レンダリング
- ファイル操作: ファイルの読み込み・保存
課題
Electron の問題点
従来の Electron ベースのエディタでは、以下の課題がありました。
- バイナリサイズが大きい: Chromium を内包するため、配布ファイルが 100MB を超えることも
- メモリ消費が多い: 複数のエディタを同時に開くとメモリ不足になりやすい
- 起動速度が遅い: アプリケーションの起動に時間がかかる
ライブプレビュー実装の難しさ
Markdown のライブプレビューを実装する際には、以下の技術的な課題があります。
以下の図は、ライブプレビュー実装における技術的な課題とその解決アプローチを示しています。
mermaidflowchart LR
input["テキスト入力"]
parse["Markdown<br/>パース処理"]
render["HTML<br/>レンダリング"]
input -->|頻繁な更新| parse
parse -->|変換処理| render
render -->|再描画| ui["UI 更新"]
debounce["デバウンス<br/>処理"]
cache["キャッシュ<br/>最適化"]
input -.->|最適化| debounce
debounce -.->|制御された更新| parse
parse -.->|高速化| cache
この図が示す通り、入力から UI 更新までのパイプラインにデバウンスやキャッシュといった最適化が必要です。
- パフォーマンス: テキスト入力のたびに変換処理が走るため、重い
- スクロール同期: エディタとプレビューのスクロール位置を同期させる必要がある
- プラグイン統合: シンタックスハイライトや数式表示などの拡張機能の統合
解決策
Tauri による軽量化
Tauri を採用することで、以下のメリットが得られます。
- 軽量なバイナリ: OS 標準の WebView を使用するため、配布サイズが劇的に削減
- 低メモリ消費: Chromium を内包しないため、メモリ使用量が少ない
- 高速起動: アプリケーションの起動時間が短縮
- セキュアな設計: Rust のメモリセーフティとタイプセーフティの恩恵
ライブプレビューの実装戦略
効率的なライブプレビューを実現するために、以下の技術を組み合わせます。
| # | 技術 | 用途 | メリット |
|---|---|---|---|
| 1 | marked.js | Markdown パーサー | 高速な変換処理 |
| 2 | highlight.js | シンタックスハイライト | 豊富な言語サポート |
| 3 | KaTeX | 数式レンダリング | 軽量で高速 |
| 4 | デバウンス | 入力制御 | パフォーマンス向上 |
アーキテクチャ設計
Tauri の特徴を活かしたアーキテクチャを採用します。
- フロントエンド: React + TypeScript でエディタ UI を構築
- バックエンド: Rust で高速なファイル I/O 処理を実装
- IPC 通信: Tauri の Command システムでフロント・バック間の通信を実現
具体例
プロジェクトのセットアップ
まずは Tauri プロジェクトを作成します。ここでは create-tauri-app を使って、React + TypeScript のテンプレートから始めます。
bash# Tauri プロジェクトの作成
yarn create tauri-app markdown-editor
# プロジェクトディレクトリに移動
cd markdown-editor
# 依存関係のインストール
yarn install
次に、Markdown 処理に必要なライブラリをインストールします。
bash# Markdown パーサーとプラグインのインストール
yarn add marked highlight.js katex
yarn add @types/marked -D
# React 用の型定義
yarn add -D @types/react @types/react-dom
プロジェクト構成
作成するプロジェクトの構成は以下の通りです。
plaintextmarkdown-editor/
├── src/ # React フロントエンド
│ ├── components/ # UI コンポーネント
│ │ ├── Editor.tsx # Markdown エディタ
│ │ ├── Preview.tsx # プレビュー表示
│ │ └── Toolbar.tsx # ツールバー
│ ├── hooks/ # カスタムフック
│ │ └── useMarkdown.ts # Markdown 処理
│ └── App.tsx # メインアプリ
└── src-tauri/ # Rust バックエンド
└── src/
└── main.rs # ファイル I/O コマンド
Markdown パーサーの設定
Markdown の変換処理を行うための設定を実装します。marked.js を使って、拡張機能を含めた設定を行います。
marked.js の初期化
typescript// src/utils/markdown.ts
import { marked } from 'marked';
import hljs from 'highlight.js';
import katex from 'katex';
上記のコードでは、必要なライブラリをインポートしています。marked は Markdown パーサー、hljs はシンタックスハイライト、katex は数式レンダリングに使用します。
カスタムレンダラーの実装
typescript// src/utils/markdown.ts(続き)
// シンタックスハイライトの設定
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang })
.value;
} catch (err) {
console.error('Highlight error:', err);
}
}
return hljs.highlightAuto(code).value;
},
langPrefix: 'hljs language-',
breaks: true,
gfm: true,
});
この設定により、コードブロックに対して自動的にシンタックスハイライトが適用されます。gfm: true により GitHub Flavored Markdown にも対応しています。
数式レンダリングの拡張
typescript// src/utils/markdown.ts(続き)
// 数式レンダリングのカスタムエクステンション
const mathExtension = {
name: 'math',
level: 'inline',
start(src: string) {
return src.indexOf('$');
},
tokenizer(src: string) {
// インライン数式: $...$
const inlineMatch = src.match(/^\$(.+?)\$/);
if (inlineMatch) {
return {
type: 'math',
raw: inlineMatch[0],
text: inlineMatch[1],
displayMode: false,
};
}
// ブロック数式: $$...$$
const blockMatch = src.match(/^\$\$(.+?)\$\$/s);
if (blockMatch) {
return {
type: 'math',
raw: blockMatch[0],
text: blockMatch[1],
displayMode: true,
};
}
},
renderer(token: any) {
try {
return katex.renderToString(token.text, {
displayMode: token.displayMode,
throwOnError: false,
});
} catch (err) {
return `<span class="math-error">${token.text}</span>`;
}
},
};
marked.use({ extensions: [mathExtension] });
このカスタムエクステンションにより、$数式$ や $$数式$$ の記法で LaTeX 形式の数式を記述できるようになります。
Markdown 変換関数
typescript// src/utils/markdown.ts(続き)
/**
* Markdown テキストを HTML に変換
* @param markdown - 変換する Markdown テキスト
* @returns 変換された HTML 文字列
*/
export function convertMarkdown(markdown: string): string {
try {
return marked.parse(markdown) as string;
} catch (error) {
console.error('Markdown parse error:', error);
return '<p>Markdown の変換中にエラーが発生しました。</p>';
}
}
この関数は Markdown テキストを受け取り、HTML に変換して返します。エラーハンドリングも含まれているため、安全に使用できます。
カスタムフックの実装
Markdown の変換処理を React のカスタムフックとして実装します。これにより、コンポーネント間で簡単に再利用できるようになります。
useMarkdown フックの基本構造
typescript// src/hooks/useMarkdown.ts
import { useState, useCallback, useEffect } from 'react';
import { convertMarkdown } from '../utils/markdown';
必要な React フックと先ほど作成した変換関数をインポートします。
デバウンス処理の実装
typescript// src/hooks/useMarkdown.ts(続き)
/**
* Markdown 処理を行うカスタムフック
* @param initialMarkdown - 初期 Markdown テキスト
* @param debounceMs - デバウンス時間(ミリ秒)
*/
export function useMarkdown(
initialMarkdown: string = '',
debounceMs: number = 300
) {
const [markdown, setMarkdown] = useState(initialMarkdown);
const [html, setHtml] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
// デバウンス付き変換処理
useEffect(() => {
setIsProcessing(true);
const timer = setTimeout(() => {
const converted = convertMarkdown(markdown);
setHtml(converted);
setIsProcessing(false);
}, debounceMs);
return () => clearTimeout(timer);
}, [markdown, debounceMs]);
デバウンス処理により、ユーザーが入力を続けている間は変換処理を遅延させ、入力が止まってから実際の変換を行います。これによりパフォーマンスが大幅に向上します。
フックの戻り値
typescript// src/hooks/useMarkdown.ts(続き)
// Markdown テキストを更新
const updateMarkdown = useCallback((newMarkdown: string) => {
setMarkdown(newMarkdown);
}, []);
return {
markdown, // 現在の Markdown テキスト
html, // 変換された HTML
updateMarkdown, // Markdown 更新関数
isProcessing, // 変換処理中フラグ
};
}
このフックを使用することで、コンポーネント側では変換処理の詳細を意識せずに Markdown エディタを実装できます。
エディタコンポーネントの実装
テキストエディタ部分のコンポーネントを作成します。シンプルながら実用的な機能を備えたエディタを実装していきます。
エディタの型定義
typescript// src/components/Editor.tsx
import React, { useRef, useEffect } from 'react';
interface EditorProps {
value: string; // エディタの現在値
onChange: (value: string) => void; // 値変更時のコールバック
placeholder?: string; // プレースホルダーテキスト
}
エディタコンポーネントのプロパティを型定義します。シンプルな制御可能コンポーネントとして設計しています。
エディタコンポーネント本体
typescript// src/components/Editor.tsx(続き)
export const Editor: React.FC<EditorProps> = ({
value,
onChange,
placeholder = 'Markdown を入力してください...',
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// テキストエリアの変更ハンドラ
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
// タブキーでインデント挿入
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault();
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
value.substring(0, start) + ' ' + value.substring(end);
onChange(newValue);
// カーソル位置を調整
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
};
タブキーの処理を実装することで、コードブロック内でのインデント入力が快適になります。デフォルトのタブ動作をキャンセルし、2 スペースを挿入しています。
エディタの JSX
typescript// src/components/Editor.tsx(続き)
return (
<div className="editor-container">
<textarea
ref={textareaRef}
className="editor-textarea"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
/>
</div>
);
};
シンプルな textarea ベースのエディタですが、必要な機能は十分に備えています。スペルチェックと自動補完をオフにすることで、コード入力時のストレスを軽減しています。
プレビューコンポーネントの実装
変換された HTML を表示するプレビューコンポーネントを作成します。セキュアな HTML レンダリングとスクロール同期機能を実装します。
プレビューの型定義とスタイル
typescript// src/components/Preview.tsx
import React, { useRef, useEffect } from 'react';
import 'highlight.js/styles/github-dark.css';
import 'katex/dist/katex.min.css';
interface PreviewProps {
html: string; // 表示する HTML
scrollSync?: boolean; // スクロール同期の有効化
onScroll?: (ratio: number) => void; // スクロールイベント
}
必要な CSS ファイルをインポートし、シンタックスハイライトと数式表示のスタイルを適用します。
プレビューコンポーネント本体
typescript// src/components/Preview.tsx(続き)
export const Preview: React.FC<PreviewProps> = ({
html,
scrollSync = false,
onScroll,
}) => {
const previewRef = useRef<HTMLDivElement>(null);
// スクロール位置の同期
const handleScroll = () => {
if (!scrollSync || !onScroll || !previewRef.current) return;
const element = previewRef.current;
const scrollRatio =
element.scrollTop / (element.scrollHeight - element.clientHeight);
onScroll(scrollRatio);
};
// HTML が更新された際の処理
useEffect(() => {
if (previewRef.current) {
// 数式の再レンダリングをトリガー
const mathElements = previewRef.current.querySelectorAll('.katex');
mathElements.forEach((el) => {
el.classList.add('rendered');
});
}
}, [html]);
スクロール位置を比率として計算し、エディタ側と同期できるようにしています。また、数式要素の再レンダリング処理も含めています。
プレビューの JSX
typescript// src/components/Preview.tsx(続き)
return (
<div
ref={previewRef}
className="preview-container"
onScroll={handleScroll}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
dangerouslySetInnerHTML を使用していますが、marked.js が適切にサニタイズを行うため、XSS 攻撃のリスクは最小限に抑えられます。
Rust バックエンドの実装
Tauri の強みを活かし、ファイル操作を Rust で実装します。高速で安全なファイル I/O 処理を提供します。
Cargo.toml の依存関係
toml# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "1.5", features = ["shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["fs"] }
非同期ファイル操作のために tokio を追加しています。
ファイル読み込みコマンド
rust// src-tauri/src/main.rs
use tauri::command;
use std::fs;
/// Markdown ファイルを読み込む
///
/// # Arguments
/// * `path` - 読み込むファイルのパス
///
/// # Returns
/// ファイルの内容、またはエラーメッセージ
#[command]
async fn read_file(path: String) -> Result<String, String> {
fs::read_to_string(&path)
.map_err(|e| format!("ファイルの読み込みに失敗しました: {}", e))
}
シンプルながら、エラーハンドリングも含めた安全なファイル読み込み処理です。Rust の Result 型により、型安全なエラー処理が実現できます。
ファイル保存コマンド
rust// src-tauri/src/main.rs(続き)
/// Markdown ファイルを保存する
///
/// # Arguments
/// * `path` - 保存先のファイルパス
/// * `content` - 保存する内容
///
/// # Returns
/// 成功メッセージ、またはエラーメッセージ
#[command]
async fn save_file(path: String, content: String) -> Result<String, String> {
fs::write(&path, content)
.map_err(|e| format!("ファイルの保存に失敗しました: {}", e))?;
Ok(format!("ファイルを保存しました: {}", path))
}
ファイル保存処理も同様に、Rust の強力な型システムを活用してエラーを適切にハンドリングしています。
コマンドの登録
rust// src-tauri/src/main.rs(続き)
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
read_file,
save_file
])
.run(tauri::generate_context!())
.expect("Tauri アプリケーションの起動に失敗しました");
}
作成したコマンドを Tauri に登録します。これにより、フロントエンドから Rust の関数を呼び出せるようになります。
フロントエンドからのコマンド呼び出し
Rust で実装したファイル操作コマンドを、React コンポーネントから呼び出します。Tauri の invoke API を使用して、型安全な通信を実現します。
Tauri API のインポート
typescript// src/utils/fileOperations.ts
import { invoke } from '@tauri-apps/api/tauri';
import { open, save } from '@tauri-apps/api/dialog';
Tauri の API をインポートします。invoke は Rust コマンドの呼び出し、open と save はファイルダイアログの表示に使用します。
ファイル読み込み関数
typescript// src/utils/fileOperations.ts(続き)
/**
* ファイル選択ダイアログを表示し、選択されたファイルを読み込む
* @returns ファイルの内容とパス、またはキャンセル時は null
*/
export async function openMarkdownFile(): Promise<{
content: string;
path: string;
} | null> {
try {
// ファイル選択ダイアログを表示
const selected = await open({
multiple: false,
filters: [
{
name: 'Markdown',
extensions: ['md', 'markdown'],
},
],
});
if (!selected || Array.isArray(selected)) {
return null;
}
// Rust のコマンドを呼び出してファイルを読み込む
const content = await invoke<string>('read_file', {
path: selected,
});
return { content, path: selected };
} catch (error) {
console.error('ファイル読み込みエラー:', error);
throw new Error(
`ファイルの読み込みに失敗しました: ${error}`
);
}
}
ファイル選択ダイアログを表示し、ユーザーが選択したファイルを Rust のコマンドで読み込みます。エラーハンドリングも適切に行われています。
ファイル保存関数
typescript// src/utils/fileOperations.ts(続き)
/**
* ファイル保存ダイアログを表示し、内容を保存する
* @param content - 保存する内容
* @param defaultPath - デフォルトのファイルパス
* @returns 保存したファイルのパス、またはキャンセル時は null
*/
export async function saveMarkdownFile(
content: string,
defaultPath?: string
): Promise<string | null> {
try {
// 保存ダイアログを表示
const selected = await save({
defaultPath,
filters: [
{
name: 'Markdown',
extensions: ['md', 'markdown'],
},
],
});
if (!selected) {
return null;
}
// Rust のコマンドを呼び出してファイルを保存
await invoke<string>('save_file', {
path: selected,
content,
});
return selected;
} catch (error) {
console.error('ファイル保存エラー:', error);
throw new Error(
`ファイルの保存に失敗しました: ${error}`
);
}
}
保存ダイアログを表示し、ユーザーが指定した場所にファイルを保存します。既存のファイルパスがあれば、それをデフォルトとして使用できます。
メインアプリケーションの実装
これまで作成したコンポーネントとユーティリティを統合し、完全な Markdown エディタアプリケーションを構築します。
App コンポーネントの state 管理
typescript// src/App.tsx
import React, { useState } from 'react';
import { Editor } from './components/Editor';
import { Preview } from './components/Preview';
import { useMarkdown } from './hooks/useMarkdown';
import { openMarkdownFile, saveMarkdownFile } from './utils/fileOperations';
import './App.css';
function App() {
const [currentFilePath, setCurrentFilePath] = useState<string | null>(null);
const { markdown, html, updateMarkdown, isProcessing } = useMarkdown();
const [isSaved, setIsSaved] = useState(true);
各種 state を定義します。currentFilePath は現在開いているファイルのパス、isSaved は保存状態を追跡します。
ファイル操作ハンドラ
typescript// src/App.tsx(続き)
// ファイルを開く
const handleOpen = async () => {
try {
const result = await openMarkdownFile();
if (result) {
updateMarkdown(result.content);
setCurrentFilePath(result.path);
setIsSaved(true);
}
} catch (error) {
alert(`エラー: ${error}`);
}
};
// ファイルを保存
const handleSave = async () => {
try {
const path = await saveMarkdownFile(
markdown,
currentFilePath || undefined
);
if (path) {
setCurrentFilePath(path);
setIsSaved(true);
}
} catch (error) {
alert(`エラー: ${error}`);
}
};
// テキスト変更時
const handleChange = (newMarkdown: string) => {
updateMarkdown(newMarkdown);
setIsSaved(false);
};
ファイル操作の各ハンドラを実装します。テキストが変更されたら保存状態を未保存に更新します。
UI レイアウトの構築
typescript// src/App.tsx(続き)
return (
<div className="app">
{/* ツールバー */}
<div className="toolbar">
<button onClick={handleOpen} className="btn">
開く
</button>
<button
onClick={handleSave}
className="btn"
disabled={isSaved}
>
保存 {!isSaved && '*'}
</button>
<div className="toolbar-info">
{currentFilePath && (
<span className="file-path">{currentFilePath}</span>
)}
{isProcessing && (
<span className="processing">変換中...</span>
)}
</div>
</div>
ツールバーには開く・保存ボタンと、現在のファイルパス、処理状態の表示を配置します。保存済みの場合は保存ボタンを無効化します。
エディタとプレビューのレイアウト
typescript// src/App.tsx(続き)
{/* エディタとプレビュー */}
<div className="content">
<div className="editor-pane">
<Editor
value={markdown}
onChange={handleChange}
placeholder="# Markdown エディタ ここに Markdown を入力してください..."
/>
</div>
<div className="preview-pane">
<Preview html={html} scrollSync={true} />
</div>
</div>
</div>
);
}
export default App;
エディタとプレビューを左右に配置し、リアルタイムで変換結果を確認できるレイアウトにしています。
スタイリング
実用的なエディタに仕上げるため、CSS でスタイリングを行います。シンプルながら使いやすいデザインを目指します。
基本レイアウトスタイル
css/* src/App.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #1e1e1e;
color: #d4d4d4;
font-family: -apple-system, BlinkMacSystemFont,
'Segoe UI', 'Noto Sans JP', sans-serif;
}
ダークテーマを基調とした配色で、長時間の使用でも目が疲れにくいデザインにしています。
ツールバースタイル
css/* src/App.css(続き) */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: #252526;
border-bottom: 1px solid #3c3c3c;
}
.btn {
padding: 6px 16px;
background-color: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.btn:hover {
background-color: #1177bb;
}
.btn:disabled {
background-color: #3c3c3c;
color: #666;
cursor: not-allowed;
}
ボタンにはホバーエフェクトを追加し、無効状態も視覚的にわかりやすくしています。
エディタとプレビューのスタイル
css/* src/App.css(続き) */
.content {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-pane,
.preview-pane {
flex: 1;
overflow: auto;
}
.editor-pane {
border-right: 1px solid #3c3c3c;
}
.editor-textarea {
width: 100%;
height: 100%;
padding: 20px;
background-color: #1e1e1e;
color: #d4d4d4;
border: none;
outline: none;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
}
エディタには等幅フォントを使用し、コードやマークアップが見やすくなっています。
プレビュースタイル
css/* src/App.css(続き) */
.preview-container {
height: 100%;
padding: 20px;
overflow: auto;
background-color: #1e1e1e;
}
.preview-container h1,
.preview-container h2,
.preview-container h3 {
margin-top: 24px;
margin-bottom: 16px;
color: #e8e8e8;
line-height: 1.4;
}
.preview-container h1 {
font-size: 2em;
border-bottom: 1px solid #3c3c3c;
padding-bottom: 8px;
}
.preview-container code {
background-color: #2d2d2d;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
プレビューエリアでは、見出しやコードブロックが視覚的に区別しやすいスタイルを適用しています。
コードブロックのスタイル
css/* src/App.css(続き) */
.preview-container pre {
background-color: #2d2d2d;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
}
.preview-container pre code {
background-color: transparent;
padding: 0;
font-size: 13px;
line-height: 1.5;
}
/* 数式のスタイル */
.preview-container .katex {
font-size: 1.1em;
}
.math-error {
color: #f48771;
background-color: #5a1d1d;
padding: 2px 6px;
border-radius: 3px;
}
コードブロックと数式に専用のスタイルを適用し、読みやすさを向上させています。
アプリケーションの動作フロー
実装した Markdown エディタの動作フローを確認しましょう。以下の図は、ユーザーの操作からプレビュー更新までの一連の流れを示しています。
mermaidsequenceDiagram
participant User as ユーザー
participant Editor as Editor<br/>コンポーネント
participant Hook as useMarkdown<br/>フック
participant Parser as Markdown<br/>パーサー
participant Preview as Preview<br/>コンポーネント
User->>Editor: テキスト入力
Editor->>Hook: updateMarkdown()
Hook->>Hook: デバウンス待機<br/>(300ms)
Note over Hook: 入力が止まったら変換開始
Hook->>Parser: convertMarkdown()
Parser->>Parser: marked.parse()<br/>シンタックス<br/>ハイライト<br/>数式レンダリング
Parser-->>Hook: HTML 文字列
Hook->>Preview: html プロップ更新
Preview->>User: プレビュー表示更新
この図が示すように、デバウンス処理により入力中の無駄な変換を避け、効率的なライブプレビューを実現しています。
ファイル操作のフロー
次に、Tauri を経由したファイル操作の流れを確認します。フロントエンドと Rust バックエンドの連携が視覚的に理解できます。
mermaidsequenceDiagram
participant User as ユーザー
participant React as React<br/>フロントエンド
participant Tauri as Tauri IPC
participant Rust as Rust<br/>バックエンド
participant FS as ファイル<br/>システム
User->>React: ファイルを開く<br/>ボタンクリック
React->>Tauri: open() ダイアログ
Tauri->>User: ファイル選択UI
User->>Tauri: ファイル選択
Tauri-->>React: ファイルパス
React->>Tauri: invoke('read_file')
Tauri->>Rust: read_file(path)
Rust->>FS: fs::read_to_string()
FS-->>Rust: ファイル内容
Rust-->>Tauri: Result<String>
Tauri-->>React: ファイル内容
React->>User: エディタに表示
Tauri の IPC 機構により、型安全で高速なファイル操作が実現されていることがわかります。
ビルドと実行
開発したアプリケーションをビルドし、実行してみましょう。
開発モードでの実行
bash# 開発サーバーの起動
yarn tauri dev
このコマンドで、ホットリロード対応の開発環境が起動します。コードを変更すると即座に反映されるため、開発効率が高まります。
プロダクションビルド
bash# リリースビルドの作成
yarn tauri build
このコマンドにより、配布可能なバイナリが生成されます。OS ごとに最適化されたインストーラーやアプリケーションバンドルが作成されます。
ビルド成果物は以下の場所に生成されます。
| # | OS | 生成される形式 | パス |
|---|---|---|---|
| 1 | macOS | .app, .dmg | src-tauri/target/release/bundle/macos/ |
| 2 | Windows | .exe, .msi | src-tauri/target/release/bundle/windows/ |
| 3 | Linux | .deb, .AppImage | src-tauri/target/release/bundle/linux/ |
拡張機能の追加
基本機能に加えて、さらに便利な拡張機能を追加できます。ここでは目次の自動生成機能を実装してみましょう。
目次生成ユーティリティ
typescript// src/utils/tableOfContents.ts
/**
* 見出し情報の型定義
*/
interface HeadingNode {
level: number; // 見出しレベル(1-6)
text: string; // 見出しテキスト
id: string; // アンカー ID
}
/**
* Markdown から見出しを抽出して目次を生成
* @param markdown - 解析する Markdown テキスト
* @returns 見出しノードの配列
*/
export function extractHeadings(
markdown: string
): HeadingNode[] {
const headings: HeadingNode[] = [];
const lines = markdown.split('\n');
lines.forEach((line) => {
const match = line.match(/^(#{1,6})\s+(.+)$/);
if (match) {
const level = match[1].length;
const text = match[2].trim();
const id = text
.toLowerCase()
.replace(
/[^\w\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF]+/g,
'-'
);
headings.push({ level, text, id });
}
});
return headings;
}
正規表現で Markdown の見出し記法を検出し、階層構造を持った目次データを生成します。日本語にも対応したアンカー ID 生成ロジックを含めています。
目次コンポーネント
typescript// src/components/TableOfContents.tsx
import React from 'react';
import { extractHeadings } from '../utils/tableOfContents';
interface TableOfContentsProps {
markdown: string;
}
export const TableOfContents: React.FC<
TableOfContentsProps
> = ({ markdown }) => {
const headings = extractHeadings(markdown);
if (headings.length === 0) {
return (
<div className='toc-empty'>見出しがありません</div>
);
}
return (
<nav className='table-of-contents'>
<h3>目次</h3>
<ul>
{headings.map((heading, index) => (
<li
key={index}
style={{
marginLeft: `${(heading.level - 1) * 16}px`,
}}
>
<a href={`#${heading.id}`}>{heading.text}</a>
</li>
))}
</ul>
</nav>
);
};
抽出した見出し情報を階層的にレンダリングします。インデントにより見出しレベルが視覚的にわかりやすくなっています。
パフォーマンス最適化
大きなファイルを扱う場合のパフォーマンス最適化について解説します。
メモ化による最適化
typescript// src/components/Editor.tsx(最適化版)
import React, { memo } from 'react';
export const Editor: React.FC<EditorProps> = memo(
({ value, onChange, placeholder }) => {
// ... 実装は同じ
},
(prevProps, nextProps) => {
// value が変更されていなければ再レンダリングをスキップ
return prevProps.value === nextProps.value;
}
);
React の memo を使用して、不要な再レンダリングを防ぎます。これにより、大きなファイルでもスムーズな編集が可能になります。
仮想スクロールの検討
typescript// src/hooks/useVirtualScroll.ts
import { useState, useEffect } from 'react';
/**
* 仮想スクロール用のカスタムフック
* 大きなコンテンツを効率的に表示
*/
export function useVirtualScroll(
totalItems: number,
itemHeight: number,
containerHeight: number
) {
const [scrollTop, setScrollTop] = useState(0);
// 表示範囲の計算
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
totalItems,
Math.ceil((scrollTop + containerHeight) / itemHeight)
);
// 表示する行の範囲
const visibleItems = { startIndex, endIndex };
return { visibleItems, setScrollTop };
}
非常に長いドキュメントの場合、仮想スクロールを導入することで、表示される部分だけをレンダリングし、パフォーマンスを大幅に向上させられます。
エラーハンドリング
実用的なアプリケーションには適切なエラーハンドリングが不可欠です。
グローバルエラーバウンダリ
typescript// src/components/ErrorBoundary.tsx
import React, {
Component,
ErrorInfo,
ReactNode,
} from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error(
'アプリケーションエラー:',
error,
errorInfo
);
}
render() {
if (this.state.hasError) {
return (
<div className='error-container'>
<h2>エラーが発生しました</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => window.location.reload()}>
再読み込み
</button>
</div>
);
}
return this.props.children;
}
}
React のエラーバウンダリを使用して、予期しないエラーが発生してもアプリケーション全体がクラッシュしないようにします。
まとめ
本記事では、Tauri を使用した本格的な Markdown エディタの実装方法を解説しました。以下の重要なポイントを押さえることで、実用的なデスクトップアプリケーションを開発できます。
実装した主要機能
| # | 機能 | 実装技術 | メリット |
|---|---|---|---|
| 1 | ライブプレビュー | marked.js + デバウンス | リアルタイム変換 |
| 2 | シンタックスハイライト | highlight.js | コードの可読性向上 |
| 3 | 数式表示 | KaTeX | LaTeX 数式対応 |
| 4 | ファイル操作 | Rust + Tauri IPC | 高速で安全な I/O |
| 5 | 目次生成 | カスタムパーサー | ドキュメント構造の可視化 |
Tauri を選ぶ理由
Tauri は従来の Electron と比較して、以下の明確な優位性があります。
- 軽量: バイナリサイズが 3-5MB と非常に小さく、配布が容易
- 高速: OS ネイティブの WebView を使用し、起動が速い
- セキュア: Rust の型安全性とメモリ安全性による堅牢な設計
- 低リソース: メモリ消費量が少なく、複数のアプリケーションを同時に実行可能
開発のポイント
効率的な開発のために、以下のポイントを意識しましょう。
まず、パフォーマンス最適化では、デバウンス処理により不要な変換を避け、メモ化により無駄な再レンダリングを防ぎます。 次に、ユーザビリティでは、リアルタイムプレビューとスクロール同期により、快適な編集体験を提供します。 そして、拡張性では、プラグインアーキテクチャにより、シンタックスハイライトや数式表示などの機能を柔軟に追加できます。 最後に、型安全性では、TypeScript と Rust の組み合わせにより、開発時にエラーを検出しやすくなります。
今後の拡張案
本記事で実装した基本機能に加えて、以下のような拡張が可能です。
- テーマのカスタマイズ: ライト/ダークモードの切り替え、カラースキームの選択
- キーボードショートカット: Ctrl+S で保存、Ctrl+O で開くなどの操作性向上
- プレビューのエクスポート: HTML や PDF への変換機能
- マルチタブ対応: 複数のファイルを同時に開いて編集
- Git 連携: バージョン管理機能の統合
- クラウド同期: Dropbox や Google Drive との同期機能
学んだ技術
本記事を通じて、以下の技術スタックを実践的に学ぶことができました。
Tauri フレームワークの基礎から、Rust によるバックエンド実装、React + TypeScript によるフロントエンド開発、Markdown パースと HTML 変換、そしてデスクトップアプリケーションのビルドと配布まで、実用的なアプリケーション開発に必要な知識を網羅的に習得できたはずです。
Tauri は今後さらに成長が期待されるフレームワークです。本記事で学んだ知識をベースに、オリジナルのデスクトップアプリケーション開発に挑戦してみてください。
関連リンク
articleTauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
articleTauri でシステムトレイ&メニューバー実装:常駐アプリの基本機能を作る
articleTauri アーキテクチャ設計指針:コマンド(Rust)と UI(Web)分離のベストプラクティス
articleTauri コマンド&CLI チートシート:init/build/dev/sign/notarize 早見表
articleTauri 開発環境の最速構築:Node・Rust・WebView ランタイムの完全セットアップ
articleTauri 性能検証レポート:起動時間・メモリ・ディスクサイズを主要 OS で実測
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleTauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
articleStorybook で“仕様が生きる”開発:ドキュメント駆動 UI の実践ロードマップ
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleSolidJS の Control Flow コンポーネント大全:Show/For/Switch/ErrorBoundary を使い分け
articleRemix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来