Extension
CodeMirror 6 の Extension システム: Facet、StateField、ViewPlugin、Compartment、優先度、組み込み Extension。
Extension
CodeMirror 6 には組み込みの動作がほとんどありません。行番号、シンタックスハイライト、キーバインディング、undo 履歴など、これらはすべて Extension です。Extension システムは、エディタを設定・カスタマイズするための主要な方法です。
Extension とは
Extension は EditorState.create() の extensions 配列に渡す値です。以下のいずれかになります:
- Facet の値(
facet.of(value)で作成) - StateField(
StateField.define()で作成) - ViewPlugin(
ViewPlugin.define()またはViewPlugin.fromClass()で作成) - 他の Extension の配列(ネストされた配列はフラットに展開される)
- Compartment ラッパー(
compartment.of(extension)で作成)
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
let view = new EditorView({
extensions: [
lineNumbers(),
keymap.of(defaultKeymap),
EditorView.lineWrapping,
EditorState.tabSize.of(2),
],
parent: document.body,
});
Extension の配列
Extension はフラットな配列で合成されます。ネストが許可されており、CodeMirror がすべての配列をフラットに展開します:
let editorSetup = [lineNumbers(), EditorView.lineWrapping];
let keybindings = [keymap.of(defaultKeymap)];
// These are equivalent:
let extensions = [editorSetup, keybindings];
// Flattens to: [lineNumbers(), EditorView.lineWrapping, keymap.of(defaultKeymap)]
これにより、再利用可能な Extension バンドルを配列として自然に作成できます。
Compartment
Compartment を使用すると、エディタの作成後に Extension を再設定できます。Extension を Compartment でラップし、後で Effect を dispatch して置き換えます。
import { Compartment } from "@codemirror/state";
let language = new Compartment();
let tabSize = new Compartment();
let view = new EditorView({
extensions: [
language.of([]),
tabSize.of(EditorState.tabSize.of(4)),
],
parent: document.body,
});
Compartment の reconfigure Effect を dispatch して再設定します:
// Change tab size to 2
view.dispatch({
effects: tabSize.reconfigure(EditorState.tabSize.of(2)),
});
// Load a language
import { javascript } from "@codemirror/lang-javascript";
view.dispatch({
effects: language.reconfigure(javascript()),
});
// Remove the language (pass empty extension)
view.dispatch({
effects: language.reconfigure([]),
});
compartment.get() で Compartment の現在の内容を読み取ることができます:
let currentLang = language.get(view.state);
💡 Tip
Compartment は、Extension のオン/オフの切り替えや設定の動的な変更を行うための意図された方法です。設定を変更するためにエディタ全体を再作成しないでください。
Facet
Facet は、複数のソースから値を収集し、単一の出力に結合する名前付きの拡張ポイントです。Facet は Facet.define() で定義します。
組み込みの Facet
CodeMirror にはいくつかの組み込み Facet があります:
EditorState.tabSize— タブ文字の幅(デフォルト: 4)EditorState.readOnly— エディタが読み取り専用かどうかEditorState.phrases— 翻訳文字列EditorState.languageData— 言語固有の設定EditorView.contentAttributes— コンテンツ要素の HTML 属性EditorView.editorAttributes— エディタラッパーの HTML 属性EditorView.decorations— Extension が提供する DecorationEditorView.updateListener— 状態更新のコールバック
Facet の使い方
.of() で Facet に値を提供します:
let ext = EditorState.tabSize.of(2);
state.facet() で Facet の値を読み取ります:
let size = state.facet(EditorState.tabSize); // 2
カスタム Facet の定義
import { Facet } from "@codemirror/state";
// A facet that combines values by taking the first one
let maxLineLength = Facet.define({
combine: (values) => (values.length ? Math.min(...values) : 80),
});
// Provide a value
let ext = maxLineLength.of(120);
// Read the combined value
let max = state.facet(maxLineLength);
combine 関数は提供されたすべての値を受け取り、単一の結果を返します。一般的な結合戦略:
- 最初の値を採用する(単一値の設定の場合)
- 配列に収集する(複数値の設定の場合)
- マージまたはリデュースする(集約された設定の場合)
計算された Facet
Facet は状態の他の部分から値を導出できます:
let lineCount = Facet.define({
combine: (values) => values[0],
});
let computedLineCount = lineCount.compute(["doc"], (state) => {
return state.doc.lines;
});
StateField
StateField は各 Transaction で更新される値を保持します。更新をまたいでカスタムの状態を維持するための主要な方法です。
import { StateField } from "@codemirror/state";
let wordCount = StateField.define({
create(state) {
return countWords(state.doc.toString());
},
update(value, tr) {
if (tr.docChanged) {
return countWords(tr.state.doc.toString());
}
return value;
},
});
function countWords(text) {
return text.split(/\s+/).filter(Boolean).length;
}
StateField は provide オプションを通じて Extension を提供することもできます:
let myField = StateField.define({
create() {
return Decoration.none;
},
update(decos, tr) {
// update decorations...
return decos;
},
provide: (field) => EditorView.decorations.from(field),
});
ViewPlugin
ViewPlugin は View の更新に応じてコードを実行し、DOM にアクセスできます。StateField では処理できない副作用が必要な場合に使用します。
import { ViewPlugin, ViewUpdate } from "@codemirror/view";
let charCounter = ViewPlugin.fromClass(
class {
dom;
constructor(view) {
this.dom = document.createElement("div");
this.dom.className = "char-count";
this.dom.textContent = `${view.state.doc.length} chars`;
view.dom.appendChild(this.dom);
}
update(update) {
if (update.docChanged) {
this.dom.textContent = `${update.state.doc.length} chars`;
}
}
destroy() {
this.dom.remove();
}
}
);
ViewPlugin は spec を通じて Decoration、イベントハンドラ、その他の View レベルの値を提供することもできます:
let myPlugin = ViewPlugin.fromClass(MyPluginClass, {
decorations: (plugin) => plugin.decorations,
eventHandlers: {
click(event, view) {
// handle click
return false;
},
},
});
Extension の優先度
複数の Extension が同じ Facet やキーマップに値を提供する場合、順序が重要です。デフォルトでは、先にリストされた Extension が優先されます。Prec を使用してこれをオーバーライドできます:
import { Prec } from "@codemirror/state";
import { keymap } from "@codemirror/view";
let myKeymap = keymap.of([
{ key: "Ctrl-s", run: () => { console.log("save"); return true; } },
]);
// Ensure this keymap takes priority over others
let highPriority = Prec.high(myKeymap);
// Or guarantee it runs first
let highest = Prec.highest(myKeymap);
優先度レベルは高い順に:
Prec.highestPrec.high- (デフォルト — ラッパーなし)
Prec.lowPrec.lowest
組み込み Extension
CodeMirror はパッケージ全体にわたって多くの Extension を提供しています:
@codemirror/view から:
lineNumbers()— 行番号のガターhighlightActiveLine()— カーソルのある行をハイライトhighlightActiveLineGutter()— アクティブな行のガターをハイライトhighlightSpecialChars()— 制御文字を可視的にレンダリングdrawSelection()— カスタムの選択描画(複数選択に必要)dropCursor()— ドラッグ&ドロップ中にカーソル位置を表示rectangularSelection()— Alt+ドラッグによる矩形選択crosshairCursor()— Alt+ドラッグ中の十字カーソルEditorView.lineWrapping— ソフト行折り返しを有効化placeholder(text)— エディタが空の場合のプレースホルダーテキスト
@codemirror/commands から:
history()— undo/redo サポートdefaultKeymap— 標準的な編集キーバインディングhistoryKeymap— Ctrl-Z / Ctrl-Y キーバインディング
@codemirror/search から:
search()— 検索と置換パネルsearchKeymap— Ctrl-F / Ctrl-H キーバインディングhighlightSelectionMatches()— 選択範囲に一致するテキストをハイライト
@codemirror/autocomplete から:
autocompletion()— オートコンプリートのポップアップcloseBrackets()— 括弧と引用符の自動閉じcloseBracketsKeymap— 関連するキーバインディング
@codemirror/language から:
bracketMatching()— 対応する括弧のハイライトfoldGutter()— コード折りたたみのガターindentOnInput()— 特定の文字の入力後に再インデントsyntaxHighlighting()— 構文木にハイライトスタイルを適用
@codemirror/lint から:
linter()— Lint 関数を実行して診断を表示lintGutter()— ガターに Lint マーカーを表示
basicSetup と minimalSetup
codemirror パッケージは 2 つの便利なバンドルをエクスポートしています:
basicSetup
ほとんどのユースケースに適した包括的な Extension セット:
import { basicSetup } from "codemirror";
import { EditorView } from "@codemirror/view";
let view = new EditorView({
extensions: [basicSetup],
parent: document.body,
});
basicSetup には、行番号、アクティブ行のハイライト、履歴、括弧のマッチング、オートコンプリート、検索、その他多くの一般的な Extension が含まれています。
minimalSetup
軽量なエディタ向けのより小さなセット:
import { minimalSetup } from "codemirror";
import { EditorView } from "@codemirror/view";
let view = new EditorView({
extensions: [minimalSetup],
parent: document.body,
});
minimalSetup には、デフォルトキーマップ、履歴、選択描画、ドロップカーソル、特殊文字のハイライト、undo/redo、括弧のマッチングのみが含まれています。
📝 Note
どちらのバンドルも単なる Extension の配列です。独自の配列にスプレッドして、特定の Extension を追加またはオーバーライドできます。
import { basicSetup } from "codemirror";
import { EditorView } from "@codemirror/view";
let view = new EditorView({
extensions: [
basicSetup,
EditorView.lineWrapping,
EditorState.tabSize.of(2),
// Additional extensions...
],
parent: document.body,
});
例: カスタム Extension の作成
以下は、ドキュメントが初期コンテンツから変更されたかどうかを追跡する StateField と、DOM にインジケーターを表示する ViewPlugin を組み合わせた例です:
import { StateField, StateEffect } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
// Effect to mark the document as "saved"
let markSaved = StateEffect.define();
// State field tracking dirty status
let dirtyField = StateField.define({
create() {
return false;
},
update(isDirty, tr) {
for (let effect of tr.effects) {
if (effect.is(markSaved)) return false;
}
if (tr.docChanged) return true;
return isDirty;
},
});
// View plugin showing dirty indicator
let dirtyIndicator = ViewPlugin.fromClass(
class {
dom;
constructor(view) {
this.dom = document.createElement("div");
this.dom.className = "dirty-indicator";
this.updateDisplay(view.state.field(dirtyField));
view.dom.parentNode?.insertBefore(this.dom, view.dom);
}
update(update) {
let dirty = update.state.field(dirtyField);
if (dirty !== update.startState.field(dirtyField)) {
this.updateDisplay(dirty);
}
}
updateDisplay(isDirty) {
this.dom.textContent = isDirty ? "Unsaved changes" : "Saved";
}
destroy() {
this.dom.remove();
}
}
);
// Bundle into a single extension
function dirtyTracking() {
return [dirtyField, dirtyIndicator];
}
使い方:
let view = new EditorView({
extensions: [basicSetup, dirtyTracking()],
parent: document.body,
});
// Later, mark as saved:
view.dispatch({
effects: markSaved.of(null),
});