T-CREATOR

Tauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応

Tauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応

近年、Electron に代わる軽量なデスクトップアプリケーションフレームワークとして Tauri が注目を集めています。本記事では、Tauri を使って実用的な Markdown エディタを開発する方法を解説します。リアルタイムでプレビューが更新されるライブプレビュー機能や、シンタックスハイライト・数式表示などの拡張プラグインに対応した、本格的なエディタの実装をステップバイステップで学んでいきましょう。

背景

Tauri とは何か

Tauri は Rust で構築されたデスクトップアプリケーションフレームワークで、Web 技術(HTML、CSS、JavaScript)を使ってネイティブアプリケーションを開発できます。Electron と比較して以下のような特徴があります。

#項目TauriElectron
1バイナリサイズ約 3-5MB約 80-150MB
2メモリ使用量軽量重量
3セキュリティ高セキュリティ(Rust ベース)標準的
4ランタイムOS 標準の WebViewChromium 内蔵
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 のメモリセーフティとタイプセーフティの恩恵

ライブプレビューの実装戦略

効率的なライブプレビューを実現するために、以下の技術を組み合わせます。

#技術用途メリット
1marked.jsMarkdown パーサー高速な変換処理
2highlight.jsシンタックスハイライト豊富な言語サポート
3KaTeX数式レンダリング軽量で高速
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 エディタ&#10;&#10;ここに 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生成される形式パス
1macOS.app, .dmgsrc-tauri/target/release/bundle/macos/
2Windows.exe, .msisrc-tauri/target/release/bundle/windows/
3Linux.deb, .AppImagesrc-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数式表示KaTeXLaTeX 数式対応
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 は今後さらに成長が期待されるフレームワークです。本記事で学んだ知識をベースに、オリジナルのデスクトップアプリケーション開発に挑戦してみてください。

関連リンク