fix: Firefox clipboard, flip dropdown default to normal mode

- Replace Vim.map("...", "\"+...", ...) with custom Vim.defineAction
  handlers calling navigator.clipboard directly. The library's "+"
  register implementation doesn't reliably write on Firefox.
  Limitation: only yy/Y/visual-y reach system clipboard; bare
  y<motion> still uses vim's internal register.
- Flip dropdown modal: default NORMAL (j/k arrows), `/` or `i`
  enters search. Listener moved to document capture phase so closed
  dropdowns receive keydown on Firefox.
This commit is contained in:
João Pedro Battistella Nadas
2026-06-09 16:53:11 +02:00
parent b092047ca6
commit 73aa54d7bd
2 changed files with 99 additions and 59 deletions

View File

@@ -10,13 +10,12 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added ### Added
- Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard. - Vim bindings in the input via CodeMirror 6 + `@replit/codemirror-vim` (loaded from esm.sh, no build step). Supports `i`/`Esc`, `hjkl`, word/line motions, `dd`/`yy`/`p`, undo/redo, `/` search, `:` command line. Auto-disabled on touch devices (detected via `(pointer: coarse)`) where modal editing fights the virtual keyboard.
- `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor. - `Shift-H` / `Shift-L` cycle focus across the page controls (source → target → model → editor → button) like `Shift-Tab` / `Tab`. Also overridden in vim normal mode (replacing vim's viewport-top/bottom default) so the same shortcut works to leave the editor.
- `y` / `Y` / `yy` and visual-mode `y` in the editor now copy to the system clipboard via vim's `+` register (synchronous `document.execCommand("copy")` under the hood — no permission prompt). - `yy` / `Y` and visual-mode `y` in the editor copy to the system clipboard via custom `navigator.clipboard.writeText()` actions (the library's `+` register is broken on Firefox). Bare `y<motion>` (e.g. `yw`, `y$`) still uses vim's internal register — that path would need a deeper hook into the library's register system.
- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor via the same `+` register. The first paste may show a one-time browser permission chip; granting clipboard-read on the origin makes future calls silent. - `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor via `navigator.clipboard.readText()`. Browsers may show a one-time permission prompt or chip; granting clipboard-read for the origin makes future calls silent. On Firefox, ensure `dom.events.asyncClipboard.readText` is `true` (default in recent versions) or the read will be blocked.
- Vim-style modal navigation on the From / To / Model dropdowns: - Vim-style modal navigation on the From / To / Model dropdowns (default NORMAL):
- Default is native typeahead search (type letters to filter) - `j` / `k` move selectedIndex down / up (works whether the dropdown is open or collapsed; uses document-level capture-phase listener so Firefox plays along)
- `Esc` enters normal mode; an accent-coloured border indicates this - `/` or `i` enters search mode — letter keys then do native typeahead; accent-coloured border indicates it
- In normal mode, `j` / `k` move selectedIndex down / up - `Esc` returns to normal mode; blur also resets so the next focus starts in normal
- `i` or `/` returns to search mode; blur also resets so the next focus starts in search
- On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection. - On page load, the model dropdown defaults to whichever model Ollama already has hot — skips the 5-15 s disk-reload pain on the first translation. After load it follows the user's manual selection.
### Changed ### Changed

View File

