zudo-paper

ElectronアプリでCodeMirror 6エディタを組み込む

Author: Takazudo | 作成: 2026/03/07

概要

個人用のメッセージ作成ヘルパーアプリ(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()を呼ぶ。vimModefontSizeが変わったらエディタを再生成する構成にしている。

マークダウン言語サポート

@codemirror/lang-markdownがマークダウンのシンタックスハイライトを提供する。codeLanguagesオプションに@codemirror/language-datalanguagesを渡すと、マークダウン内のコードブロックで各言語のハイライトが効くようになる。

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モードのキーバインドが他のキーバインドに横取りされると、jkでの移動が効かないといった問題が起きる。

外部からの値更新

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の設計の良いところだろう。