Electron + Next.js + Tailwind CSS で Markdown エディタを作った
TL;DR
- Electron で Markdown のエディタを作った
- Next.js + Tailwind CSS といった Web 技術で作れた
- 完璧ではないものの十分使えるものになった
自分用のエディタなので、「Panda Editor」という名前にしました。
本記事の内容
社内勉強会で「Electron + Next.js + Tailwind CSS でエディタを作った」という題で発表をしました。作ったエディタで解決したかった課題を伝えて実際に動くところ見てもらうデモをしたところ、嬉しいことに好評だったので記事として残すことにしました。
Electron とは HTML、CSS、JS でデスクトップアプリを作れる GitHub 製 OSS です。クロスプラットフォームビルドができる(Windows・Mac・Linux)ところに特徴があり、VSCode や Slack、Figma のアプリも Electron 製です。
本記事では Electron、Next.js、Tailwind CSS の詳細には触れません。今回は各技術の記述は少なめで、作ったアプリケーションの説明がメインです。詳しくはそれぞれの公式ドキュメントをご覧ください。
ちなみに、この記事自体も「Panda Editor」で執筆しています。
ブログ記事の執筆はエディタだけで完結しない
私はブログ執筆のためのベストなエディタにまだ出会っていません。これまで PhpStorm で記事を執筆していましたが、何か物足りないと感じていました。
そこで執筆のプロセスを考え直してみました。すると、PhpStorm で足りないと感じていた理由はブログ執筆という作業がエディタで書くことだけで完結しないからだと気づきました。
例えば、自分の知識の正しさを確認するためにブラウザで検索もします。Markdown でリンクを作成するためにリンク先の URL をコピペします。参照したい内容が PDF にあるときはローカルで該当の PDF を探して開きます。
つまり、記事を執筆する作業には知識の確認や引用というプロセスがあるためエディタだけでは完結しないのです。
書くモードと読むモード
知識の確認、引用プロセスがあることを考えるとエディタ以外のアプリケーションを立ち上げなければなりません。しかし、画面を切り替えると同時に、書くモードから読むモードに頭を切り替えなければなりません。
書くモードと読むモードの比率を考えると以下のようなところでしょうか(書くモード:読むモード)。
- 書くことは、調べることと同時並行(9:1)
- ウェブサイトや PDF を読みながらメモを取る(2:8)
- エディタで書く(10:0)
- ブラウザで記事を読む(0:10)
特にエディタからブラウザ、ブラウザからエディタの移動はモードをフルで切り替えるため意識の負担になります。
このため、書く・読む(調べる)をシームレスに行えるアプリがあると記事執筆が快適にできると考えました。
解決へのアプローチ
前述の課題の解決方法の1つは「調べながら書ける」アプリを作ることです。そこで、デスクトップアプリを Web の技術で作れる Electron を採用しました。Web アプリでも良かったのですが、Electron を使ってみたかったのでそちらにしました。
まず、有名どころのエディタを見直してみました。例えば PhpStorm で Markdown を開いた場合や Qiita や esa のエディタでは、左半分で執筆して右半分で Markdonw をプレビューするという仕様です。
しかし、**右側が必ずしもプレビューである必要はないと考えました。**この考え方を軸にしてエディタの仕様を決めていきました。
エディタの4つのモード
読むことと書くことをシームレスに行うため、エディタに「プレビューモード」「検索モード」「ファイル閲覧モード」「校正モード」という4つのモードを用意しました。
プレビューモード
プレビューモードは、react-markdown を利用して Markdown で記述した文章を HTML で表示するものです。
{/* preview */}
{props.mode === 'preview' && (
<section>
<ReactMarkdown
className="md-preview mt-8 py-2 px-3 h-176 whitespace-pre-wrap overflow-y-auto border-2 border-gray-300"
plugins={[gfm]}
unwrapDisallowed={false}
>
{props.body}
</ReactMarkdown>
</section>
)}
よくある機能です。なお、CSS は Tailwind CSS で記述しています。
検索モード
検索モードは、「書きながら調べる」を実現するための機能です。右上の虫眼鏡のアイコンをクリックすると、Google のトップページを表示します。
iframe では Google のトップページを表示できないため、Electron の BrowserView という機能で実装しています。
ツールバーでページの「進む」「戻る」ができます。また、アクセスしているサイトの URL を表示できるため、URL をコピーして記事に貼り付けることでリンク作成が簡単にできます。
ファイル閲覧モード
ファイル閲覧モードは、「読みながら書く」を実現するための機能です。ローカルに保存した PDF ファイルを読みながらメモを取ることを想定しています。
Electron は chromium ベースなので、Google Chrome で PDF を表示した場合と同じ機能が使えます。
PDF 以外にも.md
, .txt
の拡張子を持つファイルを表示できます。
ローカルファイルを表示する仕組み
自分用のエディタの話をするだけでは味気ないため、裏側のことも少し紹介します。
「ローカルファイルを開く」ボタンを押すとフロントでfileView-mode-open-file
という独自に定義したイベントが発火します。
Electron 側でdialog.showOpenDialog
を実行し、ダイアログを表示します。選択したファイルのパスが取得できるため、ファイルの中身をアプリ上で閲覧できます。
例えば PDF ファイルを選択すると、以下の画像のように表示できます。
コードは以下の通りです。
React
<button
type="button"
onClick={() => global.ipcRenderer.send('fileView-mode-open-file')}
>
ローカルファイルを開く
</button>
Electron
ipcMain.on('fileView-mode-open-file', async (_: IpcMainEvent) => {
// ダイアログを開く
// pdf, md, txt しか選択できないようにしている
const { filePaths } = await dialog.showOpenDialog({
filters: [{ name: 'Research', extensions: ['pdf', 'md', 'txt'] }],
properties: ['openFile'],
})
if (filePaths.length === 0) {
return
}
const filename = filePaths[0]
const view = mainWindow.getBrowserView()
// BrowserView で選択肢たファイルを表示する
view.webContents.loadURL(`file://${filename}`)
})
React は renderer プロセス、Electron は main プロセスを刺しますが、説明は省略します。
校正モード
@azu さんの textlint で文章のチェックをします。このモードではリントのルールでエラーになった行とエラー内容を表示します。
今までは記事を書き上げた後に textlint を適用し、エラーに従って文章が冗長だったりおかしいところを修正していました。
しかし、1つの記事を書き上げた後、最後にリントを適用すると修正するべき箇所が多いため面倒でした。それならリアルタイムでリントをかければいいと考えました。
また、lint の fix 機能を使っているため、「修正する」ボタンをクリックすると自動修正できるエラーであれば一発で修正可能です。
「Panda Editor」では日本語向けのルールの preset を使っています。ただ、技術ブログ執筆に最適な技術記事用のルールもあるので、こちらも導入予定です。
textlint でエラーが出る文章例
例えば、以下のような文はエラーと判定されます。
私は、リントに引っかかる文章を書くことができる
textlint-rule-no-mix-dearu-desumasuというルールのエラーです。エラーメッセージは152行目: 本文: "である"調 と "ですます"調 が混在 => "ですます"調 の文体に、次の "である"調 の箇所があります: "である。" Total: である : 1 ですます: 34
です。
この後は雨は降る
textlint-rule-no-doubled-joshiというルールのエラーです。エラーメッセージは157行目: 一文に二回以上利用されている助詞 "は" がみつかりました。
です。
1つ目の文は「私は、リントに引っかかる文章を書けます」、2つ目は「この後は雨が降る」のように修正するとエラーが消えます。
なお、簡便のためエラーが出たままでもファイルを保存できるようにしています。
リント実行の実装の一部
Electron の main プロセスで以下のように textlint を実行しています。
textlint-execute
は文字を入力するたびに、textlint-fix-execute
は「修正する」ボタンを押すと実行されます。
// 文字を入力するたびにリントを実行
ipcMain.handle('textlint-execute', (_: IpcMainInvokeEvent, markdown: string) => {
return lintEngine.executeOnText(markdown, '.md').then((results) => results[0].messages)
})
// 「修正する」ボタンを押すと、エラーを修正する
ipcMain.handle('textlint-fix-execute', (_: IpcMainInvokeEvent, markdown: string) => {
return lintFixEngine.executeOnText(markdown, '.md').then((results) => results[0].output)
})
このリントの実行結果をフロントで受け取り、画面右半分に表示しています。
エディタの機能
左半分のエディタ自体の機能は以下のようなものです。
- 行数を表示
- ローカルにファイルを保存
~/tmp
ディレクトリにdraft.md
という名前で保存される
- Cmd + S で保存、2分ごとに自動保存
- 「コピーする」ボタンで文章の全文をクリップボードにコピーする
今はシンプルなものなので、「タブでインデントする」「エディタとプレビュー画面のスクロールを同期」などの便利な機能は未実装です。改良の余地はまだまだあります。
開発者から見た Electron
「Electron を使うと Web の技術でデスクトップアプリが作れる」とは、renderer を Web の技術スタック(HTML + CSS + JS)で実装可能という意味でした。
このため、フロントに Next.js をあえて選ぶ必要はありません。Vanilla JS でも Vue.js でも OK です。なお、Next.js の getServerSideProps や API Routes といったサーバーを利用した機能は使えません。
その他、Electron を触ってみて学んだことを列挙します。
- Electron は、Chromium + Node.js + Custom API(OSのネイティブ関数を扱う)で構成されている
- main プロセスと renderer プロセスがある
- main プロセスがアプリを立ち上げ、renderer プロセスが Web サイトとして表示する
- main プロセスと renderer プロセスはプロセス間通信をする
- 「プロセス間通信は、ipcMain と ipcRenderer の IPC (Inter-Process Communication) モジュールを介して行うことができます」
- 最適化は骨が折れそう(パフォーマンス戦略)
- 「Panda Editor」は368MB。もっと多機能な Slack は 194MB
詳しくはDocmentを参照。
Electron に興味を持たれた方は、ぜひ公式のデモアプリ(Electron API Demo)をインストールして動かしてみてください。
まとめ
スピード重視で作ったので、2週間ちょっとで作れました。そろそろリファクタリングの時期です。
プログラマの良いところは自分でソフトウェアを作れることですね。ソフトウェアを実際に使い、不満があったらコードを書き換え、ソフトウェアの挙動を変える。それは、課題解決のために道具を自分で作り替えることに他なりません。
私が今まで作った個人開発のアプリケーションの中では、誰かにしっかり使ってもらえそうだという手応えがあります。ただ、機能が万全ではないので公開したり配布するつもりは今のところありません。
もう少し作り込んでサブスクリプションとして販売すると売上は立ちそうな気がしますが、商用にすることも特に考えていないです。
また個人開発で面白いものが作れたら記事に残していきます。
参考
faao (textlint 作者の @azu さんの OSS。実装を参考にしました。設計がとても綺麗)
Happy Coding 🎉