こんにちは、エンジニアのオオバです。
僕は普段、Markdown で文章を書く時間がいちばん長いです。
手が覚えているのは Sublime Text の操作。
欲しいのは、コードの色分けと Markdown のプレビューだけ。
それ以外は何もいらないので、自分専用のエディタを作ってみました。
Tauri v2 + CodeMirror 6 で約4MBのエディタが作れた。macOS特有のハマりどころはメニュー・終了処理・Finderからの起動に集中していた
先に結論をまとめます。
①Electronは使わずTauri v2 + CodeMirror 6にした。完成した.appは約4MB
②マルチカーソル・矩形選択・言語別ハイライトはCodeMirror 6に最初からある。足したのはSublimeの手癖のコマンドだけ
③macOSでのハマりどころは、編集メニュー・終了時の未保存確認・Finderからのファイル受け取り・CSP・Rustのバージョン
ここから順番に説明します。
→11万文字で徹底解説した「DOTweenの教科書」Unityアニメーションの超効率化ツールはこちら
- やりたかったこと:Sublimeの書き味+プレビューだけのエディタ
- 構成の判断:ElectronもAppKitも選ばなかった理由
- プレビューの作り
- ハマりどころ1:Cmd+C / Cmd+V が効かない
- ハマりどころ2:終了メニューで未保存確認が出ない
- ハマりどころ3:Finderで開いたファイルが届かない
- ハマりどころ4:CSPでCodeMirrorのスタイルが効かなくなる恐れ
- ハマりどころ5:HomebrewのRustが古くてビルドできない
- 追加機能:アウトライン(見出し一覧)
- 追加の修正:Dockに置くなら最後のウィンドウを閉じても終了させない
- 補足:プロジェクトを移動したらtauri buildが失敗した
- 結果
- 学び:Tauriのハマりどころは「WebViewの外側」に集まる
- まとめ:自分専用エディタはTauri v2 + CodeMirror 6で十分作れる
やりたかったこと:Sublimeの書き味+プレビューだけのエディタ
目的は Markdown の執筆です。
必要な機能は次の3つに絞りました。
- Sublime Text と同じ操作感
- コードブロックの色分け
- Markdown のプレビュー
アプリの名前は「Sumi」にしました。
完成した画面はこちらです。

左のエディタは Monokai の配色で、C# や TypeScript のコードブロックも言語ごとに色分けされます。
右はプレビューで、見出し・コード・リスト・引用・表がそのまま確認できます。
構成の判断:ElectronもAppKitも選ばなかった理由
Electronは重い
最初に外したのは Electron です。
Markdown を書くだけのアプリにしては重すぎます。
そこで Tauri v2(Rust)+ CodeMirror 6 の組み合わせにしました。
完成した .app は約4MB に収まっています。
ネイティブのAppKitは自前で作るものが多すぎる
ネイティブの AppKit(NSTextView)で作る案も考えました。
ただ、Sublime の操作感を出すには次の機能が要ります。
- マルチカーソル
- 矩形選択
- 言語別のハイライト
これを全部自前で作るのは重い作業です。
CodeMirror 6 なら、次の機能が最初から揃っています。
⌘ + Dでの複数選択Option + ドラッグの矩形選択- 言語データの遅延読み込み
足したのはSublimeの手癖だけ
CodeMirror 6 に無くて、僕の手が覚えている操作だけを自前のコマンドで足しました。
⌘ + Shift + L: 選択範囲を行ごとに分割⌘ + Enter: 下に行を挿入⌘ + Ctrl + ↑↓: 行の移動⌘ + Shift + D: 行の複製
この4つが無いと、書いている途中で毎回手が止まるんですよね。
プレビューの作り
プレビューには markdown-it を使っています。
スクロール追従
markdown-it の core ルールで、各ブロックに元の行番号を data-line として埋め込みました。
エディタの先頭に表示されている行から、プレビュー側の位置を補間してスクロールを合わせています。
生のHTMLは描画しない
WebView は Rust 側との IPC を持っています。
開いたファイルの中に生の HTML が入っていると、そこから IPC を叩かれる恐れがある。
そのため markdown-it は html: false にして、生の HTML は描画しない設定にしました。
noteとZennに合わせた改行と文字数
改行は note や Zenn と同じ見た目にしたかったので、breaks: true にしています。
ステータスバーには、改行を除いた文字数を出しました。
note の記事で3000文字を超えているかを見るためです。
ハマりどころ1:Cmd+C / Cmd+V が効かない
最初にハマったのはコピーとペーストでした。
Tauri on macOS では、メニューに「編集 > コピー / ペースト」が無いと、WKWebView 上で ⌘ + C / ⌘ + V が効きません。
それなら編集メニューを全部ネイティブで載せればいい、と思いますよね。
ところが Undo をネイティブのメニューに載せると、ブラウザ側の Undo が動きます。
CodeMirror が持っている履歴とずれてしまうんです。
結局、ネイティブのメニューには Cut / Copy / Paste だけ を載せました。
ハマりどころ2:終了メニューで未保存確認が出ない
既定の「終了」メニューは、ウィンドウの CloseRequested を飛ばして即座に終了します。
未保存の変更があっても、確認ダイアログが出ないまま閉じてしまう。
書きかけの文章が消えるのは、エディタとしていちばん困るところです。
そこで、自前の Quit 項目を作りました。
- Quit で各ウィンドウの
close()を呼ぶ - フロント側の
onCloseRequestedで確認ダイアログを出す
これで、終了メニューからでも未保存の確認が出るようになりました。
ハマりどころ3:Finderで開いたファイルが届かない
Finder でファイルをダブルクリックしたとき、ファイルのパスはコマンドライン引数に入っていません。
届くのは RunEvent::Opened です。
さらに、起動直後はフロント側の listener がまだ登録されていません。
その時点で Rust 側から emit すると、イベントが消えてしまいます。
対策として、Rust 側で開くファイルを預かっておき、フロントの準備ができたら取りに来る形にしました。
ハマりどころ4:CSPでCodeMirrorのスタイルが効かなくなる恐れ
CodeMirror は動的にスタイルを差し込みます。
CSP の style-src に Tauri がハッシュを自動で追加すると、'unsafe-inline' が無視されます。
その結果、CodeMirror の動的なスタイルが効かなくなる恐れがありました。
Tauri の設定で、style-src へのハッシュ追加を止めています。
"dangerousDisableAssetCspModification": ["style-src"]
ハマりどころ5:HomebrewのRustが古くてビルドできない
Homebrew で入れていた Rust は 1.87 でした。
依存しているクレート(uuid)が Rust 1.89 以上を要求していて、ビルドが通りません。
次のコマンドで Rust を上げて解決しました。
brew upgrade rust
上げた後のバージョンは 1.99 です。
追加機能:アウトライン(見出し一覧)
長い記事を書いていると、見出しへすぐ飛びたくなります。
そこで、左に見出し一覧のサイドバーを足しました。

