feat: vim bindings in the input via CodeMirror 6

- Replace the plain <textarea> with a CodeMirror 6 EditorView that
  has @replit/codemirror-vim attached. Loaded from esm.sh, so no
  build step — finrod stays no-bundler.
- vim() extension is conditionally omitted on coarse-pointer devices
  (phones, tablets) where modal editing without a hardware Esc key
  is unusable and IME composition fights the keymap.
- Ctrl-/Cmd-Enter shortcut and the typing-debounce that refreshes
  the "Hot:" indicator both migrate to CodeMirror's keymap and
  updateListener APIs respectively.
- Editor styled to match the existing .output pane.

CHANGELOG: note under [Unreleased].
This commit is contained in:
João Pedro Battistella Nadas
2026-06-09 16:15:36 +02:00
parent f9acdef3fe
commit 782c7182f2
2 changed files with 64 additions and 13 deletions

View File

@@ -7,6 +7,9 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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.
## [0.1.0] - 2026-06-09
First release. Stateless translator UI that proxies streaming requests to

View File

@@ -155,6 +155,28 @@
background: var(--border);
box-shadow: none;
}
/* CodeMirror editor host — matches .output styling so input & output align */
.cm-editor {
min-height: 180px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.cm-editor.cm-focused {
outline: none;
border-color: var(--accent);
}
.cm-scroller { padding: 0.6rem 0.75rem; }
.cm-content { caret-color: var(--accent); }
/* vim's command-line / status hint inherits readable colors */
.cm-vim-panel {
background: var(--panel);
color: var(--fg);
border-top: 1px solid var(--border);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
</style>
</head>
<body>
@@ -185,7 +207,7 @@
<div class="panes">
<div>
<label for="input">Input</label>
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
<div id="input"></div>
</div>
<div>
<label for="output">Translation</label>
@@ -197,7 +219,12 @@
<div id="meta" class="meta"></div>
</div>
<script>
<script type="module">
import { EditorView, keymap, placeholder } from "https://esm.sh/@codemirror/view@6";
import { EditorState } from "https://esm.sh/@codemirror/state@6";
import { history, defaultKeymap, historyKeymap } from "https://esm.sh/@codemirror/commands@6";
import { vim } from "https://esm.sh/@replit/codemirror-vim@6";
const LANGUAGES = [
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
"French", "German", "Italian", "Dutch", "Polish", "Russian",
@@ -210,13 +237,43 @@
const sourceSel = document.getElementById("source");
const targetSel = document.getElementById("target");
const modelSel = document.getElementById("model");
const input = document.getElementById("input");
const output = document.getElementById("output");
const goBtn = document.getElementById("go");
const meta = document.getElementById("meta");
const hotStatus = document.getElementById("hot-status");
const hotStatusText = document.getElementById("hot-status-text");
// --- CodeMirror editor (vim mode on real keyboards only) ----------------
const isCoarsePointer = window.matchMedia("(pointer: coarse)").matches;
let typingTimer;
const editor = new EditorView({
parent: document.getElementById("input"),
state: EditorState.create({
extensions: [
// vim() MUST be first per @replit/codemirror-vim docs so its keymap
// takes precedence. Skipped on touch devices — modal editing without
// a hardware Esc key on a virtual keyboard is unusable, and IME
// composition fights the vim keymap.
...(isCoarsePointer ? [] : [vim()]),
history(),
keymap.of([
{ key: "Mod-Enter", run: () => { translate(); return true; } },
...defaultKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
placeholder("Type or paste text to translate…"),
EditorView.updateListener.of(u => {
if (u.docChanged) {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
}
}),
],
}),
});
const inputText = () => editor.state.doc.toString();
function fillLangs() {
const auto = document.createElement("option");
auto.value = "auto";
@@ -276,7 +333,7 @@
}
async function translate() {
const text = input.value.trim();
const text = inputText().trim();
if (!text) return;
goBtn.disabled = true;
goBtn.textContent = "Translating…";
@@ -409,16 +466,7 @@
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
}
let typingTimer;
input.addEventListener("input", () => {
clearTimeout(typingTimer);
typingTimer = setTimeout(refreshLoaded, 500);
});
goBtn.addEventListener("click", translate);
input.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
});
fillLangs();
loadModels().then(refreshLoaded);