zudo-codemirror-wisdom

Type to search...

to open search from anywhere

Clipboard Integration

CreatedMar 29, 2026UpdatedApr 3, 2026Takeshi Takatsudo

Sync vim registers with the system clipboard in @replit/codemirror-vim.

Clipboard Integration

By default, vim registers in @replit/codemirror-vim are internal — yank and paste operations do not interact with the system clipboard. This page covers how to bridge that gap.

The Challenge

In a terminal vim, the "+ and "* registers connect to the system clipboard and selection buffer. In a browser-based editor, these registers are just in-memory objects with no system integration. Yanking text with "+y stores it internally but does not copy to the OS clipboard, and "+p pastes from the internal register, not from whatever the user copied elsewhere.

Register Controller API

The Vim object exposes the register controller, which manages all named registers.

import { Vim } from "@replit/codemirror-vim";

const controller = Vim.getRegisterController();
const registers = controller.registers;

Each register is an object with methods for getting and setting text. You can replace any register with a custom implementation that syncs with the system clipboard.

Creating a Clipboard Register

The following implementation creates a register object that writes to navigator.clipboard on every yank and stores text locally for paste operations.

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;
}

Replacing the Clipboard Registers

Replace the * and + registers with the clipboard-aware implementation.

const controller = Vim.getRegisterController();
const registers = controller.registers;
const clipboardRegister = createClipboardRegister();

registers["*"] = clipboardRegister;
registers["+"] = clipboardRegister;

After this, "+y and "*y will copy to the system clipboard, and "+p / "*p will paste from the internal buffer (which stays in sync on yank).

Clipboard=Unnamedplus Behavior

In vim, set clipboard=unnamedplus makes the default (unnamed) register alias the + register. Every yank and delete goes to the system clipboard without needing a register prefix.

To replicate this, also replace the unnamed register (").

registers['"'] = clipboardRegister;
controller.unnamedRegister = clipboardRegister;

With this in place, plain yy copies to the system clipboard and p pastes whatever was last yanked.

Syncing Clipboard on Focus

One limitation of the approach above is that text copied outside the editor (e.g., from another application) is not automatically available for p. The internal register only knows about text that was yanked inside the editor.

To handle external clipboard content, read from navigator.clipboard when the editor or window gains focus.

Standard Browser Approach

In a standard web page, listening on the editor’s focus event works:

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: Use window.focus Instead

⚠️ Warning

In Tauri applications (which use macOS WKWebView), calling navigator.clipboard.readText() inside a focusin event handler on the editor element triggers the native macOS “Paste” editing menu — a clipboard permission prompt that appears as a floating menu near the text cursor. This creates a poor user experience because it appears every time the user clicks into the editor.

Use window.addEventListener("focus", ...) instead of view.dom.addEventListener("focusin", ...).

The window.focus event fires when the application window regains focus from another application (e.g., the user Cmd-Tabs back). This is the moment when the system clipboard is most likely to contain new content from an external source.

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 {
    // clipboard permission denied -- ignore
  }
};

window.addEventListener("focus", syncHandler);

// Cleanup when the editor is destroyed
function teardown() {
  window.removeEventListener("focus", syncHandler);
}

Key differences from the standard approach:

  • window event, not editor DOM event — Avoids triggering the macOS WKWebView clipboard permission prompt
  • Deduplication check — Only updates the register if the clipboard content differs from what is already stored (keyBuffer.join("\n") !== text)
  • Linewise detection — Sets linewise: true when the clipboard text ends with a newline, which makes p paste below the current line (matching vim’s linewise paste behavior)

📝 Note

navigator.clipboard.readText() requires the page to have clipboard-read permission. In some browsers, this is granted automatically when the document is focused. In others, the user may see a permission prompt. If permission is denied, the catch block silently handles the error.

Complete Implementation

Putting it all together. This version uses window.focus for clipboard sync, which is compatible with both standard browsers and Tauri/WKWebView environments.

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();

  // Replace system clipboard registers
  registers["*"] = clipboardRegister;
  registers["+"] = clipboardRegister;

  // Make unnamed register use clipboard (clipboard=unnamedplus)
  registers['"'] = clipboardRegister;
  controller.unnamedRegister = clipboardRegister;

  // Sync external clipboard on window focus (NOT editor focusin).
  // Using focusin triggers the macOS WKWebView "Paste" permission menu.
  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 cleanup function
  return () => {
    cancelled = true;
    window.removeEventListener("focus", syncHandler);
  };
}

Call setupClipboardIntegration() after creating the EditorView. Store the returned cleanup function and call it when the editor is destroyed.

Revision History