translate 0.1.6 -> 0.1.7: translate as you type, and say what was detected

Typing never triggered a translation. The input handler was wired only to the
language detector, so nothing happened until Enter, the Translate button or a
language change -- the panel looked broken to anyone who simply typed and
waited. Translation now runs 500 ms after the text settles.

Each request carries a sequence number. Typing outruns the network, and
without that guard a slower earlier response paints its stale text over a
newer one.

Detection already worked, but wrote its answer only into the text of the
collapsed "Auto-detect" option, which a closed select hides -- so it read as
"detection is broken" when it had in fact succeeded. Name the language in a
row under the selects, and lower the 12-character floor to 5, since short
phrases never cleared it. With translate-as-you-type on, skip the standalone
detect call: the translation response already carries the detected language,
so asking twice only doubled the load on a public mirror.

Two of the three fallback mirrors were dead, checked by posting to both
endpoints: translate.plausibility.cloud answers 405, and lingva.ml serves an
HTML page because it is not a LibreTranslate API at all, so it could only ever
produce the "HTML instead of JSON" failure. Drop both and add
silentmode.st/libre, which stays a fallback rather than the default because it
covers six languages against disroot's fifty.

Auto-translate sends text to a translation server as it is typed, so it is a
visible toggle rather than silent behaviour. Default on.
This commit is contained in:
Local Dev 2026-10-22 15:59:09 +03:00
parent 3b2cad491d
commit 93e54ca348
4 changed files with 161 additions and 20 deletions

View file

@ -16,6 +16,36 @@ Releases are no longer recorded here: the live version and hashes are in
`../site/releases-manifest.json` and `https://dl.silentmode.st/releases-manifest.json`, `../site/releases-manifest.json` and `https://dl.silentmode.st/releases-manifest.json`,
and each release commit is `Theseus 0.3.x: ...` in git history. and each release commit is `Theseus 0.3.x: ...` in git history.
## session: `2026-10-22:translate-auto`
translate 0.1.6 → **0.1.7**. **PUBLISHED 2026-10-22** to the extension OTA
channel (`bns/theseus/extensions/translate/`), no Theseus release needed —
but the source is **still uncommitted**, so git does not yet contain the code
that is live. Commit before anyone rebuilds from this tree.
- [bundled-addons/translate/panel.html](bundled-addons/translate/panel.html) —
translate-as-you-type (500 ms debounce, sequence-guarded so a slow earlier
request cannot paint over a newer one), a visible "Detected: German" row
under the selects, `DETECT_MIN_CHARS` 12 → 5, a "Translate as I type" toggle
(default on, persisted in `ui`).
- [bundled-addons/translate/index.js](bundled-addons/translate/index.js) —
fallback mirrors corrected: dropped `translate.plausibility.cloud` (405) and
`lingva.ml` (serves HTML; it is not a LibreTranslate API), added
`silentmode.st/libre`.
**Why:** typing never triggered a translation at all — only Enter, the button
or a language change did. Detection *did* work, but was written only into the
text of the collapsed `<option>`, where nothing shows it.
Verified against a real Theseus, 6/6 checks (`scratchpad/tr-verify.mjs`), and
the published artifact was pulled back over the network and staged by a real
Theseus (`scratchpad/tr-ota2.mjs`).
Noticed while publishing, **not fixed**: `consent` and `blocker` both carry an
`updateURL` with no channel behind it, so every update check spends two
requests on a guaranteed 404:
`https://navigate.st/bns/theseus.x/extensions/{consent,blocker}/updates.json`.
## session: `parallel:collision-policy` ## session: `parallel:collision-policy`
Owner's focus: BCNR ↔ ICANN name collision handling. Not mine — leave for Owner's focus: BCNR ↔ ICANN name collision handling. Not mine — leave for

View file

