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.
628 lines
30 KiB
HTML
628 lines
30 KiB
HTML
<!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>
|