クリップボード統合
@replit/codemirror-vim で Vim レジスタをシステムクリップボードと同期させる方法。
クリップボード統合
デフォルトでは、@replit/codemirror-vim の Vim レジスタは内部的なもので、ヤンクやペーストの操作はシステムクリップボードとやり取りしません。このページでは、そのギャップを埋める方法を解説します。
課題
ターミナルの Vim では、"+ と "* レジスタがシステムクリップボードと選択バッファに接続されています。しかしブラウザベースのエディターでは、これらのレジスタはシステムとの統合がないメモリ上のオブジェクトに過ぎません。"+y でテキストをヤンクしても内部に保存されるだけで OS のクリップボードにはコピーされず、"+p も内部レジスタからペーストするだけで、他のアプリケーションからコピーした内容は取得できません。
Register Controller API
Vim オブジェクトはすべての名前付きレジスタを管理する Register Controller を公開しています。
import { Vim } from "@replit/codemirror-vim";
const controller = Vim.getRegisterController();
const registers = controller.registers;
各レジスタはテキストの取得と設定のためのメソッドを持つオブジェクトです。任意のレジスタを、システムクリップボードと同期するカスタム実装に置き換えることができます。
クリップボードレジスタの作成
以下の実装は、ヤンクのたびに navigator.clipboard に書き込み、ペースト操作用にテキストをローカルに保持するレジスタオブジェクトを作成します。
function createClipboardRegister() {
const register = {
keyBuffer: [] as string[],
insertModeChanges: [] as never[],
searchQueries: [] as string[],
linewise: false,
blockwise: false,
setText(text?: string, linewise?: boolean, blockwise?: boolean) {
register.keyBuffer = text ? [text] : [];
register.linewise = !!linewise;
register.blockwise = !!blockwise;
if (text) {
navigator.clipboard.writeText(text).catch(console.warn);
}
},
pushText(text: string, linewise?: boolean) {
register.keyBuffer.push(text);
register.linewise = !!linewise;
navigator.clipboard
.writeText(register.keyBuffer.join("\n"))
.catch(console.warn);
},
pushInsertModeChanges(_changes: never) {},
pushSearchQuery(_query: string) {},
clear() {
register.keyBuffer = [];
register.linewise = false;
register.blockwise = false;
},
toString() {
return register.keyBuffer.join("\n");
},
};
return register;
}
クリップボードレジスタの置き換え
* と + レジスタをクリップボード対応の実装に置き換えます。
const controller = Vim.getRegisterController();
const registers = controller.registers;
const clipboardRegister = createClipboardRegister();
registers["*"] = clipboardRegister;
registers["+"] = clipboardRegister;
これにより、"+y と "*y はシステムクリップボードにコピーし、"+p / "*p は内部バッファ(ヤンク時に同期されます)からペーストするようになります。
clipboard=unnamedplus の動作
Vim では set clipboard=unnamedplus によって、デフォルト(無名)レジスタを + レジスタのエイリアスにできます。すべてのヤンクと削除がレジスタプレフィックスなしでシステムクリップボードに反映されます。
これを再現するには、無名レジスタ(")も置き換えます。
registers['"'] = clipboardRegister;
controller.unnamedRegister = clipboardRegister;
これにより、通常の yy でシステムクリップボードにコピーされ、p で最後にヤンクした内容がペーストされます。
フォーカス時のクリップボード同期
上記のアプローチの制限として、エディターの外部でコピーされたテキスト(例: 別のアプリケーション)は p で自動的に利用できません。内部レジスタはエディター内でヤンクされたテキストしか把握していないためです。
外部クリップボードの内容を処理するには、エディターまたはウィンドウがフォーカスを得たときに navigator.clipboard から読み取ります。
標準ブラウザでのアプローチ
標準的なウェブページでは、エディターの focus イベントをリッスンします。
view.dom.addEventListener("focus", async () => {
try {
const text = await navigator.clipboard.readText();
if (text) {
clipboardRegister.keyBuffer = [text];
clipboardRegister.linewise = false;
clipboardRegister.blockwise = false;
}
} catch {
// clipboard read may fail if permission is denied
}
});
Tauri / WKWebView: window.focus を使用する
⚠️ Warning
Tauri アプリケーション(macOS WKWebView を使用)では、エディター要素の focusin イベントハンドラー内で navigator.clipboard.readText() を呼び出すと、ネイティブの macOS「ペースト」編集メニューがトリガーされます。これはテキストカーソル付近にフローティングメニューとして表示されるクリップボード許可プロンプトです。ユーザーがエディターにクリックするたびに表示されるため、ユーザー体験が悪化します。
view.dom.addEventListener("focusin", ...) ではなく window.addEventListener("focus", ...) を使用してください。
window.focus イベントは、アプリケーションウィンドウが別のアプリケーションからフォーカスを取り戻した(例: ユーザーが Cmd+Tab で戻った)ときに発火します。これはシステムクリップボードに外部ソースからの新しいコンテンツが含まれている可能性が最も高い瞬間です。
const syncHandler = async () => {
try {
const text = await navigator.clipboard.readText();
if (text && clipboardRegister.keyBuffer.join("\n") !== text) {
clipboardRegister.keyBuffer = [text];
clipboardRegister.linewise = text.endsWith("\n");
clipboardRegister.blockwise = false;
}
} catch {
// クリップボード権限が拒否 -- 無視
}
};
window.addEventListener("focus", syncHandler);
// エディター破棄時のクリーンアップ
function teardown() {
window.removeEventListener("focus", syncHandler);
}
標準アプローチとの主な違い:
windowイベント、エディター DOM イベントではない — macOS WKWebView のクリップボード許可プロンプトのトリガーを回避- 重複チェック — クリップボードの内容が既に保存されているものと異なる場合のみレジスタを更新(
keyBuffer.join("\n") !== text) - 行単位の検出 — クリップボードテキストが改行で終わる場合
linewise: trueを設定。これによりpが現在の行の下にペーストされ、vim の行単位ペースト動作に一致
📝 Note
navigator.clipboard.readText() にはクリップボード読み取り権限が必要です。一部のブラウザでは、ドキュメントがフォーカスされると自動的に権限が付与されます。その他のブラウザでは、ユーザーに権限プロンプトが表示される場合があります。権限が拒否された場合、catch ブロックがエラーを暗黙的に処理します。
完全な実装
すべてをまとめた実装です。このバージョンはクリップボード同期に window.focus を使用し、標準ブラウザと Tauri/WKWebView 環境の両方と互換性があります。
import { Vim } from "@replit/codemirror-vim";
import { EditorView } from "@codemirror/view";
function createClipboardRegister() {
const register = {
keyBuffer: [] as string[],
insertModeChanges: [] as never[],
searchQueries: [] as string[],
linewise: false,
blockwise: false,
setText(text?: string, linewise?: boolean, blockwise?: boolean) {
register.keyBuffer = text ? [text] : [];
register.linewise = !!linewise;
register.blockwise = !!blockwise;
if (text) {
navigator.clipboard.writeText(text).catch(console.warn);
}
},
pushText(text: string, linewise?: boolean) {
register.keyBuffer.push(text);
register.linewise = !!linewise;
navigator.clipboard
.writeText(register.keyBuffer.join("\n"))
.catch(console.warn);
},
pushInsertModeChanges(_changes: never) {},
pushSearchQuery(_query: string) {},
clear() {
register.keyBuffer = [];
register.linewise = false;
register.blockwise = false;
},
toString() {
return register.keyBuffer.join("\n");
},
};
return register;
}
function setupClipboardIntegration() {
const controller = Vim.getRegisterController();
const registers = controller.registers;
const clipboardRegister = createClipboardRegister();
// システムクリップボードレジスタを置き換え
registers["*"] = clipboardRegister;
registers["+"] = clipboardRegister;
// 無名レジスタをクリップボードに設定 (clipboard=unnamedplus)
registers['"'] = clipboardRegister;
controller.unnamedRegister = clipboardRegister;
// window.focus で外部クリップボードを同期(エディターの focusin ではない)
// focusin を使用すると macOS WKWebView の「ペースト」許可メニューがトリガーされる
let cancelled = false;
const syncHandler = async () => {
if (cancelled) return;
try {
const text = await navigator.clipboard.readText();
if (cancelled) return;
if (text && clipboardRegister.keyBuffer.join("\n") !== text) {
clipboardRegister.keyBuffer = [text];
clipboardRegister.linewise = text.endsWith("\n");
clipboardRegister.blockwise = false;
}
} catch {
// clipboard-read permission denied
}
};
window.addEventListener("focus", syncHandler);
// クリーンアップ関数を返す
return () => {
cancelled = true;
window.removeEventListener("focus", syncHandler);
};
}
EditorView の作成後に setupClipboardIntegration() を呼び出してください。返されたクリーンアップ関数を保存し、エディター破棄時に呼び出してください。