@@ -158,7 +175,9 @@
LibreTranslate is a free MIT-licensed engine. Public instances rate-limit — if you
hit "too many requests" or a mirror is down, pick another URL from the list or
- self-host. Google (unofficial) uses
+ self-host; Theseus falls through to the others automatically.
+ silentmode.st/libre is ours and answers reliably, but only covers
+ English, French, German, Greek, Russian and Spanish. Google (unofficial) uses
translate.googleapis.com/translate_a/single — no key, but unofficial
and Google can break it at any time.
@@ -170,7 +189,7 @@
const srcSel = $("src"), tgtSel = $("tgt");
const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear");
const settings = $("settings");
- const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey");
+ const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey"), autoChk = $("autoTranslate");
const ltUrlLabel = $("ltUrlLabel"), ltKeyLabel = $("ltKeyLabel");
// ISO-639-1 short list. First `auto` for source only. Users can add more
@@ -220,6 +239,25 @@
function paintAutoLabel() {
const opt = srcSel.querySelector('option[value="auto"]');
if (opt) opt.textContent = autoLabel();
+ paintDetectRow();
+ }
+ // The row under the selects. Only meaningful while the source is Auto —
+ // with an explicit source there is nothing to detect.
+ function paintDetectRow() {
+ const el = $("det");
+ if (!el) return;
+ if (srcSel.value !== "auto" || !detectedCode) { el.hidden = true; el.textContent = ""; return; }
+ const name = LANG_NAME.get(detectedCode) || detectedCode;
+ const low = detectedConfidence !== null && detectedConfidence < 60;
+ el.innerHTML = "";
+ el.append("Detected: ");
+ const b = document.createElement("b"); b.textContent = name; el.append(b);
+ if (low) {
+ const q = document.createElement("span");
+ q.className = "q"; q.textContent = " — not certain, set the language if this is wrong";
+ el.append(q);
+ }
+ el.hidden = false;
}
function fillSelect(el, includeAuto) {
@@ -260,7 +298,7 @@
// current working default so the panel isn't broken out of the box.
const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]);
const settingsState = { backend: "libretranslate", ltUrl: DEFAULT_LT_URL, ltKey: "" };
- const uiState = { src: "auto", tgt: defaultTarget, zoom: 100, recent: {} };
+ const uiState = { src: "auto", tgt: defaultTarget, zoom: 100, recent: {}, autoTranslate: true };
// Populate the selects now that uiState exists — fillSelect reads the
// frequency table to build the pinned group, so it cannot run before this.
@@ -301,8 +339,11 @@
ltKey.value = settingsState.ltKey;
srcSel.value = uiState.src;
tgtSel.value = uiState.tgt;
+ if (typeof uiState.autoTranslate !== "boolean") uiState.autoTranslate = true;
+ autoChk.checked = uiState.autoTranslate;
applyZoom();
onBackendChange();
+ paintDetectRow();
}
async function saveSettings() {
settingsState.backend = backendSel.value;
@@ -327,7 +368,11 @@
// is not enough for a detector to be right, and firing per keystroke would
// hammer a public mirror for no benefit. Nothing here blocks translating —
// a failed or slow detect just leaves the label alone.
- const DETECT_DEBOUNCE_MS = 700, DETECT_MIN_CHARS = 12;
+ // 12 characters was too strict — "Guten Morgen" barely cleared it and most
+ // quick one-word checks never detected anything, which read as "detection
+ // is broken". Short text detects less reliably, so the low-confidence
+ // qualifier on the label does the honest work instead of a blunt cutoff.
+ const DETECT_DEBOUNCE_MS = 700, DETECT_MIN_CHARS = 5;
let detectTimer = null, detectSeq = 0;
function setDetected(code, confidence) {
if (code === detectedCode && confidence === detectedConfidence) return;
@@ -356,9 +401,36 @@
clearTimeout(detectTimer);
if (srcSel.value !== "auto") { clearDetected(); return; }
if (inp.value.trim().length < DETECT_MIN_CHARS) { clearDetected(); return; }
+ // When translating as you type, the translation response already carries
+ // detectedLanguage. Firing /detect as well would double the traffic to a
+ // public mirror to learn something we are about to be told anyway.
+ if (uiState.autoTranslate) return;
detectTimer = setTimeout(runDetect, DETECT_DEBOUNCE_MS);
}
+ // Translate as the text settles.
+ //
+ // Until now the panel only translated on Enter, the Translate button, or a
+ // language change — typing did nothing at all, which is what "it doesn't
+ // auto translate" meant. The debounce is deliberately shorter than the
+ // detect one: this is the thing the user is waiting to see.
+ const AUTO_DEBOUNCE_MS = 500;
+ let autoTimer = null;
+ // What the visible translation corresponds to. Guards against re-sending
+ // identical work when the text is edited back to where it was, or when a
+ // language change lands on a pair we already have.
+ let translatedKey = "";
+ const currentKey = () => `${srcSel.value}|${tgtSel.value}|${inp.value.trim()}`;
+ function scheduleAuto() {
+ clearTimeout(autoTimer);
+ if (!uiState.autoTranslate) return;
+ if (!inp.value.trim()) return;
+ if (currentKey() === translatedKey) return;
+ if (srcSel.value !== "auto" && srcSel.value === tgtSel.value) return;
+ autoTimer = setTimeout(() => doTranslate({ auto: true }), AUTO_DEBOUNCE_MS);
+ }
+ function cancelPending() { clearTimeout(autoTimer); clearTimeout(detectTimer); }
+
function setStatus(text, cls = "") {
status.textContent = text;
status.className = "m" + (cls ? " " + cls : "");
@@ -370,9 +442,15 @@
// " setTimeout(scheduleDetect, 0));
+ // Translate (and, when auto-translate is off, detect) as the text settles.
+ // Paste fires input too, but the value is only current after the event-loop
+ // turn, hence the deferred handler.
+ function onInput() { scheduleDetect(); scheduleAuto(); }
+ inp.addEventListener("input", onInput);
+ inp.addEventListener("paste", () => setTimeout(onInput, 0));
$("copyOut").addEventListener("click", async () => {
if (out.classList.contains("empty") || !out.textContent) return;
try {
@@ -450,6 +541,12 @@
setTimeout(() => setStatus("ready"), 1200);
} catch (e) { setStatus("copy failed", "err"); }
});
+ autoChk.addEventListener("change", () => {
+ uiState.autoTranslate = autoChk.checked;
+ saveUi();
+ if (uiState.autoTranslate) scheduleAuto();
+ else { cancelPending(); scheduleDetect(); } // fall back to standalone detection
+ });
gearBtn.addEventListener("click", () => settings.classList.toggle("open"));
backendSel.addEventListener("change", async () => { onBackendChange(); await saveSettings(); });
ltUrl.addEventListener("change", saveSettings);
@@ -463,6 +560,7 @@
// Naming a source explicitly retires the detection; going back to
// Auto-detect re-runs it against whatever is in the box.
if (srcSel.value !== "auto") clearDetected(); else scheduleDetect();
+ paintDetectRow(); // the row is only shown under Auto
await saveUi();
if (!inp.value.trim()) return; // nothing to re-translate
const src = srcSel.value, tgt = tgtSel.value;