@ -1,14 +1,18 @@
{ {
"id": "translate", "id": "translate",
"name": "Translate", "name": "Translate",
"version": "0.1.6", "version": "0.1.7",
"description": "Right-click a selection to translate it. Sidebar panel with LibreTranslate or Google as the backend.", "description": "Translate as you type, with the detected language named in the panel. Right-click a selection to send it here. LibreTranslate or Google as the backend.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEuNSIgZmlsbD0iIzFkNGVkOCIvPiA8cmVjdCB4PSIwIiB5PSIwLjYiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiNmZmYiLz4gPHRleHQgeD0iNi43IiB5PSIxMi4xIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic3lzdGVtLXVpLFNlZ29lIFVJLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iMTIuNCIgZm9udC13ZWlnaHQ9IjgwMCIgZmlsbD0iIzFkNGVkOCI+QTwvdGV4dD4gPHJlY3QgeD0iMTAuNiIgeT0iMTAiIHdpZHRoPSIxMy40IiBoZWlnaHQ9IjEzLjQiIHJ4PSIyLjgiIGZpbGw9IiMwYjEyMjAiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjEiLz4gPGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj4gPHBhdGggZD0iTTE3LjMgMTIuMnYxLjEiLz4gPHBhdGggZD0iTTEzLjUgMTQuNmg3LjYiLz4gPHBhdGggZD0iTTE4LjIgMTUuNyAxMy45IDIxIi8+IDxwYXRoIGQ9Ik0xNi41IDE1LjcgMjAuOCAyMSIvPiA8L2c+IDwvc3ZnPg==",
"main": "index.js", "main": "index.js",
"activation": "on-demand", "activation": "on-demand",
"panels": [ "panels": [
{ "id": "main", "title": "Translate", "page": "panel.html" } {
"id": "main",
"title": "Translate",
"page": "panel.html"
}
], ],
"capabilities": [ "capabilities": [
"sidebar-panel", "sidebar-panel",

View file

@ -12,10 +12,19 @@
// user's chosen URL fails. Kept short on purpose — three tries is plenty to // user's chosen URL fails. Kept short on purpose — three tries is plenty to
// route around one mirror being down, and we don't want to spam a chain of // route around one mirror being down, and we don't want to spam a chain of
// public instances for one click. // public instances for one click.
// Checked 2026-10-22 by POSTing /translate and /detect to each:
// translate.disroot.org 200, detects + translates, 50 languages
// silentmode.st/libre 200, our own instance, 6 languages (en/fr/de/el/ru/es)
// Removed at the same time, both verified broken rather than merely slow:
// translate.plausibility.cloud 405 on both endpoints
// lingva.ml 200 but serves an HTML page — it is not a
// LibreTranslate API at all, so it could only
// ever produce the "HTML instead of JSON" error
// Our own instance is second, not first: it answers reliably but only covers
// six languages, so it is the right safety net and the wrong default.
const LT_FALLBACKS = [ const LT_FALLBACKS = [
"https://translate.disroot.org", "https://translate.disroot.org",
"https://translate.plausibility.cloud", "https://silentmode.st/libre",
"https://lingva.ml",
]; ];
function looksLikeHtml(s) { function looksLikeHtml(s) {

View file

@ -51,6 +51,13 @@
.langs .swap { padding: 6px 8px; background: var(--btn); color: var(--ink); .langs .swap { padding: 6px 8px; background: var(--btn); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font: inherit; } border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font: inherit; }
.langs .swap:hover { background: var(--btn-h); } .langs .swap:hover { background: var(--btn-h); }
/* The detected language used to live only in the text of the collapsed
"Auto-detect" <option>, where a closed <select> hides it — which is why
detection looked broken even when it had worked. Say it in the open. */
.det { padding: 6px 12px; border-bottom: 1px solid var(--line);
background: var(--panel2); color: var(--mut); font-size: 11.5px; }
.det b { color: var(--ink); font-weight: 600; }
.det .q { color: var(--dim); }
.body { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .body { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.zone { display: flex; flex-direction: column; flex: 1; min-height: 0; } .zone { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.zone + .zone { border-top: 1px solid var(--line); } .zone + .zone { border-top: 1px solid var(--line); }
@ -90,6 +97,11 @@
} }
.settings.open { display: flex; } .settings.open { display: flex; }
.settings label { display: flex; flex-direction: column; gap: 4px; color: var(--dim); font-size: 11px; } .settings label { display: flex; flex-direction: column; gap: 4px; color: var(--dim); font-size: 11px; }
/* Checkbox rows read as a row, not a stacked field like the text inputs. */
.settings label.chk { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;
align-items: center; color: var(--ink); font-size: 12px; }
.settings label.chk input { width: 15px; height: 15px; padding: 0; accent-color: var(--acid); }
.settings label.chk .hint2 { grid-column: 2; color: var(--dim); font-size: 11px; }
.settings input, .settings select { padding: 6px 8px; font: inherit; .settings input, .settings select { padding: 6px 8px; font: inherit;
background: var(--bg); color: var(--ink); background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; } border: 1px solid var(--line); border-radius: 6px; }
@ -117,6 +129,7 @@
<button class="swap" id="swap" title="Swap languages">⇄</button> <button class="swap" id="swap" title="Swap languages">⇄</button>
<select id="tgt" title="Target language"></select> <select id="tgt" title="Target language"></select>
</div> </div>
<div class="det" id="det" hidden></div>
<div class="body"> <div class="body">
<div class="zone"> <div class="zone">
@ -135,6 +148,11 @@
</div> </div>
<div class="settings" id="settings"> <div class="settings" id="settings">
<label class="chk">
<input type="checkbox" id="autoTranslate" checked />
Translate as I type
<span class="hint2">Off means nothing is sent until you press Translate or Enter.</span>
</label>
<label> <label>
Backend Backend
<select id="backend"> <select id="backend">
@ -147,8 +165,7 @@
<input id="ltUrl" type="url" placeholder="https://translate.disroot.org" list="ltMirrors" /> <input id="ltUrl" type="url" placeholder="https://translate.disroot.org" list="ltMirrors" />
<datalist id="ltMirrors"> <datalist id="ltMirrors">
<option value="https://translate.disroot.org"></option> <option value="https://translate.disroot.org"></option>
<option value="https://translate.plausibility.cloud"></option> <option value="https://silentmode.st/libre"></option>
<option value="https://lingva.ml"></option>
</datalist> </datalist>
</label> </label>
<label id="ltKeyLabel"> <label id="ltKeyLabel">
@ -158,7 +175,9 @@
<div class="hint"> <div class="hint">
LibreTranslate is a free MIT-licensed engine. Public instances rate-limit — if you 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 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.
<code>silentmode.st/libre</code> is ours and answers reliably, but only covers
English, French, German, Greek, Russian and Spanish. Google (unofficial) uses
<code>translate.googleapis.com/translate_a/single</code> — no key, but unofficial <code>translate.googleapis.com/translate_a/single</code> — no key, but unofficial
and Google can break it at any time. and Google can break it at any time.
</div> </div>
@ -170,7 +189,7 @@
const srcSel = $("src"), tgtSel = $("tgt"); const srcSel = $("src"), tgtSel = $("tgt");
const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear"); const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear");
const settings = $("settings"); const settings = $("settings");
const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey"); const backendSel = $("backend"), ltUrl = $("ltUrl"), ltKey = $("ltKey"), autoChk = $("autoTranslate");
const ltUrlLabel = $("ltUrlLabel"), ltKeyLabel = $("ltKeyLabel"); const ltUrlLabel = $("ltUrlLabel"), ltKeyLabel = $("ltKeyLabel");
// ISO-639-1 short list. First `auto` for source only. Users can add more // ISO-639-1 short list. First `auto` for source only. Users can add more
@ -220,6 +239,25 @@
function paintAutoLabel() { function paintAutoLabel() {
const opt = srcSel.querySelector('option[value="auto"]'); const opt = srcSel.querySelector('option[value="auto"]');
if (opt) opt.textContent = autoLabel(); 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) { function fillSelect(el, includeAuto) {
@ -260,7 +298,7 @@
// current working default so the panel isn't broken out of the box. // current working default so the panel isn't broken out of the box.
const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]); const DEAD_LT_URLS = new Set(["https://translate.argosopentech.com"]);
const settingsState = { backend: "libretranslate", ltUrl: DEFAULT_LT_URL, ltKey: "" }; 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 // Populate the selects now that uiState exists — fillSelect reads the
// frequency table to build the pinned group, so it cannot run before this. // frequency table to build the pinned group, so it cannot run before this.
@ -301,8 +339,11 @@
ltKey.value = settingsState.ltKey; ltKey.value = settingsState.ltKey;
srcSel.value = uiState.src; srcSel.value = uiState.src;
tgtSel.value = uiState.tgt; tgtSel.value = uiState.tgt;
if (typeof uiState.autoTranslate !== "boolean") uiState.autoTranslate = true;
autoChk.checked = uiState.autoTranslate;
applyZoom(); applyZoom();
onBackendChange(); onBackendChange();
paintDetectRow();
} }
async function saveSettings() { async function saveSettings() {
settingsState.backend = backendSel.value; settingsState.backend = backendSel.value;
@ -327,7 +368,11 @@
// is not enough for a detector to be right, and firing per keystroke would // 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 — // hammer a public mirror for no benefit. Nothing here blocks translating —
// a failed or slow detect just leaves the label alone. // 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; let detectTimer = null, detectSeq = 0;
function setDetected(code, confidence) { function setDetected(code, confidence) {
if (code === detectedCode && confidence === detectedConfidence) return; if (code === detectedCode && confidence === detectedConfidence) return;
@ -356,9 +401,36 @@
clearTimeout(detectTimer); clearTimeout(detectTimer);
if (srcSel.value !== "auto") { clearDetected(); return; } if (srcSel.value !== "auto") { clearDetected(); return; }
if (inp.value.trim().length < DETECT_MIN_CHARS) { 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); 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 = "") { function setStatus(text, cls = "") {
status.textContent = text; status.textContent = text;
status.className = "m" + (cls ? " " + cls : ""); status.className = "m" + (cls ? " " + cls : "");
@ -370,9 +442,15 @@
// "<!doctype …". Doing the fetch server-side (via the add-on's index.js) // "<!doctype …". Doing the fetch server-side (via the add-on's index.js)
// sidesteps that whole class of problem and lets index.js fall through a // sidesteps that whole class of problem and lets index.js fall through a
// small mirror list if the user's URL is down. // small mirror list if the user's URL is down.
async function doTranslate() { // Every in-flight translation carries a sequence number. Typing fires these
// faster than the network answers, and without this the slower of two
// overlapping requests could paint its stale text over the newer one.
let translateSeq = 0;
async function doTranslate(opts) {
const auto = !!(opts && opts.auto);
clearTimeout(autoTimer);
const text = inp.value.trim(); const text = inp.value.trim();
if (!text) { setStatus("nothing to translate"); return; } if (!text) { if (!auto) setStatus("nothing to translate"); return; }
const source = srcSel.value, target = tgtSel.value; const source = srcSel.value, target = tgtSel.value;
if (source && source !== "auto" && source === target) { if (source && source !== "auto" && source === target) {
setStatus("source = target", "err"); setStatus("source = target", "err");
@ -380,9 +458,13 @@
out.classList.remove("empty"); out.classList.remove("empty");
return; return;
} }
goBtn.disabled = true; const orig = goBtn.textContent; const seq = ++translateSeq;
const key = currentKey();
goBtn.disabled = true;
goBtn.textContent = "Translating…"; goBtn.textContent = "Translating…";
setStatus("translating…"); setStatus("translating…");
// Leave the previous translation on screen while the new one is in
// flight: blanking it on every keystroke made the pane flicker.
out.classList.remove("empty"); out.classList.remove("empty");
try { try {
const res = await window.silentmode.invoke("translate", { const res = await window.silentmode.invoke("translate", {
@ -391,7 +473,9 @@
ltUrl: settingsState.ltUrl, ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey, ltKey: settingsState.ltKey,
}); });
if (seq !== translateSeq) return; // a newer edit already superseded this
out.textContent = res.text || "(empty response)"; out.textContent = res.text || "(empty response)";
translatedKey = key;
const via = res.via || settingsState.backend; const via = res.via || settingsState.backend;
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok"); setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
// Count only translations that actually landed. Counting on every // Count only translations that actually landed. Counting on every
@ -407,12 +491,15 @@
noteLangUse(source === "auto" ? res.detected : source); noteLangUse(source === "auto" ? res.detected : source);
if (topLangs().join(",") !== before) refillLangSelects(); if (topLangs().join(",") !== before) refillLangSelects();
} catch (e) { } catch (e) {
if (seq !== translateSeq) return;
out.textContent = ""; out.textContent = "";
out.classList.add("empty"); out.classList.add("empty");
out.textContent = "Failed: " + (e?.message || String(e)); out.textContent = "Failed: " + (e?.message || String(e));
setStatus("failed", "err"); setStatus("failed", "err");
} finally { } finally {
goBtn.disabled = false; goBtn.textContent = orig; // Only the newest request owns the button, or an overlapping older one
// would re-enable it while a translation is still running.
if (seq === translateSeq) { goBtn.disabled = false; goBtn.textContent = "Translate"; }
} }
saveUi(); saveUi();
} }
@ -434,14 +521,18 @@
inp.value = ""; out.textContent = "The translation will appear here."; inp.value = ""; out.textContent = "The translation will appear here.";
out.classList.add("empty"); out.classList.add("empty");
setStatus("ready"); setStatus("ready");
clearTimeout(detectTimer); cancelPending();
translateSeq++; // orphan anything still in flight
translatedKey = "";
clearDetected(); clearDetected();
inp.focus(); inp.focus();
}); });
// Detect as the text settles, and on paste (which fires input too, but the // Translate (and, when auto-translate is off, detect) as the text settles.
// value is only current after the event loop turn). // Paste fires input too, but the value is only current after the event-loop
inp.addEventListener("input", scheduleDetect); // turn, hence the deferred handler.
inp.addEventListener("paste", () => setTimeout(scheduleDetect, 0)); function onInput() { scheduleDetect(); scheduleAuto(); }
inp.addEventListener("input", onInput);
inp.addEventListener("paste", () => setTimeout(onInput, 0));
$("copyOut").addEventListener("click", async () => { $("copyOut").addEventListener("click", async () => {
if (out.classList.contains("empty") || !out.textContent) return; if (out.classList.contains("empty") || !out.textContent) return;
try { try {
@ -450,6 +541,12 @@
setTimeout(() => setStatus("ready"), 1200); setTimeout(() => setStatus("ready"), 1200);
} catch (e) { setStatus("copy failed", "err"); } } 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")); gearBtn.addEventListener("click", () => settings.classList.toggle("open"));
backendSel.addEventListener("change", async () => { onBackendChange(); await saveSettings(); }); backendSel.addEventListener("change", async () => { onBackendChange(); await saveSettings(); });
ltUrl.addEventListener("change", saveSettings); ltUrl.addEventListener("change", saveSettings);
@ -463,6 +560,7 @@
// Naming a source explicitly retires the detection; going back to // Naming a source explicitly retires the detection; going back to
// Auto-detect re-runs it against whatever is in the box. // Auto-detect re-runs it against whatever is in the box.
if (srcSel.value !== "auto") clearDetected(); else scheduleDetect(); if (srcSel.value !== "auto") clearDetected(); else scheduleDetect();
paintDetectRow(); // the row is only shown under Auto
await saveUi(); await saveUi();
if (!inp.value.trim()) return; // nothing to re-translate if (!inp.value.trim()) return; // nothing to re-translate
const src = srcSel.value, tgt = tgtSel.value; const src = srcSel.value, tgt = tgtSel.value;