zudo-codemirror-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

スクロールバーのカーソルインジケーター

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 * trackHeight

lineBlockAt(head) はカーソル位置の視覚的な行ブロックを返し、コンテンツ上端からのピクセルオフセットを提供します。contentHeight で割ることで、スクロールバートラックにマッピングされる 0-1 の比率が得られます。

Note

この Extension は scrollHeightscrollPastEnd() のパディングを含む場合がある)ではなく、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: absoluteview.dom 内に配置されます。どちらもエディターの右端を対象としているため、視覚的に重なります。どちらが上に表示されるかを制御する必要がある場合は、それぞれに異なる z-index(または CSS カスタムプロパティ)を設定してください。

Revision History

Takeshi Takatsudo作成: 2026-04-03T22:32:58+09:00更新: 2026-05-29T06:25:22+09:00