@@ -179,8 +179,9 @@
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
} }
/* Vim normal-mode indicator on the language/model dropdowns */ /* Vim search-mode indicator on the language/model dropdowns
select.vim-normal { (default is normal mode — j/k navigate; / or i enters search). */
select.vim-search {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent); box-shadow: 0 0 0 1px var(--accent);
} }
@@ -517,71 +518,111 @@
moveFocus(k === "l" ? 1 : -1); moveFocus(k === "l" ? 1 : -1);
}); });
// Vim customisations: focus navigation + system-clipboard register routing. // Vim customisations: focus navigation + system-clipboard yank/paste.
// Override H/L (normally "viewport top/bottom") to exit the editor across // Override H/L (normally "viewport top/bottom") to exit the editor across
// controls, matching the same Shift-H/L contract the document handler // controls, matching the Shift-H/L contract the document handler uses.
// uses elsewhere. Route y/Y/yy and p/P through vim's "+" register so they // Custom yank/paste via navigator.clipboard — the library's "+" register
// sync with the system clipboard — the @replit/codemirror-vim "+" handler // path is unreliable on Firefox, so we own the clipboard I/O ourselves.
// uses document.execCommand("copy") for yank (no permission prompt) and // Limitation: bare `y<motion>` (yw, y$, etc.) still goes to vim's internal
// navigator.clipboard.readText() for paste (browser may show a one-time // register; only yy / Y / visual `y` reach the system clipboard.
// permission chip; "Allow" makes it permanent).
if (!isCoarsePointer) { if (!isCoarsePointer) {
Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); Vim.defineAction("finrodFocusPrev", () => moveFocus(-1));
Vim.defineAction("finrodFocusNext", () => moveFocus(1)); Vim.defineAction("finrodFocusNext", () => moveFocus(1));
Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" });
Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" });
// Yank → system clipboard (synchronous, no prompt). Vim.defineAction("finrodYankLine", () => {
Vim.map("y", '"+y', "normal"); const line = editor.state.doc.lineAt(editor.state.selection.main.head);
Vim.map("Y", '"+Y', "normal"); const text = editor.state.doc.sliceString(line.from, line.to);
Vim.map("yy", '"+yy', "normal"); navigator.clipboard.writeText(text)
Vim.map("y", '"+y', "visual"); .catch(e => console.warn("Clipboard write failed:", e));
});
Vim.defineAction("finrodYankSelection", () => {
const { from, to } = editor.state.selection.main;
if (from === to) return;
const text = editor.state.sliceDoc(from, to);
navigator.clipboard.writeText(text)
.catch(e => console.warn("Clipboard write failed:", e));
});
async function pasteAt(after) {
let text;
try {
text = await navigator.clipboard.readText();
} catch (e) {
console.warn("Clipboard read failed:", e);
return;
}
if (!text) return;
const sel = editor.state.selection.main;
const pos = after ? sel.to : sel.from;
editor.dispatch({
changes: { from: pos, insert: text },
selection: { anchor: pos + text.length },
});
}
Vim.defineAction("finrodPasteAfter", () => pasteAt(true));
Vim.defineAction("finrodPasteBefore", () => pasteAt(false));
// Paste ← system clipboard. The chip nag is browser-side; granting Vim.mapCommand("yy", "action", "finrodYankLine", {}, { context: "normal" });
// clipboard-read permission for the origin makes it silent. Vim.mapCommand("Y", "action", "finrodYankLine", {}, { context: "normal" });
Vim.map("p", '"+p', "normal"); Vim.mapCommand("y", "action", "finrodYankSelection", {}, { context: "visual" });
Vim.map("P", '"+P', "normal"); Vim.mapCommand("p", "action", "finrodPasteAfter", {}, { context: "normal" });
Vim.mapCommand("P", "action", "finrodPasteBefore", {}, { context: "normal" });
} }
// Modal vim-style behavior on the language/model dropdowns. // Modal vim-style behavior on the language/model dropdowns.
// default: typeahead search (browser native) // default: NORMAL mode — j/k move selectedIndex (vim everywhere)
// Esc: switch to "normal" mode — j/k move selectedIndex // `/` or `i`: enter search mode — letter keys do native typeahead
// i, /: back to search mode // Esc: back to normal mode
// blur: reset to search mode on next focus // blur: reset to normal mode on next focus
// Disabled on touch (no Esc key, no value). // Uses a document-level CAPTURE listener so events fire whether the
// dropdown is open or collapsed (Firefox doesn't bubble keydown
// reliably from a collapsed <select>).
if (!isCoarsePointer) { if (!isCoarsePointer) {
for (const sel of [sourceSel, targetSel, modelSel]) { const vimSelects = [sourceSel, targetSel, modelSel];
sel.dataset.mode = "search"; for (const sel of vimSelects) {
const enterNormal = () => {
sel.dataset.mode = "normal"; sel.dataset.mode = "normal";
sel.classList.add("vim-normal"); sel.addEventListener("blur", () => {
}; sel.dataset.mode = "normal";
const enterSearch = () => { sel.classList.remove("vim-search");
sel.dataset.mode = "search"; });
sel.classList.remove("vim-normal"); }
}; const step = (sel, delta) => {
const step = (delta) => {
const next = sel.selectedIndex + delta; const next = sel.selectedIndex + delta;
if (next < 0 || next >= sel.options.length) return; if (next < 0 || next >= sel.options.length) return;
sel.selectedIndex = next; sel.selectedIndex = next;
sel.dispatchEvent(new Event("change", { bubbles: true })); sel.dispatchEvent(new Event("change", { bubbles: true }));
}; };
sel.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
const sel = document.activeElement;
if (!vimSelects.includes(sel)) return;
// Shift-H/L: let the focus-nav handler take it; don't preventDefault.
if (e.shiftKey && (e.key === "H" || e.key === "L")) return;
if (e.key === "Escape") { if (e.key === "Escape") {
enterNormal(); sel.dataset.mode = "normal";
sel.classList.remove("vim-search");
e.preventDefault(); e.preventDefault();
e.stopPropagation();
return; return;
} }
if (sel.dataset.mode === "search") return; // native typeahead if (sel.dataset.mode === "search") return; // let native typeahead happen
if (e.key === "j") { e.preventDefault(); step(+1); return; } // normal mode
if (e.key === "k") { e.preventDefault(); step(-1); return; } if (e.key === "j") { e.preventDefault(); e.stopPropagation(); step(sel, +1); return; }
if (e.key === "i" || e.key === "/") { enterSearch(); e.preventDefault(); return; } if (e.key === "k") { e.preventDefault(); e.stopPropagation(); step(sel, -1); return; }
// Let Shift-H/L bubble to the document focus handler; preventDefault if (e.key === "/" || e.key === "i") {
// anything else so it doesn't pollute the next search. sel.dataset.mode = "search";
if (!(e.shiftKey && (e.key === "H" || e.key === "L"))) e.preventDefault(); sel.classList.add("vim-search");
}); e.preventDefault();
sel.addEventListener("blur", enterSearch); e.stopPropagation();
return;
} }
// Suppress any other letter so typeahead doesn't fire silently in normal mode.
if (e.key.length === 1) {
e.preventDefault();
e.stopPropagation();
}
}, true); // CAPTURE phase — needed for closed-dropdown keydowns on Firefox
} }
fillLangs(); fillLangs();