概要
個人用のメッセージ作成ヘルパーアプリ(Electron + Vite + React + TypeScript構成)を開発する中で、マークダウン編集機能にCodeMirror 6を採用した。CodeMirror 6は前バージョンから完全に書き直されたモダンなエディタフレームワークで、Extensionsベースのアーキテクチャを持っている。セットアップから実用的な設定までのまとめ。
CodeMirror 6はCodeMirror 5とは別物
まず前提として、CodeMirror 6はCodeMirror 5とは完全に別のライブラリ。APIに互換性はなく、移行というよりは新規導入として扱う必要がある。npmパッケージもcodemirror(v6)とcodemirror5のように分かれている。
今回使用したパッケージは以下。
{
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language-data": "^6.5.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.39.16",
"@replit/codemirror-vim": "^6.3.0"
}パッケージが多いが、CodeMirror 6は機能ごとにパッケージが分離されている設計なのでこうなる。
CodeMirror 6の基本概念
CodeMirror 6のアーキテクチャは以下の要素で構成されている。
- EditorState — イミュータブルな状態オブジェクト。ドキュメント内容、選択状態、拡張機能を保持する
- EditorView — DOMにマウントされるビュー。StateからUIをレンダリングする
- Extensions — 機能を追加するプラグインシステム。配列で渡し、順序が意味を持つ
- Transactions — 状態変更はトランザクション経由で行い、
dispatch()で適用する
従来のエディタライブラリが「巨大な1つのオブジェクトに設定をたくさん渡す」スタイルだったのに対し、CodeMirror 6はExtensionsの組み合わせで機能を構成する。basicSetupという便利なプリセットがあり、行番号、折りたたみ、ブラケットマッチングなど基本的な機能がまとめて入っている。
React + TypeScriptでの基本セットアップ
Reactとの統合ではuseRefでEditorViewを保持し、useEffectでライフサイクルを管理する。以下が基本的なコンポーネントパターン。
import { useEffect, useRef } from "react";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";
import { markdown } from "@codemirror/lang-markdown";
import { languages } from "@codemirror/language-data";
import { oneDark } from "@codemirror/theme-one-dark";
import { vim } from "@replit/codemirror-vim";
import { search } from "@codemirror/search";
interface EditorProps {
value: string;
onChange: (value: string) => void;
vimMode?: boolean;
fontSize?: number;
}
function MarkdownEditor({
value,
onChange,
vimMode = false,
fontSize = 14,
}: EditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const extensions = [
basicSetup,
markdown({ codeLanguages: languages }),
oneDark,
search(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}),
EditorView.theme({
"&": { fontSize: `${fontSize}px` },
".cm-content": { fontFamily: "'JetBrains Mono', monospace" },
}),
];
if (vimMode) {
extensions.unshift(vim());
}
const state = EditorState.create({
doc: value,
extensions,
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
};
}, [vimMode, fontSize]);
return <div ref={containerRef} />;
}EditorState.create()で状態を作り、EditorViewでDOMにマウントする。クリーンアップではview.destroy()を呼ぶ。vimModeやfontSizeが変わったらエディタを再生成する構成にしている。
マークダウン言語サポート
@codemirror/lang-markdownがマークダウンのシンタックスハイライトを提供する。codeLanguagesオプションに@codemirror/language-dataのlanguagesを渡すと、マークダウン内のコードブロックで各言語のハイライトが効くようになる。
markdown({ codeLanguages: languages });@codemirror/language-dataは多数の言語モードを動的importで提供しているので、使われた言語だけがロードされる仕組み。全言語を最初からバンドルに含めるわけではない。
テーマのカスタマイズ
@codemirror/theme-one-darkを使えばOne Darkテーマがそのまま適用できる。カスタムテーマを作りたい場合はEditorView.theme()でCSSプロパティを指定する。
const customTheme = EditorView.theme({
"&": {
backgroundColor: "#1e1e2e",
color: "#cdd6f4",
},
".cm-content": {
caretColor: "#f5e0dc",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
},
".cm-cursor": {
borderLeftColor: "#f5e0dc",
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": {
backgroundColor: "#45475a",
},
".cm-gutters": {
backgroundColor: "#181825",
color: "#6c7086",
borderRight: "1px solid #313244",
},
});CSSファイルではなくJSオブジェクトでスタイルを定義する方式。CodeMirror 6のテーマシステムはExtensionとして動作するので、テーマの切り替えもExtensionsの再構成で行える。
Vimモードの追加
@replit/codemirror-vimがCodeMirror 6向けのVimモード実装として最も実用的な選択肢。Replitが開発・メンテナンスしている。
import { vim } from "@replit/codemirror-vim";
// extensionsの配列に追加
extensions.unshift(vim());vim()はextensions配列の先頭に置く必要がある。他のキーバインドより先にVimのキーバインドを処理させるため。push()ではなくunshift()を使う。
if (vimMode) {
extensions.unshift(vim());
}Extensionsは配列の先頭にあるものが優先度が高い。Vimモードのキーバインドが他のキーバインドに横取りされると、jやkでの移動が効かないといった問題が起きる。
外部からの値更新
Reactの親コンポーネントから値を更新する場合、Reactのstateとは別にEditorViewのdispatchで内容を同期する必要がある。
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentDoc = view.state.doc.toString();
if (currentDoc !== value) {
view.dispatch({
changes: {
from: 0,
to: currentDoc.length,
insert: value,
},
});
}
}, [value]);currentDoc !== valueのチェックがないと、onChange → 親のstate更新 → value prop変更 → dispatch → onChange...という無限ループになる。
Viteでのバンドル最適化
CodeMirrorは@codemirror/language-dataで全言語モードを含めると1.7MBほどになる。Electronアプリではネットワークコストはないのでバンドルサイズ自体は問題にならないが、manualChunksで分割しておくとキャッシュ効率が上がる。
// vite.config.ts
export default defineConfig({
build: {
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules")) {
if (
id.includes("@codemirror") ||
id.includes("@replit/codemirror-vim") ||
id.includes("@lezer/")
) {
return "codemirror";
}
}
},
},
},
},
});ここで@lezer/パッケージを含めているのが地味に大事な点。
@lezer/をCodeMirrorから分離するとビルドが壊れる
LezerはCodeMirrorのパーサーシステムで、@lezer/commonや@lezer/highlightといったパッケージがCodeMirrorのコア依存として使われている。
当初、@lezer/を別チャンクに分離しようとしたところ、ビルドは通るが実行時に以下のエラーが出た。
Cannot access 'F' before initializationこれはRollupがチャンク間の循環依存を解決できず、変数の初期化順序が壊れることで発生する。@codemirror/*と@lezer/*は内部的に相互参照しているため、別チャンクに分割すると初期化のタイミングがずれる。
対策は単純で、@lezer/パッケージをCodeMirrorと同じチャンクにまとめること。上記のmanualChunks設定で両方を"codemirror"チャンクに入れているのはそのため。
まとめ
CodeMirror 6はExtensionsベースの設計により、機能の追加・削除が柔軟にできる。初期セットアップのボイラープレートはやや多いが、一度コンポーネントを作ってしまえば各機能はExtensionとしてon/offできる。
Electronアプリでの利用においては、バンドルサイズは気にしなくて良い代わりに、ViteのmanualChunksでチャンク分割する際の循環依存に注意が必要。@lezer/をCodeMirrorから分離しないこと。
Vimモードは@replit/codemirror-vim一択で、extensions配列の先頭にunshift()で入れる。検索機能も@codemirror/searchをextensionsに追加するだけで使える。このあたりの拡張性がCodeMirror 6の設計の良いところだろう。