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:
parent
3b2cad491d
commit
93e54ca348
4 changed files with 161 additions and 20 deletions
30
PENDING.md
30
PENDING.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue