スクロールバーのカーソルインジケーター
ViewPlugin と DOM 操作を使用して、カーソル位置をスクロールバートラック上の細い水平線として表示する方法。
スクロールバーのカーソルインジケーター
このレシピでは、VS Code のミニマップカーソルインジケーターのように、カーソルの位置をスクロールバートラック上の細い水平線として表示する CodeMirror Extension を構築します。長いドキュメントでの現在位置を素早く視覚的に確認できます。
概要
この Extension は、エディターの DOM 内に絶対位置で配置された小さな <div> 要素を作成します。この要素は、ドキュメント内のカーソルの相対位置に基づいて垂直方向に配置されます。選択変更、ドキュメント変更、ジオメトリ変更時に更新されます。
実装
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
const SCROLLBAR_WIDTH = "14px";
function scrollbarCursorIndicator() {
return ViewPlugin.fromClass(
class {
private indicator: HTMLDivElement;
private pendingFrame = 0;
private focused: boolean;
private readonly onFocus: () => void;
private readonly onBlur: () => void;
constructor(private view: EditorView) {
this.indicator = document.createElement("div");
Object.assign(this.indicator.style, {
position: "absolute",
right: "0",
width: SCROLLBAR_WIDTH,
height: "2px",
pointerEvents: "none",
zIndex: "10",
background: "var(--palette-cursor)",
});
view.dom.appendChild(this.indicator);
this.focused = view.hasFocus;
this.updatePosition();
this.onFocus = () => {
this.focused = true;
this.updatePosition();
};
this.onBlur = () => {
this.focused = false;
this.indicator.style.display = "none";
};
view.dom.addEventListener("focusin", this.onFocus);
view.dom.addEventListener("focusout", this.onBlur);
}
update(update: ViewUpdate) {
if (
update.selectionSet ||
update.docChanged ||
update.geometryChanged
) {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
this.pendingFrame = requestAnimationFrame(() => {
this.pendingFrame = 0;
this.updatePosition();
});
}
}
private updatePosition() {
if (!this.focused) {
this.indicator.style.display = "none";
return;
}
const { scrollDOM, state } = this.view;
const { scrollHeight, clientHeight } = scrollDOM;
// Hide when content doesn't scroll
if (scrollHeight <= clientHeight) {
this.indicator.style.display = "none";
return;
}
this.indicator.style.display = "";
const head = state.selection.main.head;
const block = this.view.lineBlockAt(head);
const contentH = this.view.contentHeight;
const ratio = contentH > 0 ? block.top / contentH : 0;
const scrollerTop = scrollDOM.offsetTop;
const trackHeight = clientHeight;
this.indicator.style.top = `${scrollerTop + ratio * trackHeight}px`;
}
destroy() {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
this.view.dom.removeEventListener("focusin", this.onFocus);
this.view.dom.removeEventListener("focusout", this.onBlur);
this.indicator.remove();
}
},
);
}主要な設計判断
位置の計算
インジケーターの垂直位置は、カーソルの行位置とコンテンツ全体の高さの比率として計算されます。
ratio = lineBlock. top / contentHeight
indicatorTop = scrollerTop + ratio * trackHeightlineBlockAt(head) はカーソル位置の視覚的な行ブロックを返し、コンテンツ上端からのピクセルオフセットを提供します。contentHeight で割ることで、スクロールバートラックにマッピングされる 0-1 の比率が得られます。
Note
この Extension は scrollHeight(scrollPastEnd() のパディングを含む場合がある)ではなく、contentHeight(実際のドキュメントコンテンツの高さ)を使用します。これにより、エディターが最後の行の後に追加のスクロール可能なスペースを持つ場合に、インジケーターが不自然に低い位置に押し下げられるのを防ぎます。
フォーカス対応の可視性
インジケーターはエディターがフォーカスを持っている場合のみ表示されます。分割ペインエディターでは、複数の CodeMirror インスタンスが同時に表示される場合があります。フォーカス対応がないと、各ペインが独自のインジケーターを表示し、視覚的な混乱を引き起こします。
this.onFocus = () => {
this.focused = true;
this.updatePosition();
};
this.onBlur = () => {
this.focused = false;
this.indicator.style.display = "none";
};プラグインは true をデフォルトにするのではなく、view.hasFocus から this.focused を初期化します。これにより、フォーカスを受け取らずに作成されたエディター(新しく開いた分割ペインなど)のケースを処理します。
DOM アプローチ vs デコレーション
この Extension は CodeMirror のデコレーションではなく、直接の DOM 操作(view.dom への <div> の追加)を使用します。これは、インジケーターがドキュメントコンテンツではなくスクロールバーに対して相対的に配置されるためです。デコレーションはドキュメント範囲にアノテーションを付けるために設計されており、スクロールバートラック上のオーバーレイには使用できません。
requestAnimationFrame
位置の更新は requestAnimationFrame でバッチ処理され、複数のイベントが短時間に連続して発火した場合(高速タイピングなど)のレイアウトスラッシングを回避します。
クリーンアップ
destroy メソッドはインジケーター要素を削除し、ペンディングのアニメーションフレームをキャンセルし、イベントリスナーを削除します。
destroy() {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
this.view.dom.removeEventListener("focusin", this.onFocus);
this.view.dom.removeEventListener("focusout", this.onBlur);
this.indicator.remove();
}Warning
destroy メソッドで必ずイベントリスナーを削除してください。クリーンアップしないと、Extension が再設定されたりエディターが再作成されたりした場合にリスナーが蓄積されます。
使い方
const extensions = [
// ... other extensions
scrollbarCursorIndicator(),
];インジケーターの色を変更するには、CSS カスタムプロパティを設定します。
.my-editor {
--palette-cursor: #528bff;
}または、インジケータースタイルの background 値を固定色に変更します。
background: "rgba(82, 139, 255, 0.8)",スクロールバーの検索マッチマーカー
関連するテクニックとして、スクロールバートラックに検索マッチごとに1つのティックを描画する方法があります。VS Code で検索が有効なときに見られる「マッチのミニマップ」です。このプラグインは別の Extension の StateField の純粋なコンシューマーです。状態を自分では持たず、フィールドから位置を読み取って DOM ノードを再描画するだけです。
カスタムハイライトレイヤーで searchHighlightField を公開している詳細については Search & Replace を参照してください。
別の Extension のフィールドから位置を読み取る
view.state.field(field, false) は省略可能なフィールドを安全に読み取る方法です。第2引数に false を渡すと、フィールドがマウントされていない場合(テスト中や検索 Extension が含まれていない場合など)にスローする代わりに undefined を返します。
const set = view.state.field(searchHighlightField, false);
if (!set) return []; // field not mounted — nothing to paintこれにより、スクロールバーマーカープラグインは検索 Extension の存在に対してハードな依存を持たずに単独で動作できます。
1行に複数のマッチを1つのティックに集約する
ドキュメントの1行には多数の検索ヒットが含まれる場合があります。それぞれに個別のティックを描画するとスクロールバーが煩雑になります。集約の戦略では lineBlockAt(from).top を一意なキーとして使用します。同じ視覚的行に2つのマッチが落ちると同じ top ピクセルを共有するため、1つのマーカーに集約されます。
interface MarkerPos {
top: number;
active: boolean;
}
function collectMarkerPositions(view: EditorView): MarkerPos[] {
const set = view.state.field(searchHighlightField, false);
if (!set) return [];
const seen = new Map<number, MarkerPos>();
const docLength = view.state.doc.length;
const cursor = set.iter();
while (cursor.value !== null) {
const from = cursor.from;
// Decoration ranges past the live document length would throw
// inside lineBlockAt. The StateField maps ranges through tr.changes,
// so this is a defensive guard for stale positions only.
if (from < 0 || from > docLength) {
cursor.next();
continue;
}
const block = view.lineBlockAt(from);
const top = block.top; // pixel offset from content top — unique per visual line
const spec = cursor.value.spec as { class?: string };
const active = spec.class === "find-match-active";
const existing = seen.get(top);
if (existing) {
// Promote to active if any range on this line is the active hit.
if (active) existing.active = true;
} else {
seen.set(top, { top, active });
}
cursor.next();
}
return Array.from(seen.values());
}基本とアクティブの2段階システムにより、集約後も通常の検索ヒットと現在フォーカスされているヒットの区別が保持されます。
再描画トリガーと viewportChanged を除外する理由
update メソッドが再描画をスケジュールするタイミングを決定します。
update(update: ViewUpdate) {
const prevField = update.startState.field(searchHighlightField, false);
const nextField = update.state.field(searchHighlightField, false);
const fieldChanged = prevField !== nextField;
// Repaint on:
// fieldChanged — a new decoration set was dispatched (new search results)
// docChanged — doc edits remap positions through tr.changes,
// shifting block.top even if the field reference is stable
// geometryChanged — window resize or line-wrap toggle shifts block.top
//
// NOT viewportChanged: the markers are absolute on the scrollbar track
// and do not depend on which lines CodeMirror currently virtualizes.
if (fieldChanged || update.docChanged || update.geometryChanged) {
this.scheduleRepaint();
}
}viewportChanged はスクロール時に頻繁に発火します。スクロールバーのマーカー位置は contentHeight に対する絶対的な block.top 値から計算されるため、ユーザーがスクロールしても変化しません。viewportChanged で再描画すると、スクロールイベントごとに不要な処理が発生します。
完全なプラグイン
import { type Extension } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { searchHighlightField } from "./search-highlight"; // your field
const SCROLLBAR_WIDTH = "14px";
export function searchScrollbarMarkersExtension(): Extension {
return ViewPlugin.fromClass(
class {
private container: HTMLDivElement;
private pendingFrame = 0;
constructor(private view: EditorView) {
this.container = document.createElement("div");
Object.assign(this.container.style, {
position: "absolute",
right: "0",
top: "0",
width: SCROLLBAR_WIDTH,
height: "100%",
pointerEvents: "none",
zIndex: "10",
});
view.dom.appendChild(this.container);
this.scheduleRepaint();
}
update(update: ViewUpdate) {
const prevField = update.startState.field(searchHighlightField, false);
const nextField = update.state.field(searchHighlightField, false);
const fieldChanged = prevField !== nextField;
if (fieldChanged || update.docChanged || update.geometryChanged) {
this.scheduleRepaint();
}
}
private scheduleRepaint(): void {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
this.pendingFrame = requestAnimationFrame(() => {
this.pendingFrame = 0;
this.repaint();
});
}
private repaint(): void {
const { scrollDOM } = this.view;
const { scrollHeight, clientHeight } = scrollDOM;
if (scrollHeight <= clientHeight || clientHeight === 0) {
this.container.replaceChildren();
return;
}
const positions = collectMarkerPositions(this.view);
if (positions.length === 0) {
this.container.replaceChildren();
return;
}
// contentHeight — same trap as the cursor indicator above.
const contentH = this.view.contentHeight;
if (contentH <= 0) {
this.container.replaceChildren();
return;
}
const scrollerTop = scrollDOM.offsetTop;
const trackHeight = clientHeight;
const fragment = document.createDocumentFragment();
for (const pos of positions) {
const ratio = pos.top / contentH;
const clamped = Math.max(0, Math.min(1, ratio));
const top = scrollerTop + clamped * trackHeight;
const marker = document.createElement("div");
marker.className = pos.active
? "cm-search-scrollbar-marker cm-search-scrollbar-marker-active"
: "cm-search-scrollbar-marker";
marker.style.top = `${top}px`;
fragment.appendChild(marker);
}
this.container.replaceChildren(fragment);
}
destroy() {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
this.container.remove();
}
},
);
}マーカーの CSS
ティックは細い絶対位置の div です。アクティブなヒットを区別するために別の色を使用します。
.cm-search-scrollbar-marker {
position: absolute;
right: 0;
width: 14px;
height: 2px;
background: rgba(255, 200, 0, 0.6);
}
.cm-search-scrollbar-marker-active {
background: rgba(255, 140, 0, 0.9);
}Note
カーソルインジケーターとマッチマーカーのコンテナは、どちらも position: absolute で view.dom 内に配置されます。どちらもエディターの右端を対象としているため、視覚的に重なります。どちらが上に表示されるかを制御する必要がある場合は、それぞれに異なる z-index(または CSS カスタムプロパティ)を設定してください。