v0.2.0: vim bindings, Shift-H/L focus nav, hot-model default #3
@@ -9,6 +9,11 @@ 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`. Suppressed inside the editor where vim already owns those motions.
|
||||||
|
- 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
|
||||||
|
- Editor font-size pinned to `1rem` to match the output pane (CodeMirror 6 ships smaller by default).
|
||||||
|
|
||||||
## [0.1.0] - 2026-06-09
|
## [0.1.0] - 2026-06-09
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
|
||||||
}
|
}
|
||||||
.cm-editor.cm-focused {
|
.cm-editor.cm-focused {
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -428,7 +429,7 @@
|
|||||||
return `${secs}s`;
|
return `${secs}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLoaded() {
|
async function refreshLoaded(isInitial = false) {
|
||||||
let models = [];
|
let models = [];
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/loaded");
|
const r = await fetch("/api/loaded");
|
||||||
@@ -444,6 +445,7 @@
|
|||||||
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
|
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
|
||||||
|
|
||||||
// Update dropdown options. Use textContent (option doesn't render HTML).
|
// Update dropdown options. Use textContent (option doesn't render HTML).
|
||||||
|
const optionValues = Array.from(modelSel.options, o => o.value);
|
||||||
for (const opt of modelSel.options) {
|
for (const opt of modelSel.options) {
|
||||||
if (loadedMap.has(opt.value)) {
|
if (loadedMap.has(opt.value)) {
|
||||||
opt.textContent = `● ${opt.value}`;
|
opt.textContent = `● ${opt.value}`;
|
||||||
@@ -452,6 +454,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On the very first refresh after page load, prefer a hot model as the
|
||||||
|
// dropdown default — skipping disk reload pain. Only on initial load,
|
||||||
|
// so the user's manual selection isn't yanked away while they type.
|
||||||
|
if (isInitial) {
|
||||||
|
const hot = models.find(m => optionValues.includes(m.name));
|
||||||
|
if (hot) modelSel.value = hot.name;
|
||||||
|
}
|
||||||
|
|
||||||
// Update status line.
|
// Update status line.
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
hotStatus.classList.add("idle");
|
hotStatus.classList.add("idle");
|
||||||
@@ -468,8 +478,35 @@
|
|||||||
|
|
||||||
goBtn.addEventListener("click", translate);
|
goBtn.addEventListener("click", translate);
|
||||||
|
|
||||||
|
// Shift-H / Shift-L navigate focus across controls (vim-style Shift-Tab /
|
||||||
|
// Tab). Skipped when focus is inside the CodeMirror editor — there H/L
|
||||||
|
// are vim's viewport motions and must reach the editor untouched.
|
||||||
|
const focusables = [
|
||||||
|
{ el: sourceSel, focus: () => sourceSel.focus() },
|
||||||
|
{ el: targetSel, focus: () => targetSel.focus() },
|
||||||
|
{ el: modelSel, focus: () => modelSel.focus() },
|
||||||
|
{ el: editor.contentDOM, focus: () => editor.focus() },
|
||||||
|
{ el: goBtn, focus: () => goBtn.focus() },
|
||||||
|
];
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (!e.shiftKey) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k !== "h" && k !== "l") return;
|
||||||
|
if (editor.dom.contains(document.activeElement)) return; // vim owns H/L in the editor
|
||||||
|
e.preventDefault();
|
||||||
|
const ae = document.activeElement;
|
||||||
|
let idx = focusables.findIndex(f => f.el === ae);
|
||||||
|
if (idx === -1) {
|
||||||
|
focusables[k === "l" ? 0 : focusables.length - 1].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = k === "l" ? 1 : -1;
|
||||||
|
const next = focusables[(idx + delta + focusables.length) % focusables.length];
|
||||||
|
next.focus();
|
||||||
|
});
|
||||||
|
|
||||||
fillLangs();
|
fillLangs();
|
||||||
loadModels().then(refreshLoaded);
|
loadModels().then(() => refreshLoaded(true));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user