見出しは自前の行スキャンで拾う
見出しの抽出には、CodeMirror の構文木を使っていません。
構文木は、表示している範囲までしか解析されないことがあります。
長い文章だと、後半の見出しが一覧から抜けてしまう。
そのため、自前で1行ずつ見て見出しを拾う形にしました。
次のものを見出しとして拾わないように、テストで固定しています。
- コードブロック内の
# - Zenn のフロントマター
- リストの直後にある
---
ジャンプすると1つ手前の見出しがハイライトされた
見出しをクリックすると、その見出しが上から8pxの位置に来るようにジャンプさせています。
ところが、ウィンドウの最上端には直前の節の行が残ります。
そのため「今いる節」の判定が、1つ手前の見出しになっていました。
判定の位置を1行分下にずらして解決しています。
ウィンドウが裏にあるとスクロールが追従しない
もう1つは、気づくまで時間がかかった問題です。
ウィンドウが他のアプリの裏に隠れていると、WebKit は requestAnimationFrame を止めます。
スクロールの追従を rAF でまとめて処理していたので、裏にあるとプレビューもアウトラインも追従しなくなっていました。
scroll イベントの中で直接更新するように変えています。
追加の修正:Dockに置くなら最後のウィンドウを閉じても終了させない
Dock に置いて使うなら、Sublime と同じ挙動にしたいところです。
Tauri は、最後のウィンドウを閉じるとアプリごと終了します。
次のように変えました。
RunEvent::ExitRequestedでcode: Noneのときapi.prevent_exit()を呼び、終了を止めるRunEvent::Reopenでhas_visible_windows: falseのとき、ウィンドウを作り直す- 終了メニューから終了したときだけフラグを立てて、本当に終了させる
これで、Dock のアイコンをクリックすると新しいウィンドウが開くようになりました。
未保存フラグはRust側に持たせる
未保存の確認を JS(onCloseRequested)だけに任せていると、別の問題が出ます。
別の Space にあって表示されていないウィンドウは、WebView が止まっていて閉じられない恐れがありました。
そこで、未保存かどうかのフラグを Rust 側に持たせています。
保存済みのウィンドウは、JS に聞かずにそのまま閉じます。
終了直後に開き直すとerror -600
終了した直後に open -a で開き直すと、起動しないことがありました。
LaunchServices が次のエラーを返します。
_LSOpenURLsWithCompletionHandler() failed ... error -600
プロセスは消えているのに、LaunchServices 側の記録がまだ残っている瞬間に当たっていたようです。
数秒あけてから開けば起動します。
補足:プロジェクトを移動したらtauri buildが失敗した
途中で、プロジェクトのフォルダを temp から internal へ移しました。
その後の tauri build で、次のエラーが出ています。
failed to read plugin permissions: failed to read file '<旧パス>/src-tauri/target/release/build/tauri-*/out/permissions/...toml'
Rust のビルドキャッシュ(target/)が、ビルドスクリプトの出力先を絶対パスで持っていたのが原因です。
キャッシュを消して解決しました。
cd src-tauri && cargo clean
消えたキャッシュは2.3GB ありました。
結果
実機で次の動作を確認しています。
- 入力と保存
⌘ + Dのマルチカーソル- Finder からのファイルを開いての起動
- 未保存確認のダイアログ
- アウトラインからのジャンプと、エディタ・プレビューの位置合わせ
Sublime の手癖のまま書けて、プレビューも横に出る。
欲しかったものがそのまま手に入りました。
学び:Tauriのハマりどころは「WebViewの外側」に集まる
振り返ると、エディタ部分で苦労したところはほとんどありません。
CodeMirror 6 が、マルチカーソルや言語別ハイライトを最初から持っていたからです。
時間を使ったのは、メニュー・終了処理・Finder からの起動・Dock の挙動といった、macOS アプリとしての振る舞い の方でした。
もう1つは、WebView が止まる場面です。
裏に隠れたウィンドウや別の Space のウィンドウでは、rAF も JS も期待どおりに動きません。
状態を Rust 側に持たせておくと、こうした場面でも安心できます。
まとめ:自分専用エディタはTauri v2 + CodeMirror 6で十分作れる
①コピー&ペーストにはネイティブの編集メニューが要る。Undoは載せずCodeMirrorの履歴に任せる
②既定の終了メニューは未保存確認を飛ばす。自前のQuitで各ウィンドウをclose()する
③Finderから開いたファイルはRunEvent::Openedで届く。フロントの準備ができるまでRust側で預かる
④裏に隠れたウィンドウではrAFが止まる。スクロール追従はscrollイベントで直接更新する
⑤最後のウィンドウで終了させないなら、ExitRequestedでprevent_exit、Reopenでウィンドウを作り直す
Electron を使わなくても、約4MB で毎日使えるエディタになりました。
自分の手癖に合わせた道具を持つと、書くこと自体が少し楽しくなります。
同じように Tauri で macOS アプリを作ろうとしている人の参考になれば嬉しいです。

この記事が気に入ったらフォローしよう
- macOS 15.4 / Tauri 2.12 / CodeMirror 6 / Rust 1.99
