✏️

Tauri v2 + React でローカルファーストなMarkdownノートアプリを作った

に公開

はじめに — 「ちょうどいい」ノートアプリがない問題

Notion はクラウド依存、Obsidian はプラグイン沼。自分が欲しかったのはもっとシンプルなものだった。

  • データは手元の Markdown ファイル。クラウドなし、DB なし
  • 起動が速い。Electron のもっさり感はイヤ
  • フォルダとタグで整理できれば十分
  • キーボードだけで操作できる

「80% は既存ツールで満たせるけど、残りの 20% が気になる」——エンジニアなら覚えのある感覚だと思う。妥協するか、自分で作るか。作ることにした。

そうして生まれたのが Graphite というノートアプリだ。

技術選定 — 迷ったこと、決めたこと

Electron じゃなくて Tauri にした理由

一番の理由はサイズ。Electron はバンドル 150MB 超、Tauri は OS 組み込みの WebView を使うので約 10MB。メモアプリに 150MB は重い。バックエンドが Rust なのもファイル操作やセキュリティ面で都合がいい。

……正直に言うと、Rust を書く口実が欲しかったのもある。

エディタは TipTap

Markdown エディタのライブラリはかなり悩んだ。CodeMirror はリッチプレビュー統合が大変、Monaco はオーバースペック。TipTap は ProseMirror ベースで、tiptap-markdown パッケージのおかげで「見た目はリッチに、保存は Markdown」という体験を作りやすかった。

状態管理は Zustand

State と Action を 1 つのオブジェクトにまとめて set() で更新するだけ。Redux の Action / Reducer / Selector の儀式は不要。個人開発のテンションを保つには、この手軽さがちょうどいい。

export const useNoteStore = create<NoteState>((set, get) => ({
  notes: [],
  loading: false,
  loadNotes: async (folder) => {
    set({ loading: true });
    const notes = await noteApi.listNotes(folder);
    set({ notes, loading: false });
  },
}));

UI は shadcn/ui + Tailwind CSS

npm パッケージではなく、必要なコンポーネントのコードを自分のプロジェクトにコピーするスタイル。「ここだけ色を変えたい」がソースを直接いじるだけで済むので、個人開発と相性がいい。

アーキテクチャ — 失敗から生まれた 4 層構造

Graphite のコードは 4 つの層に分かれている。

Component → Store → lib/api → Rust (Tauri Command)

最初は Component から直接 invoke() を呼んでいた。小規模なうちは動くが、ファイルが増えると同じ呼び出しがコピペで散らばり、エラーハンドリングもバラバラになった。

痛みを感じてから整理した結果、3 つのルールに落ち着いた。

  1. invoke()src/lib/api/ にだけ書く
  2. Component は Store 経由でデータを取る
  3. Store 同士は直接参照しない

API 層はビジネスロジックを書かない、ただの型付き invoke() ラッパー。ロジックとエラー処理は Store に集約する。こうすると将来バックエンドを差し替えるときも API 層だけ書き換えれば済む。

最初からこの設計にしていれば……とも思うが、痛みを感じて整理したからこそ腹落ちしている。

苦労ポイント① — TipTap と Markdown の「二重人格」問題

見た目はリッチ、中身は Markdown

TipTap の中身は ProseMirror のドキュメントツリー。見出しは <h1>、太字は <strong>。一方、保存するのは # 見出し**太字** の Markdown テキスト。この 2 つの世界を行き来させるのが一番頭を使ったところだった。

