こんにちは、エンジニアのオオバです。

お悩みさん
お悩みさん
  • 軽いMarkdownエディタが欲しい
  • Sublime Textの操作感のまま書きたい
  • TauriでmacOSアプリを作るときのハマりどころを知りたい
  • オオバ
    オオバ
    本記事ではこれらの悩みを解決します。

    僕は普段、Markdown で文章を書く時間がいちばん長いです。

    手が覚えているのは Sublime Text の操作。

    欲しいのは、コードの色分けと Markdown のプレビューだけ。

    それ以外は何もいらないので、自分専用のエディタを作ってみました。

    Tauri v2 + CodeMirror 6 で約4MBのエディタが作れた。macOS特有のハマりどころはメニュー・終了処理・Finderからの起動に集中していた

    先に結論をまとめます。

    Tauri v2 + CodeMirror 6 でMarkdownエディタを作った結果

    ①Electronは使わずTauri v2 + CodeMirror 6にした。完成した.appは約4MB

    ②マルチカーソル・矩形選択・言語別ハイライトはCodeMirror 6に最初からある。足したのはSublimeの手癖のコマンドだけ

    ③macOSでのハマりどころは、編集メニュー・終了時の未保存確認・Finderからのファイル受け取り・CSP・Rustのバージョン

    ここから順番に説明します。

    →11万文字で徹底解説した「DOTweenの教科書」Unityアニメーションの超効率化ツールはこちら

    やりたかったこと:Sublimeの書き味+プレビューだけのエディタ

    目的は Markdown の執筆です。

    必要な機能は次の3つに絞りました。

    アプリの名前は「Sumi」にしました。

    完成した画面はこちらです。

    左がCodeMirrorのエディタ(Monokai)、右がmarkdown-itのプレビュー

    左がCodeMirrorのエディタ(Monokai)、右がmarkdown-itのプレビュー

    左のエディタは Monokai の配色で、C# や TypeScript のコードブロックも言語ごとに色分けされます。

    右はプレビューで、見出し・コード・リスト・引用・表がそのまま確認できます。

    構成の判断:ElectronもAppKitも選ばなかった理由

    Electronは重い

    最初に外したのは Electron です。

    Markdown を書くだけのアプリにしては重すぎます。

    そこで Tauri v2(Rust)+ CodeMirror 6 の組み合わせにしました。

    完成した .app は約4MB に収まっています。

    ネイティブのAppKitは自前で作るものが多すぎる

    ネイティブの AppKit(NSTextView)で作る案も考えました。

    ただ、Sublime の操作感を出すには次の機能が要ります。

    これを全部自前で作るのは重い作業です。

    CodeMirror 6 なら、次の機能が最初から揃っています。

    足したのはSublimeの手癖だけ

    CodeMirror 6 に無くて、僕の手が覚えている操作だけを自前のコマンドで足しました。

    この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 項目を作りました。

    これで、終了メニューからでも未保存の確認が出るようになりました。

    ハマりどころ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行ずつ見て見出しを拾う形にしました。

    次のものを見出しとして拾わないように、テストで固定しています。

    ジャンプすると1つ手前の見出しがハイライトされた

    見出しをクリックすると、その見出しが上から8pxの位置に来るようにジャンプさせています。

    ところが、ウィンドウの最上端には直前の節の行が残ります。

    そのため「今いる節」の判定が、1つ手前の見出しになっていました。

    判定の位置を1行分下にずらして解決しています。

    ウィンドウが裏にあるとスクロールが追従しない

    もう1つは、気づくまで時間がかかった問題です。

    ウィンドウが他のアプリの裏に隠れていると、WebKit は requestAnimationFrame を止めます。

    スクロールの追従を rAF でまとめて処理していたので、裏にあるとプレビューもアウトラインも追従しなくなっていました。

    scroll イベントの中で直接更新するように変えています。

    追加の修正:Dockに置くなら最後のウィンドウを閉じても終了させない

    Dock に置いて使うなら、Sublime と同じ挙動にしたいところです。

    Tauri は、最後のウィンドウを閉じるとアプリごと終了します。

    次のように変えました。

    これで、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 ありました。

    結果

    実機で次の動作を確認しています。

    Sublime の手癖のまま書けて、プレビューも横に出る。

    欲しかったものがそのまま手に入りました。

    学び:Tauriのハマりどころは「WebViewの外側」に集まる

    振り返ると、エディタ部分で苦労したところはほとんどありません。

    CodeMirror 6 が、マルチカーソルや言語別ハイライトを最初から持っていたからです。

    時間を使ったのは、メニュー・終了処理・Finder からの起動・Dock の挙動といった、macOS アプリとしての振る舞い の方でした。

    もう1つは、WebView が止まる場面です。

    裏に隠れたウィンドウや別の Space のウィンドウでは、rAF も JS も期待どおりに動きません。

    状態を Rust 側に持たせておくと、こうした場面でも安心できます。

    まとめ:自分専用エディタはTauri v2 + CodeMirror 6で十分作れる

    Tauri v2 + CodeMirror 6 でMarkdownエディタを作るときのハマりどころ

    ①コピー&ペーストにはネイティブの編集メニューが要る。Undoは載せずCodeMirrorの履歴に任せる

    ②既定の終了メニューは未保存確認を飛ばす。自前のQuitで各ウィンドウをclose()する

    ③Finderから開いたファイルはRunEvent::Openedで届く。フロントの準備ができるまでRust側で預かる

    ④裏に隠れたウィンドウではrAFが止まる。スクロール追従はscrollイベントで直接更新する

    ⑤最後のウィンドウで終了させないなら、ExitRequestedでprevent_exit、Reopenでウィンドウを作り直す

    Electron を使わなくても、約4MB で毎日使えるエディタになりました。

    自分の手癖に合わせた道具を持つと、書くこと自体が少し楽しくなります。

    同じように Tauri で macOS アプリを作ろうとしている人の参考になれば嬉しいです。

    オススメ記事
    検証環境