|
|
|
|
@@ -155,6 +155,64 @@
|
|
|
|
|
background: var(--border);
|
|
|
|
|
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>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
@@ -184,8 +242,13 @@
|
|
|
|
|
|
|
|
|
|
<div class="panes">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="input">Input</label>
|
|
|
|
|
<textarea id="input" placeholder="Type or paste text to translate…"></textarea>
|
|
|
|
|
<div class="input-header">
|
|
|
|
|
<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>
|
|
|
|
|
<label for="output">Translation</label>
|
|
|
|
|
@@ -197,7 +260,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, Vim } from "https://esm.sh/@replit/codemirror-vim@6";
|
|
|
|
|
|
|
|
|
|
const LANGUAGES = [
|
|
|
|
|
"English", "Portuguese (Brazil)", "Portuguese (Portugal)", "Spanish",
|
|
|
|
|
"French", "German", "Italian", "Dutch", "Polish", "Russian",
|
|
|
|
|
@@ -210,12 +278,132 @@
|
|
|
|
|
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");
|
|
|
|
|
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() {
|
|
|
|
|
const auto = document.createElement("option");
|
|
|
|
|
@@ -276,7 +464,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function translate() {
|
|
|
|
|
const text = input.value.trim();
|
|
|
|
|
const text = inputText().trim();
|
|
|
|
|
if (!text) return;
|
|
|
|
|
goBtn.disabled = true;
|
|
|
|
|
goBtn.textContent = "Translating…";
|
|
|
|
|
@@ -371,7 +559,7 @@
|
|
|
|
|
return `${secs}s`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshLoaded() {
|
|
|
|
|
async function refreshLoaded(isInitial = false) {
|
|
|
|
|
let models = [];
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch("/api/loaded");
|
|
|
|
|
@@ -387,6 +575,7 @@
|
|
|
|
|
const loadedMap = new Map(models.map(m => [m.name, m.expires_at]));
|
|
|
|
|
|
|
|
|
|
// 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) {
|
|
|
|
|
if (loadedMap.has(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.
|
|
|
|
|
if (models.length === 0) {
|
|
|
|
|
hotStatus.classList.add("idle");
|
|
|
|
|
@@ -409,19 +606,53 @@
|
|
|
|
|
hotStatusText.textContent = `Hot: ${parts.join(", ")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let typingTimer;
|
|
|
|
|
input.addEventListener("input", () => {
|
|
|
|
|
clearTimeout(typingTimer);
|
|
|
|
|
typingTimer = setTimeout(refreshLoaded, 500);
|
|
|
|
|
goBtn.addEventListener("click", translate);
|
|
|
|
|
|
|
|
|
|
// Shift-H / Shift-L cycle focus across controls (vim-style Shift-Tab /
|
|
|
|
|
// 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);
|
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") translate();
|
|
|
|
|
});
|
|
|
|
|
// Override vim's H/L (normally "viewport top/bottom") so they exit the
|
|
|
|
|
// editor to the previous/next control, matching the Shift-H/L contract
|
|
|
|
|
// 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();
|
|
|
|
|
loadModels().then(refreshLoaded);
|
|
|
|
|
loadModels().then(() => refreshLoaded(true));
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
|
|
|
|
|