タイプライタースクロール
ViewPlugin と requestAnimationFrame、トレランスゾーンを使って、タイピング中にカーソル行をビューポートの中央に保つ方法。
タイプライタースクロール
タイプライタースクロールは、iA Writer や Obsidian のような執筆向けエディターと同様に、ユーザーがタイピングする際にカーソル行をエディタービューポートの垂直中央に保ちます。このレシピでは、CodeMirror Extension として構築する方法を示します。
仕組み
この Extension は選択範囲とドキュメントの変更をリッスンする ViewPlugin を使用します。カーソルがビューポート中央付近のトレランスゾーンの外に出ると、エディターがスクロールして再度中央に配置します。
import { ViewPlugin, ViewUpdate } from "@codemirror/view";
function typewriterScrolling() {
return ViewPlugin.fromClass(
class {
private pendingFrame = 0;
update(update: ViewUpdate) {
if (!update.selectionSet && !update.docChanged) return;
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
const view = update.view;
const { head } = view.state.selection.main;
this.pendingFrame = requestAnimationFrame(() => {
this.pendingFrame = 0;
const coords = view.coordsAtPos(head);
if (!coords) return;
const editorRect = view.dom.getBoundingClientRect();
const viewportHeight = editorRect.height;
const centerY = editorRect.top + viewportHeight / 2;
const cursorY = coords.top;
// カーソルが中央 1/3 の外にある場合のみスクロール
const tolerance = viewportHeight / 6;
if (Math.abs(cursorY - centerY) < tolerance) return;
view.scrollDOM.scrollTo({
top: view.scrollDOM.scrollTop + (cursorY - centerY),
behavior: "instant",
});
});
}
destroy() {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
}
},
);
}
主要な設計判断
requestAnimationFrame
スクロール調整は update メソッド内で同期的にではなく、requestAnimationFrame 内で実行されます。これによりレイアウトスラッシングを防ぎます — レイアウトプロパティの読み取り(coordsAtPos や getBoundingClientRect など)の後にすぐ書き込み(scrollTo)を同じ同期フロー内で行うと、ブラウザにレイアウトの再計算を複数回強制する可能性があります。
同じフレーム内で複数の更新が到着した場合、前のペンディングフレームはキャンセルされます。最後の更新のスクロール調整のみが実行されます。
トレランスゾーン
トレランスゾーンがないと、エディターはすべてのカーソル移動でスクロールし、ガタつく体験になります。トレランスはビューポートの高さの 1/6 に設定されており、カーソルはビューポートの中央 1/3 内ではスクロールをトリガーせずに移動できます。
+--------------------------+
| | <- 上部 1/3(ここでスクロール発生)
| |
+--------------------------+
| | <- 中央 1/3(スクロールなし)
| カーソルここOK |
+--------------------------+
| | <- 下部 1/3(ここでスクロール発生)
| |
+--------------------------+
トリガー条件
プラグインは2つの条件に応答します。
update.selectionSet— カーソルが移動した(矢印キー、マウスクリック、検索ジャンプ)update.docChanged— ドキュメントが変更された(タイピング、ペースト、アンドゥ)
タイピングはドキュメント変更と選択変更の両方を生成し、クリックは選択のみを変更するため、両方が必要です。
インスタントスクロール
スクロール動作は "smooth" ではなく "instant" に設定されています。スムーススクロールは高速タイピング時にアニメーションの遅れが目立ちます。インスタントスクロールは可視的な遅延なくカーソルを中央に保ちます。
📝 Note
よりソフトな感触を好む場合は "instant" を "smooth" に変更できます。ただし、高速なキー入力は複数のスムーススクロールアニメーションをキューに入れ、ビューポートがカーソルに遅れを取る可能性があることに注意してください。
クリーンアップ
destroy メソッドはプラグインが削除されるときにペンディングのアニメーションフレームをキャンセルします。これにより、エディターが破棄された後に古いコールバックが実行されるのを防ぎます。
destroy() {
if (this.pendingFrame) cancelAnimationFrame(this.pendingFrame);
}
使い方
const extensions = [
// ... other extensions
typewriterScrolling(),
];
💡 Tip
タイプライタースクロールは @codemirror/view の scrollPastEnd() と組み合わせると最適に動作します。これがないと、最後の行の下にコンテンツがないため、最後の行をビューポートの中央にスクロールできません。scrollPastEnd() はドキュメントの後に仮想スペースを追加して、これを可能にします。
トグル可能にする
設定駆動型エディターでは、タイプライタースクロールはエディターを再作成せずにオン/オフできるべきです。一つのアプローチは、Extension を条件付きで含めることです。
const extensions = [
// ... other extensions
...(settings.typewriterScrolling ? [typewriterScrolling()] : []),
];
または、ランタイムでの再設定に Compartment を使用します。