diff --git a/CHANGELOG.md b/CHANGELOG.md index fa074ad..0477617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,13 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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. - `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. -- `p` / `P` in vim normal mode paste from the system clipboard at / before the cursor (vim's default registers are skipped — the realistic source for a translator is another app, not an in-editor yank). +- `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). +- `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. +- Vim-style modal navigation on the From / To / Model dropdowns: + - Default is native typeahead search (type letters to filter) + - `Esc` enters normal mode; an accent-coloured border indicates this + - In normal mode, `j` / `k` move selectedIndex down / up + - `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. ### Changed diff --git a/static/index.html b/static/index.html index decd64f..4b21c74 100644 --- a/static/index.html +++ b/static/index.html @@ -178,6 +178,12 @@ border-top: 1px solid var(--border); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + + /* Vim normal-mode indicator on the language/model dropdowns */ + select.vim-normal { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); + } @@ -511,36 +517,71 @@ moveFocus(k === "l" ? 1 : -1); }); - // Override vim's H/L (normally "viewport top/bottom") so they exit the - // editor to the previous/next control, matching the same Shift-H/L - // contract the document handler uses elsewhere. Also override p/P in - // normal mode to paste from the system clipboard (vim's default p - // pastes from its internal register — useless when the source text - // comes from another app). + // Vim customisations: focus navigation + system-clipboard register routing. + // Override H/L (normally "viewport top/bottom") to exit the editor across + // controls, matching the same Shift-H/L contract the document handler + // uses elsewhere. Route y/Y/yy and p/P through vim's "+" register so they + // sync with the system clipboard — the @replit/codemirror-vim "+" handler + // uses document.execCommand("copy") for yank (no permission prompt) and + // navigator.clipboard.readText() for paste (browser may show a one-time + // permission chip; "Allow" makes it permanent). if (!isCoarsePointer) { Vim.defineAction("finrodFocusPrev", () => moveFocus(-1)); Vim.defineAction("finrodFocusNext", () => moveFocus(1)); Vim.mapCommand("H", "action", "finrodFocusPrev", {}, { context: "normal" }); Vim.mapCommand("L", "action", "finrodFocusNext", {}, { context: "normal" }); - async function pasteFromClipboard(after) { - try { - const text = await navigator.clipboard.readText(); - 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 }, - }); - } catch (e) { - console.warn("Clipboard read failed:", e); - } + // Yank → system clipboard (synchronous, no prompt). + Vim.map("y", '"+y', "normal"); + Vim.map("Y", '"+Y', "normal"); + Vim.map("yy", '"+yy', "normal"); + Vim.map("y", '"+y', "visual"); + + // Paste ← system clipboard. The chip nag is browser-side; granting + // clipboard-read permission for the origin makes it silent. + Vim.map("p", '"+p', "normal"); + Vim.map("P", '"+P', "normal"); + } + + // Modal vim-style behavior on the language/model dropdowns. + // default: typeahead search (browser native) + // Esc: switch to "normal" mode — j/k move selectedIndex + // i, /: back to search mode + // blur: reset to search mode on next focus + // Disabled on touch (no Esc key, no value). + if (!isCoarsePointer) { + for (const sel of [sourceSel, targetSel, modelSel]) { + sel.dataset.mode = "search"; + const enterNormal = () => { + sel.dataset.mode = "normal"; + sel.classList.add("vim-normal"); + }; + const enterSearch = () => { + sel.dataset.mode = "search"; + sel.classList.remove("vim-normal"); + }; + const step = (delta) => { + const next = sel.selectedIndex + delta; + if (next < 0 || next >= sel.options.length) return; + sel.selectedIndex = next; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }; + sel.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + enterNormal(); + e.preventDefault(); + return; + } + if (sel.dataset.mode === "search") return; // native typeahead + if (e.key === "j") { e.preventDefault(); step(+1); return; } + if (e.key === "k") { e.preventDefault(); step(-1); return; } + if (e.key === "i" || e.key === "/") { enterSearch(); e.preventDefault(); return; } + // Let Shift-H/L bubble to the document focus handler; preventDefault + // anything else so it doesn't pollute the next search. + if (!(e.shiftKey && (e.key === "H" || e.key === "L"))) e.preventDefault(); + }); + sel.addEventListener("blur", enterSearch); } - Vim.defineAction("finrodPasteAfter", () => pasteFromClipboard(true)); - Vim.defineAction("finrodPasteBefore", () => pasteFromClipboard(false)); - Vim.mapCommand("p", "action", "finrodPasteAfter", {}, { context: "normal" }); - Vim.mapCommand("P", "action", "finrodPasteBefore", {}, { context: "normal" }); } fillLangs();