Compare commits
10 Commits
v0.1.0
...
123ce59595
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123ce59595 | ||
| d4a033e500 | |||
|
|
163f8dda57 | ||
|
|
2d067226b9 | ||
|
|
73aa54d7bd | ||
|
|
b092047ca6 | ||
|
|
a058665b5b | ||
|
|
876014a7b7 | ||
|
|
9564900909 | ||
|
|
782c7182f2 |
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,6 +7,19 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- "Scan image" button next to the input pane: opens the phone's rear camera (or a file picker on desktop), runs OCR locally in the browser via Tesseract.js (loaded from esm.sh on first use), and replaces the editor contents with the recognised text. Language follows the "From" dropdown via an ISO 639-2 mapping; "Auto-detect" loads a common European set (`eng+por+deu+nld`). Progress status shows beneath the input ("Loading OCR engine…" → "OCR 40%" → "OCR done in 3.2s"). Zero image-size cost — same CDN pattern as CodeMirror.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-06-09
|
||||||
|
|
||||||
|
### 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` (vim's internal register), 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.
|
||||||
|
- 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
|
||||||
|
|
||||||
First release. Stateless translator UI that proxies streaming requests to
|
First release. Stateless translator UI that proxies streaming requests to
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "finrod"
|
name = "finrod"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "Translator UI backed by Ollama"
|
description = "Translator UI backed by Ollama"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -155,6 +155,64 @@
|
|||||||
background: var(--border);
|
background: var(--border);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Input-pane header: label on the left, "Scan image" button on the right */
|
||||||
|
.input-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.input-header label { margin-bottom: 0; }
|
||||||
|
.scan-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.scan-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.scan-btn:disabled { cursor: progress; opacity: 0.6; }
|
||||||
|
.scan-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
min-height: 1em;
|
||||||
|
}
|
||||||
|
.scan-status.error { color: var(--error); }
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
font-size: 1rem; /* match .output's inherited body size — CM6 defaults are smaller */
|
||||||
|
}
|
||||||
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -184,8 +242,13 @@
|
|||||||
|
|
||||||
<div class="panes">
|
<div class="panes">
|
||||||
<div>
|
<div>
|
||||||
<label for="input">Input</label>
|
<div class="input-header">
|
||||||
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
|
<label for="input">Input</label>
|
||||||
|
<button type="button" id="scan-btn" class="scan-btn" title="Extract text from a camera shot or image file">Scan image</button>
|
||||||
|
</div>
|
||||||
|
<div id="input"></div>
|
||||||
|
<input type="file" id="scan-input" accept="image/*" capture="environment" hidden>
|
||||||
|
<div id="scan-status" class="scan-status"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="output">Translation</label>
|
<label for="output">Translation</label>
|
||||||
@@ -197,7 +260,12 @@
|
|||||||
<div id="meta" class="meta"></div>
|
<div id="meta" class="meta"></div>
|
||||||
</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, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
||||||
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
||||||
@@ -210,12 +278,132 @@
|
|||||||
const sourceSel = document.getElementById("source");
|
const sourceSel = document.getElementById("source");
|
||||||
const targetSel = document.getElementById("target");
|
const targetSel = document.getElementById("target");
|
||||||
const modelSel = document.getElementById("model");
|
const modelSel = document.getElementById("model");
|
||||||
const input = document.getElementById("input");
|
|
||||||
const output = document.getElementById("output");
|
const output = document.getElementById("output");
|
||||||
const goBtn = document.getElementById("go");
|
const goBtn = document.getElementById("go");
|
||||||
const meta = document.getElementById("meta");
|
const meta = document.getElementById("meta");
|
||||||
const hotStatus = document.getElementById("hot-status");
|
const hotStatus = document.getElementById("hot-status");
|
||||||
const hotStatusText = document.getElementById("hot-status-text");
|
const hotStatusText = document.getElementById("hot-status-text");
|
||||||
|
const scanBtn = document.getElementById("scan-btn");
|
||||||
|
const scanInput = document.getElementById("scan-input");
|
||||||
|
const scanStatus = document.getElementById("scan-status");
|
||||||
|
|
||||||
|
// --- OCR via Tesseract.js (lazy-loaded on first use from esm.sh) --------
|
||||||
|
// Tesseract language codes are ISO 639-2 — map from our "From" dropdown.
|
||||||
|
const TESS_LANGS = {
|
||||||
|
"English": "eng",
|
||||||
|
"Portuguese (Brazil)": "por",
|
||||||
|
"Portuguese (Portugal)": "por",
|
||||||
|
"Spanish": "spa",
|
||||||
|
"French": "fra",
|
||||||
|
"German": "deu",
|
||||||
|
"Italian": "ita",
|
||||||
|
"Dutch": "nld",
|
||||||
|
"Polish": "pol",
|
||||||
|
"Russian": "rus",
|
||||||
|
"Japanese": "jpn",
|
||||||
|
"Korean": "kor",
|
||||||
|
"Chinese (Simplified)": "chi_sim",
|
||||||
|
"Chinese (Traditional)": "chi_tra",
|
||||||
|
"Arabic": "ara",
|
||||||
|
"Hindi": "hin",
|
||||||
|
"Turkish": "tur",
|
||||||
|
"Swedish": "swe",
|
||||||
|
"Norwegian": "nor",
|
||||||
|
"Danish": "dan",
|
||||||
|
"Finnish": "fin",
|
||||||
|
"Greek": "ell",
|
||||||
|
"Hebrew": "heb",
|
||||||
|
"Czech": "ces",
|
||||||
|
"Hungarian": "hun",
|
||||||
|
"Romanian": "ron",
|
||||||
|
"Ukrainian": "ukr",
|
||||||
|
"Vietnamese": "vie",
|
||||||
|
"Thai": "tha",
|
||||||
|
"Indonesian": "ind",
|
||||||
|
};
|
||||||
|
// "Auto-detect" — load common European set together; tesseract picks best.
|
||||||
|
const TESS_AUTO = "eng+por+deu+nld";
|
||||||
|
|
||||||
|
function setScanStatus(text, { error = false } = {}) {
|
||||||
|
scanStatus.textContent = text;
|
||||||
|
scanStatus.classList.toggle("error", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOCR(file) {
|
||||||
|
const src = sourceSel.value;
|
||||||
|
const lang = src === "auto" ? TESS_AUTO : (TESS_LANGS[src] || TESS_AUTO);
|
||||||
|
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
setScanStatus("Loading OCR engine…");
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Lazy import — first scan triggers the ~5 MB JS + language pack
|
||||||
|
// download; cached by the browser after that.
|
||||||
|
const { createWorker } = await import("https://esm.sh/tesseract.js@5");
|
||||||
|
const worker = await createWorker(lang, 1, {
|
||||||
|
logger: (m) => {
|
||||||
|
if (m.status === "recognizing text") {
|
||||||
|
setScanStatus(`OCR ${Math.round(m.progress * 100)}%`);
|
||||||
|
} else if (m.status) {
|
||||||
|
setScanStatus(`OCR: ${m.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { data: { text } } = await worker.recognize(file);
|
||||||
|
await worker.terminate();
|
||||||
|
|
||||||
|
editor.dispatch({
|
||||||
|
changes: { from: 0, to: editor.state.doc.length, insert: text.trim() },
|
||||||
|
selection: { anchor: 0 },
|
||||||
|
});
|
||||||
|
const dt = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
setScanStatus(`OCR done in ${dt}s (${lang})`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setScanStatus(`OCR failed: ${e.message || e}`, { error: true });
|
||||||
|
} finally {
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
scanInput.value = ""; // allow re-selecting the same file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scanBtn.addEventListener("click", () => scanInput.click());
|
||||||
|
scanInput.addEventListener("change", (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) runOCR(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 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() {
|
function fillLangs() {
|
||||||
const auto = document.createElement("option");
|
const auto = document.createElement("option");
|
||||||
@@ -276,7 +464,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function translate() {
|
async function translate() {
|
||||||
const text = input.value.trim();
|
const text = inputText().trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
goBtn.disabled = true;
|
goBtn.disabled = true;
|
||||||
goBtn.textContent = "Translating…";
|
goBtn.textContent = "Translating…";
|
||||||
@@ -371,7 +559,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");
|
||||||
@@ -387,6 +575,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}`;
|
||||||
@@ -395,6 +584,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");
|
||||||
@@ -409,19 +606,53 @@
|
|||||||
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let typingTimer;
|
goBtn.addEventListener("click", translate);
|
||||||
input.addEventListener("input", () => {
|
|
||||||
clearTimeout(typingTimer);
|
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
|
||||||
typingTimer = setTimeout(refreshLoaded, 500);
|
// Tab). Two paths kept in sync:
|
||||||
|
// 1. Document keydown listener — fires when focus is on a select / button
|
||||||
|
// 2. Vim normal-mode mapping (below) — fires when focus is in the editor
|
||||||
|
// Together they make Shift-H/L work everywhere, including escaping the editor.
|
||||||
|
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() },
|
||||||
|
];
|
||||||
|
function moveFocus(delta) {
|
||||||
|
const ae = document.activeElement;
|
||||||
|
let idx = focusables.findIndex(f => f.el === ae || f.el.contains?.(ae));
|
||||||
|
if (idx === -1) {
|
||||||
|
focusables[delta > 0 ? 0 : focusables.length - 1].focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = focusables[(idx + delta + focusables.length) % focusables.length];
|
||||||
|
next.focus();
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (!e.shiftKey) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k !== "h" && k !== "l") return;
|
||||||
|
// Inside the editor, vim's mapCommand handles H/L (see below). Letting
|
||||||
|
// the event bubble here would double-jump.
|
||||||
|
if (editor.dom.contains(document.activeElement)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(k === "l" ? 1 : -1);
|
||||||
});
|
});
|
||||||
|
|
||||||
goBtn.addEventListener("click", translate);
|
// Override vim's H/L (normally "viewport top/bottom") so they exit the
|
||||||
input.addEventListener("keydown", (e) => {
|
// editor to the previous/next control, matching the Shift-H/L contract
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
|
// the document handler uses elsewhere.
|
||||||
});
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
fillLangs();
|
fillLangs();
|
||||||
loadModels().then(refreshLoaded);
|
loadModels().then(() => refreshLoaded(true));
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user