tiptap-markdown で基本的な変換はできるが、コードブロックのシンタックスハイライトなど細かい部分は自分で調整が必要になる。CodeBlockLowlight 拡張と lowlight を組み合わせ、入力ルールも ```python のような言語指定に対応させた。

CodeBlockLowlight.extend({
  addInputRules() {
    return [
      // ```python + スペース → Python コードブロック
      textblockTypeInputRule({
        find: /^```([a-z]+)[\s\n]$/,
        type: this.type,
        getAttributes: (match) => ({ language: match[1] }),
      }),
      // ``` + スペース → 言語なしコードブロック
      textblockTypeInputRule({
        find: /^```\s$/,
        type: this.type,
      }),
    ];
  },
  addNodeView() {
    return ReactNodeViewRenderer(CodeBlockView);
  },
}).configure({ lowlight })

addNodeView() で React コンポーネントをアタッチし、コードブロック右上に言語名ラベルとコピーボタンを表示している。

自動保存のさじ加減

キー入力のたびにファイルに書き込むのは I/O の無駄。保存間隔が長すぎるとアプリが落ちたときにデータが飛ぶ。悩んだ結果、2 段階のデバウンスに落ち着いた。

  • 300ms 後: UI を更新(プレビュー、文字数カウント)
  • 3000ms 後: ファイルに書き込み

さらにエディタのアンマウント時に未保存があればフラッシュする三段構え。使っていて「保存ボタン」を押したいと思ったことがない。

苦労ポイント② — macOS に「ネイティブっぽさ」を出す戦い

信号機ボタンの位置調整

macOS アプリで「ちゃんとしてる感」を出すのに効くのが、ウィンドウ左上の信号機ボタンのポジショニングだ。Tauri でカスタムタイトルバーを使うとデフォルト位置がサイドバーとずれる。調整には Rust から Cocoa API を objc2 クレートで直接叩く必要がある。

#[cfg(target_os = "macos")]
fn set_traffic_light_position(window: &tauri::WebviewWindow, x: f64, y_from_top: f64) {
    unsafe {
        let buttons = [
            NSWindowButton::CloseButton,
            NSWindowButton::MiniaturizeButton,
            NSWindowButton::ZoomButton,
        ];
        let spacing = 20.0;
        for (i, button_type) in buttons.iter().enumerate() {
            if let Some(button) = ns_window.standardWindowButton(*button_type) {
                let size = button.frame().size;
                // Cocoa は左下が原点。Y 座標を逆算する
                let cocoa_y = if let Some(superview) = button.superview() {
                    superview.frame().size.height - y_from_top - size.height
                } else {
                    y_from_top
                };
                let origin = NSPoint::new(x + (i as f64) * spacing, cocoa_y);
                button.setFrame(NSRect::new(origin, NSSize::new(size.width, size.height)));
            }
        }
    }
}

ハマりポイントは Cocoa の座標系が Y 軸反転(左下が原点)なこと。「上から 13px」と思って y = 13.0 と書くとボタンが画面の下に飛ぶ。正解は 親ビューの高さ - 13 - ボタンの高さ という逆算。これに気づくまで 2 時間溶かした。

さらにウィンドウのリサイズやフルスクリーン切り替えで macOS がボタン位置をリセットしてくるため、イベントリスナーで監視して毎回再設定している。たかが信号機ボタンの位置にこんなに苦労するとは思わなかったが、こういう細部が「ネイティブっぽいアプリ」の差を生む。

ネイティブメニューの多言語化

macOS メニューバーは Rust 側で構築されるが、翻訳リソースは React の i18n にある。解決策は言語が切り替わるたびにメニュー全体を作り直す力技。Tauri v2 のメニュー API には個別項目の更新手段がなく、再構築が一番確実だった。メニュー項目はせいぜい 25 個なので、再構築のコストは体感ゼロ。言語切り替え時にメニューがパッと変わるのは気持ちいい。

苦労ポイント③ — キーボード操作を「ちゃんと」やる難しさ

コンテキストに応じたショートカット

Graphite は 20 以上のショートカットキーを持つ。Enter キーひとつとっても、ノートリストでは「ノートのリネーム」、サイドバーでは「フォルダのリネーム」にしたい。

これを実現するためにコマンドに scope(有効範囲) を持たせた。ホットキーが押されたら今どのパネルにフォーカスがあるかをチェックし、スコープが合うコマンドだけ実行する。Mod プレフィックスで macOS の Cmd と Windows の Ctrl を吸収するのは VS Code 等でおなじみの手法だが、自分で実装すると理解が深まる。

日本語 IME との終わらない戦い

個人的にこのプロジェクトで一番つらかったのがこれ。日本語の変換確定 Enter を、アプリがショートカットの Enter として誤検知する問題。

isComposing フラグを見ればいい」のは Chromium の話。Tauri の macOS 版は WKWebView(WebKit)を使っていて、イベントの発火順序が違う

Chromium: keydown (isComposing=true) → compositionend
WebKit:   compositionend → keydown (isComposing=false)  ← !?

WebKit では compositionend が先に来るため、変換確定の Enter が普通の Enter と見分けがつかない。対処として 3 つの ref を使った状態マシン的なフックを作り、Chromium と WebKit のどちらのパスでも IME の確定 Enter を誤検知しないようにした。

export function useIMEGuard() {
  const composingRef = useRef(false);
  const enterBlockedRef = useRef(false);
  const pendingGuardRef = useRef(false);

  const onCompositionEnd = () => {
    composingRef.current = false;
    if (!enterBlockedRef.current) {
      // WebKit パス: 次の keydown をブロック
      pendingGuardRef.current = true;
    }
  };

  const isComposing = (key: string): boolean => {
    if (composingRef.current) {
      if (key === 'Enter') enterBlockedRef.current = true;
      return true;
    }
    if (pendingGuardRef.current) {
      pendingGuardRef.current = false;
      return true; // WebKit の "遅れてくる Enter" をブロック
    }
    return false;
  };

  return { onCompositionStart, onCompositionEnd, isComposing };
}

たった 30 行のフックだが、ここに至るまでの試行錯誤は長かった。日本語入力を扱うアプリを作る人は覚悟しておいてほしい。

こだわったポイント

パストラバーサル対策

ローカルファイルを扱うアプリで怖いのがパストラバーサル。Graphite では、ファイルパスを扱うすべての Rust コマンドの先頭で ensure_within_vault() を呼ぶことを鉄の掟にしている。.. の文字列チェックと canonicalize() による実パス検証の二段構えで、シンボリックリンクによる迂回も防ぐ。個人アプリでもこういうところは Rust でしっかり守ると気持ちがいい。

ゴミ箱は復元できてこそ

削除したノートは UUID でリネームしてゴミ箱ディレクトリに移動し、元パス情報を manifest.json に記録。復元時は元のパスに戻し、同名ファイルが存在すればサフィックスをつける。すべて JSON + ファイルシステムで完結、DB 不要。

Markdown ファイルがそのままデータ

YAML Frontmatter でメタデータ(タイトル、タグ、作成日時など)を持たせたただの .md ファイル。Graphite が嫌になったら、フォルダをそのまま VS Code や Obsidian で開ける。ロックインなし。これが「ローカルファースト」の一番大切なところだと思っている。

学びと振り返り

Tauri、使ってみてどうだった?

良かった点: バンドルが小さい(dmg で約 10MB)。Rust バックエンドの安心感。公式プラグインが充実。

つらかった点: macOS ネイティブ統合の情報が少なく、Tauri → objc2 → Apple の Cocoa リファレンスと 3 つを行き来する羽目に。WebView の挙動がプラットフォームで異なる(IME 問題がまさにそれ)。

総合的には「選んでよかった」。

AI と一緒に開発するためのルール文書化

このプロジェクトでは、コーディングルールを .claude/rules/ に Markdown で文書化している。1 ヶ月後の自分は他人なので、ルールの「なぜ」を書き残しておかないと自分でルールを破り始める。

もうひとつの理由は AI との協業。Claude Code にルールファイルを読ませると、プロジェクトの規約に沿ったコードを生成してくれる。「invoke() を Component に書かないで」と毎回言わなくて済む。これが個人開発の生産性をかなり上げてくれた。

コードベースの規模感

TypeScript/React 約 100 ファイル 8,400 行、Rust 約 20 ファイル 1,800 行。合計約 10,000 行。レイヤー分離を守って各ファイルを小さく保った効果だと思う。

おわりに

自分で使うアプリを自分で作るのは、エンジニアの特権だ。「ここがこうだったらいいのに」をそのまま実装できる。Cocoa の座標系で 2 時間溶かしたり、IME のバグを直したと思ったら別のブラウザエンジンで再発したり、大変なことはたくさんあった。でも、そういう「なんでやねん」の積み重ねが「自分の手に馴染むツール」を生み出してくれた。

Graphite は MIT ライセンスで公開している。

GitHub: bukamasedo/graphite

# Homebrew (macOS)
brew install --cask bukamasedo/tap/graphite-notes

# または Releases ページからダウンロード
# https://github.com/bukamasedo/graphite/releases

ローカルファーストでシンプルなメモアプリを探している方、Tauri でデスクトップアプリを作ってみたい方の参考になれば嬉しいです。

Discussion