feat: y/Y system clipboard, modal vim dropdowns
- y/Y/yy and visual y now route through vim's "+" register, which
copies to the system clipboard via the synchronous document
.execCommand("copy") path — no permission prompt.
- p/P also remapped to "+ register for symmetry; replaces my custom
navigator.clipboard.readText() action. Browser may still show a
permission chip on first paste; granting once makes it silent.
- Source / target / model dropdowns get modal vim-style nav:
default is native typeahead, Esc → normal mode (accent border),
j/k move selectedIndex, i and / return to search, blur resets.
Disabled on coarse-pointer devices.
This commit is contained in:
@@ -10,7 +10,13 @@ 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.
|
||||||
- `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.
|
- 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
|
||||||
|
|||||||
@@ -178,6 +178,12 @@
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
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 */
|
||||||
|
select.vim-normal {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -511,36 +517,71 @@
|
|||||||
moveFocus(k === "l" ? 1 : -1);
|
moveFocus(k === "l" ? 1 : -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Override vim's H/L (normally "viewport top/bottom") so they exit the
|
// Vim customisations: focus navigation + system-clipboard register routing.
|
||||||
// editor to the previous/next control, matching the same Shift-H/L
|
// Override H/L (normally "viewport top/bottom") to exit the editor across
|
||||||
// contract the document handler uses elsewhere. Also override p/P in
|
// controls, matching the same Shift-H/L contract the document handler
|
||||||
// normal mode to paste from the system clipboard (vim's default p
|
// uses elsewhere. Route y/Y/yy and p/P through vim's "+" register so they
|
||||||
// pastes from its internal register — useless when the source text
|
// sync with the system clipboard — the @replit/codemirror-vim "+" handler
|
||||||
// comes from another app).
|
// 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) {
|
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" });
|
||||||
|
|
||||||
async function pasteFromClipboard(after) {
|
// Yank → system clipboard (synchronous, no prompt).
|
||||||
try {
|
Vim.map("y", '"+y', "normal");
|
||||||
const text = await navigator.clipboard.readText();
|
Vim.map("Y", '"+Y', "normal");
|
||||||
if (!text) return;
|
Vim.map("yy", '"+yy', "normal");
|
||||||
const sel = editor.state.selection.main;
|
Vim.map("y", '"+y', "visual");
|
||||||
const pos = after ? sel.to : sel.from;
|
|
||||||
editor.dispatch({
|
// Paste ← system clipboard. The chip nag is browser-side; granting
|
||||||
changes: { from: pos, insert: text },
|
// clipboard-read permission for the origin makes it silent.
|
||||||
selection: { anchor: pos + text.length },
|
Vim.map("p", '"+p', "normal");
|
||||||
});
|
Vim.map("P", '"+P', "normal");
|
||||||
} catch (e) {
|
}
|
||||||
console.warn("Clipboard read failed:", e);
|
|
||||||
}
|
// 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();
|
fillLangs();
|
||||||
|
|||||||
Reference in New Issue
Block a user