theseus/bundled-addons/translate/panel.html
Local Dev 93e54ca348 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.
2026-10-22 15:59:09 +03:00

628 lines
30 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Translate</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.55 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--line);
background: var(--panel); }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
header .t .em { font-size: 15px; }
header .m { color: var(--dim); font-size: 11.5px; }
header .m.err { color: #ff9081; }
header .m.ok { color: var(--acid); }
header .t .em { display: inline-flex; }
header .t .em svg { display: block; width: 17px; height: 17px; }
.zoom { display: flex; align-items: center; gap: 2px; margin-left: 10px; }
.zoom button {
width: 20px; height: 20px; padding: 0; line-height: 1;
display: grid; place-items: center;
background: var(--btn); color: var(--mut);
border: 1px solid var(--line); border-radius: 5px;
cursor: pointer; font: inherit; font-size: 13px;
}
.zoom button:hover:not(:disabled) { background: var(--btn-h); color: var(--ink); }
.zoom button:disabled { opacity: .35; cursor: default; }
.zoom .pct {
min-width: 34px; text-align: center; color: var(--dim);
font-size: 10.5px; font-variant-numeric: tabular-nums; cursor: pointer;
}
.zoom .pct:hover { color: var(--ink); }
.langs { display: flex; gap: 6px; align-items: center;
padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel2); }
.langs select { flex: 1; min-width: 0; padding: 6px 8px; font: inherit;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; }
.langs .swap { padding: 6px 8px; background: var(--btn); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font: inherit; }
.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; }
.zone { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.zone + .zone { border-top: 1px solid var(--line); }
.zone label { color: var(--dim); font-size: 10.5px; letter-spacing: .18em;
text-transform: uppercase; padding: 8px 14px 4px; display: flex;
justify-content: space-between; align-items: baseline; }
.zone label .action { color: var(--mut); font-size: 10.5px; cursor: pointer;
letter-spacing: normal; text-transform: none; }
.zone label .action:hover { color: var(--acid); }
/* --tsize drives both panes so zoom keeps them matched. 15px is the
default: the old 13.5px was small for reading a paragraph of a language
you don't know well, which is the whole job here. */
textarea, .output {
flex: 1; padding: 8px 14px 14px; border: none; outline: none; resize: none;
background: transparent; color: var(--ink);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font-size: var(--tsize, 15px); line-height: 1.55;
overflow-y: auto; white-space: pre-wrap; word-wrap: break-word;
}
textarea::placeholder { color: var(--dim); }
.output.empty { color: var(--dim); font-style: italic; }
.actions { display: flex; gap: 6px; padding: 8px 12px;
border-top: 1px solid var(--line); background: var(--panel); }
.actions .btn {
flex: 1; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px;
background: var(--btn); color: var(--ink); cursor: pointer; font: inherit;
}
.actions .btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
.actions .btn:hover:not(:disabled) { background: var(--btn-h); }
.actions .btn.primary:hover:not(:disabled) { filter: brightness(1.05); }
.actions .btn:disabled { opacity: .45; cursor: default; }
.actions .btn.gear { flex: 0 0 auto; padding: 8px 10px; }
.settings {
display: none; padding: 10px 14px; background: var(--panel2);
border-top: 1px solid var(--line); font-size: 12px; color: var(--mut);
flex-direction: column; gap: 8px;
}
.settings.open { display: flex; }
.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;
background: var(--bg); color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; }
.settings .hint { color: var(--dim); font-size: 11px; line-height: 1.5; }
.settings .hint code { background: var(--bg); border: 1px solid var(--line);
padding: 1px 4px; border-radius: 3px; font-size: 10.5px; }
</style>
</head>
<body>
<header>
<div class="t">
<span class="em" aria-hidden="true"><svg viewBox="0 0 24 24" width="24" height="24"> <circle cx="12" cy="12" r="11.5" fill="#1d4ed8"/> <rect x="0" y="0.6" width="13.4" height="13.4" rx="2.8" fill="#fff"/> <text x="6.7" y="12.1" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="12.4" font-weight="800" fill="#1d4ed8">A</text> <rect x="10.6" y="10" width="13.4" height="13.4" rx="2.8" fill="#0b1220" stroke="#fff" stroke-width="1.1"/> <g stroke="#fff" stroke-width="2.2" stroke-linecap="round" fill="none"> <path d="M17.3 12.2v1.1"/> <path d="M13.5 14.6h7.6"/> <path d="M18.2 15.7 13.9 21"/> <path d="M16.5 15.7 20.8 21"/> </g> </svg></span>
<span>Translate</span>
<span class="zoom">
<button id="zoomOut" title="Smaller text (Ctrl+-)" aria-label="Smaller text">−</button>
<span class="pct" id="zoomPct" title="Reset to 100%" role="button" tabindex="0">100%</span>
<button id="zoomIn" title="Larger text (Ctrl++)" aria-label="Larger text">+</button>
</span>
</div>
<div class="m" id="status">ready</div>
</header>
<div class="langs">
<select id="src" title="Source language"></select>
<button class="swap" id="swap" title="Swap languages">⇄</button>
<select id="tgt" title="Target language"></select>
</div>
<div class="det" id="det" hidden></div>
<div class="body">
<div class="zone">
<label>Source <span class="action" id="clearIn">clear</span></label>
<textarea id="in" placeholder="Right-click a selection on a page, or paste text here." spellcheck="false"></textarea>
</div>
<div class="zone">
<label>Translation <span class="action" id="copyOut">copy</span></label>
<div class="output empty" id="out">The translation will appear here.</div>
</div>
</div>
<div class="actions">
<button class="btn primary" id="go">Translate</button>
<button class="btn gear" id="gear" title="Settings">⚙</button>
</div>
<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>
Backend
<select id="backend">
<option value="libretranslate">LibreTranslate</option>
<option value="google">Google (unofficial free endpoint)</option>
</select>
</label>
<label id="ltUrlLabel">
LibreTranslate server URL
<input id="ltUrl" type="url" placeholder="https://translate.disroot.org" list="ltMirrors" />
<datalist id="ltMirrors">
<option value="https://translate.disroot.org"></option>
<option value="https://silentmode.st/libre"></option>
</datalist>
</label>
<label id="ltKeyLabel">
LibreTranslate API key (optional)
<input id="ltKey" type="text" placeholder="only if your instance requires one" />
</label>
<div class="hint">
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; 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
and Google can break it at any time.
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const inp = $("in"), out = $("out"), status = $("status");
const srcSel = $("src"), tgtSel = $("tgt");
const goBtn = $("go"), swapBtn = $("swap"), gearBtn = $("gear");
const settings = $("settings");
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
// via settings later; for v1 this covers ~99% of use.
const LANGS = [
["ar","Arabic"],["bg","Bulgarian"],["cs","Czech"],["da","Danish"],
["de","German"],["el","Greek"],["en","English"],["es","Spanish"],
["et","Estonian"],["fi","Finnish"],["fr","French"],["he","Hebrew"],
["hi","Hindi"],["hu","Hungarian"],["id","Indonesian"],["it","Italian"],
["ja","Japanese"],["ko","Korean"],["lt","Lithuanian"],["lv","Latvian"],
["nb","Norwegian"],["nl","Dutch"],["pl","Polish"],["pt","Portuguese"],
["ro","Romanian"],["ru","Russian"],["sk","Slovak"],["sl","Slovenian"],
["sv","Swedish"],["th","Thai"],["tr","Turkish"],["uk","Ukrainian"],
["vi","Vietnamese"],["zh","Chinese"],
];
const LANG_NAME = new Map(LANGS);
// Most-used languages, pinned above the full list. Ranked by use count and
// capped at RECENT_MAX so the group stays a shortcut rather than a second
// copy of the alphabet. "auto" is excluded — it is already the first row of
// the source select and is not a language anyone is "choosing".
const RECENT_MAX = 10;
function noteLangUse(code) {
if (!code || code === "auto") return;
const r = uiState.recent || (uiState.recent = {});
r[code] = (r[code] || 0) + 1;
}
function topLangs() {
const r = uiState.recent || {};
return Object.entries(r)
.filter(([c]) => LANG_NAME.has(c))
.sort((a, b) => b[1] - a[1] || LANG_NAME.get(a[0]).localeCompare(LANG_NAME.get(b[0])))
.slice(0, RECENT_MAX)
.map(([c]) => c);
}
// What the source select's first row says. Once a language has been
// detected it names it inline — "Auto-detect · German" — so the answer is
// visible where the question was asked, instead of only in the status line
// after a translation has already happened.
let detectedCode = null, detectedConfidence = null;
function autoLabel() {
if (!detectedCode) return "Auto-detect";
const name = LANG_NAME.get(detectedCode) || detectedCode;
const low = detectedConfidence !== null && detectedConfidence < 60;
return `Auto-detect · ${name}${low ? "?" : ""}`;
}
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) {
const keep = el.value;
el.innerHTML = "";
if (includeAuto) el.appendChild(new Option(autoLabel(), "auto"));
const top = topLangs();
if (top.length) {
const g = document.createElement("optgroup");
g.label = "Frequently used";
for (const c of top) g.appendChild(new Option(LANG_NAME.get(c), c));
el.appendChild(g);
const all = document.createElement("optgroup");
all.label = "All languages";
for (const [code, label] of LANGS) all.appendChild(new Option(label, code));
el.appendChild(all);
} else {
for (const [code, label] of LANGS) el.appendChild(new Option(label, code));
}
// A code appears twice once it is in the pinned group; assigning the value
// selects the first match, which is the pinned one. That is what we want.
if (keep) el.value = keep;
}
function refillLangSelects() {
const s = srcSel.value, t = tgtSel.value;
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
srcSel.value = s; tgtSel.value = t;
}
// Default target = user's browser language, falling back to English.
const browserLang = (navigator.language || "en").split("-")[0].toLowerCase();
const defaultTarget = LANGS.some(([c]) => c === browserLang) && browserLang !== "en" ? browserLang : "en";
const S = window.silentmode?.storage;
const DEFAULT_LT_URL = "https://translate.disroot.org";
// Old defaults that we now migrate off — argosopentech.com went offline.
// If a user's saved URL matches one of these, silently upgrade it to the
// 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: {}, 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.
fillSelect(srcSel, true);
fillSelect(tgtSel, false);
// ---- text zoom ---------------------------------------------------------
// Percent of the 15px base, clamped so the panes stay usable at either end.
const ZOOM_MIN = 70, ZOOM_MAX = 220, ZOOM_STEP = 10, ZOOM_BASE = 15;
function applyZoom() {
const z = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, uiState.zoom || 100));
uiState.zoom = z;
document.documentElement.style.setProperty("--tsize", (ZOOM_BASE * z / 100).toFixed(2) + "px");
$("zoomPct").textContent = z + "%";
$("zoomOut").disabled = z <= ZOOM_MIN;
$("zoomIn").disabled = z >= ZOOM_MAX;
}
function setZoom(z) { uiState.zoom = z; applyZoom(); saveUi(); }
async function loadState() {
if (!S) return;
try {
const saved = await S.get("settings", null);
if (saved && typeof saved === "object") Object.assign(settingsState, saved);
const ui = await S.get("ui", null);
if (ui && typeof ui === "object") Object.assign(uiState, ui);
} catch (e) { console.warn("load state failed:", e); }
// One-shot migration off a dead default URL.
if (DEAD_LT_URLS.has(settingsState.ltUrl)) {
settingsState.ltUrl = DEFAULT_LT_URL;
if (S) try { await S.set("settings", settingsState); } catch {}
}
// Saved counts arrived, so the pinned group can be built for real.
if (!uiState.recent || typeof uiState.recent !== "object") uiState.recent = {};
refillLangSelects();
backendSel.value = settingsState.backend;
ltUrl.value = settingsState.ltUrl;
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;
settingsState.ltUrl = ltUrl.value.trim().replace(/\/+$/, "") || DEFAULT_LT_URL;
settingsState.ltKey = ltKey.value.trim();
if (S) try { await S.set("settings", settingsState); } catch {}
}
async function saveUi() {
uiState.src = srcSel.value;
uiState.tgt = tgtSel.value;
if (S) try { await S.set("ui", uiState); } catch {}
}
function onBackendChange() {
const isLt = backendSel.value === "libretranslate";
ltUrlLabel.style.display = isLt ? "" : "none";
ltKeyLabel.style.display = isLt ? "" : "none";
}
// Detection runs on the text as it settles, so the source language is named
// before Translate is pressed. Debounced and length-gated: a couple of words
// 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.
// 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;
detectedCode = code || null;
detectedConfidence = typeof confidence === "number" ? confidence : null;
paintAutoLabel();
}
function clearDetected() { setDetected(null, null); }
async function runDetect() {
if (srcSel.value !== "auto") return;
const text = inp.value.trim();
if (text.length < DETECT_MIN_CHARS) { clearDetected(); return; }
const seq = ++detectSeq;
try {
const r = await window.silentmode.invoke("detect", {
text,
backend: settingsState.backend,
ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey,
});
if (seq !== detectSeq) return; // a newer edit already superseded this
if (r && r.language) setDetected(r.language, r.confidence);
} catch { /* detection is a nicety; never surface it as an error */ }
}
function scheduleDetect() {
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 : "");
}
// The panel used to fetch the translation server directly, but a public
// LibreTranslate mirror sitting behind a Cloudflare-style anti-bot page
// returns HTML for a POST from `Origin: file://`, and JSON.parse chokes on
// "<!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
// small mirror list if the user's URL is down.
// 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();
if (!text) { if (!auto) setStatus("nothing to translate"); return; }
const source = srcSel.value, target = tgtSel.value;
if (source && source !== "auto" && source === target) {
setStatus("source = target", "err");
out.textContent = text;
out.classList.remove("empty");
return;
}
const seq = ++translateSeq;
const key = currentKey();
goBtn.disabled = true;
goBtn.textContent = "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");
try {
const res = await window.silentmode.invoke("translate", {
text, source, target,
backend: settingsState.backend,
ltUrl: settingsState.ltUrl,
ltKey: settingsState.ltKey,
});
if (seq !== translateSeq) return; // a newer edit already superseded this
out.textContent = res.text || "(empty response)";
translatedKey = key;
const via = res.via || settingsState.backend;
setStatus(res.detected ? `${via} · detected ${res.detected}` : via, "ok");
// Count only translations that actually landed. Counting on every
// dropdown change would rank languages the user skimmed past as highly
// as the ones they work in. A detected source counts as used too — it
// is a language they read, even though they never picked it.
// The translation response carries the detection for free — take it,
// so the label is right even when the standalone detect was skipped
// (short text, Google backend, or a mirror that refused /detect).
if (source === "auto" && res.detected) setDetected(res.detected, res.confidence);
const before = topLangs().join(",");
noteLangUse(target);
noteLangUse(source === "auto" ? res.detected : source);
if (topLangs().join(",") !== before) refillLangSelects();
} catch (e) {
if (seq !== translateSeq) return;
out.textContent = "";
out.classList.add("empty");
out.textContent = "Failed: " + (e?.message || String(e));
setStatus("failed", "err");
} finally {
// 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();
}
// Buttons ---------------------------------------------------------------
goBtn.addEventListener("click", doTranslate);
swapBtn.addEventListener("click", () => {
if (srcSel.value === "auto") return;
const a = srcSel.value, b = tgtSel.value;
srcSel.value = b; tgtSel.value = a;
// Swap the text too if there's already output.
if (out.textContent && !out.classList.contains("empty")) {
inp.value = out.textContent;
out.textContent = ""; out.classList.add("empty");
}
saveUi();
});
$("clearIn").addEventListener("click", () => {
inp.value = ""; out.textContent = "The translation will appear here.";
out.classList.add("empty");
setStatus("ready");
cancelPending();
translateSeq++; // orphan anything still in flight
translatedKey = "";
clearDetected();
inp.focus();
});
// 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 {
await navigator.clipboard.writeText(out.textContent);
setStatus("copied", "ok");
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);
ltKey.addEventListener("change", saveSettings);
// Changing either language re-runs the translation straight away. Picking a
// new target and then having to hit Translate was the single most confusing
// thing about this panel: the old handler saved the choice and stopped
// there, so the pane below kept showing the previous language's text with
// no sign it was stale.
async function onLangChange() {
// 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;
if (src !== "auto" && src === tgt) return; // doTranslate would just echo
doTranslate();
}
srcSel.addEventListener("change", onLangChange);
tgtSel.addEventListener("change", onLangChange);
// Zoom controls.
$("zoomIn").addEventListener("click", () => setZoom((uiState.zoom || 100) + ZOOM_STEP));
$("zoomOut").addEventListener("click", () => setZoom((uiState.zoom || 100) - ZOOM_STEP));
$("zoomPct").addEventListener("click", () => setZoom(100));
$("zoomPct").addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setZoom(100); }
});
// Ctrl/Cmd+Enter to translate; Ctrl/Cmd +/-/0 to zoom the text panes.
// Bound on the document so it works whichever pane has focus.
document.addEventListener("keydown", (e) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key === "Enter" && e.target === inp) { e.preventDefault(); doTranslate(); return; }
// "=" is the unshifted "+" on most layouts; accept both.
if (e.key === "+" || e.key === "=") { e.preventDefault(); setZoom((uiState.zoom || 100) + ZOOM_STEP); }
else if (e.key === "-") { e.preventDefault(); setZoom((uiState.zoom || 100) - ZOOM_STEP); }
else if (e.key === "0") { e.preventDefault(); setZoom(100); }
});
// Pending selection from a right-click. When the addon dispatches
// "context-menu", the addon's index.js writes __pending to storage and
// reveals the sidebar; we pull it here.
async function drainPending() {
if (!S) return;
let pending = null;
try { pending = await S.get("__pending", null); } catch {}
if (!pending || !pending.text) return;
const age = Date.now() - (pending.at || 0);
if (age > 5 * 60_000) { try { await S.set("__pending", null); } catch {}; return; } // stale
inp.value = pending.text;
try { await S.set("__pending", null); } catch {}
setStatus(pending.host ? `from ${pending.host}` : "from page selection");
// Text arrived without a keystroke, so nothing scheduled a detect. The
// translation below reports the language anyway, but kicking one off now
// names it while that request is still in flight.
scheduleDetect();
doTranslate();
}
// First paint. loadState → drainPending, and re-drain whenever the panel
// becomes visible again (the sidebar preload dispatches a "sidebar-
// visibility" event on its window).
(async () => {
applyZoom(); // paint the control before storage answers (or if it never does)
await loadState(); // re-applies with the saved zoom once it's read
await drainPending();
inp.focus();
})();
window.addEventListener("visibilitychange", () => { if (!document.hidden) drainPending(); });
// Sidebar preload emits this custom event on the panel's window when the
// sidebar is shown/hidden — see main.js's send("sidebar-visibility").
// Not all preload versions expose it as an event; if not, visibilitychange
// above covers most cases.
</script>
</body>
</html>