zudo-codemirror-wisdom

Type to search...

to open search from anywhere

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

作成2026年4月3日Takeshi Takatsudo

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;

        // コンテンツがスクロールしない場合は非表示
        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)",

Revision History