Both selects grow a "Frequently used" optgroup above "All languages", holding up to ten entries ranked by how often each language has actually been translated to or from. Counted on a successful translation, not on a dropdown change: picking your way down the list looking for something would otherwise rank every language you skimmed past as highly as the ones you work in. A detected source counts too — with Auto-detect on you never pick that language explicitly, but it is one you read. Ties break on the language's display name so the order is stable rather than dependent on object key order. Counts live in the existing uiState, so they persist through the saveUi/loadState path already there, and the group only appears once there is something to put in it. Verified in the harness: one translation records exactly one use for the target and one for the detected source; the cap holds at ten with fifteen tracked; and a pre-seeded profile comes back with its group, target and zoom restored, Auto-detect still first in the source select.
460 lines
21 KiB
HTML
460 lines
21 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); }
|
||
.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; }
|
||
.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="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>
|
||
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://translate.plausibility.cloud"></option>
|
||
<option value="https://lingva.ml"></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. 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");
|
||
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);
|
||
}
|
||
function fillSelect(el, includeAuto) {
|
||
const keep = el.value;
|
||
el.innerHTML = "";
|
||
if (includeAuto) el.appendChild(new Option("Auto-detect", "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: {} };
|
||
|
||
// 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;
|
||
applyZoom();
|
||
onBackendChange();
|
||
}
|
||
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";
|
||
}
|
||
|
||
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.
|
||
async function doTranslate() {
|
||
const text = inp.value.trim();
|
||
if (!text) { 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;
|
||
}
|
||
goBtn.disabled = true; const orig = goBtn.textContent;
|
||
goBtn.textContent = "Translating…";
|
||
setStatus("translating…");
|
||
out.classList.remove("empty");
|
||
try {
|
||
const res = await window.silentmode.invoke("translate", {
|
||
text, source, target,
|
||
backend: settingsState.backend,
|
||
ltUrl: settingsState.ltUrl,
|
||
ltKey: settingsState.ltKey,
|
||
});
|
||
out.textContent = res.text || "(empty response)";
|
||
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.
|
||
const before = topLangs().join(",");
|
||
noteLangUse(target);
|
||
noteLangUse(source === "auto" ? res.detected : source);
|
||
if (topLangs().join(",") !== before) refillLangSelects();
|
||
} catch (e) {
|
||
out.textContent = "";
|
||
out.classList.add("empty");
|
||
out.textContent = "Failed: " + (e?.message || String(e));
|
||
setStatus("failed", "err");
|
||
} finally {
|
||
goBtn.disabled = false; goBtn.textContent = orig;
|
||
}
|
||
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");
|
||
inp.focus();
|
||
});
|
||
$("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"); }
|
||
});
|
||
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() {
|
||
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");
|
||
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>
|