langcoach 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +152 -76
- package/dist/index.js +1136 -64
- package/extension/_locales/en/messages.json +1117 -0
- package/extension/_locales/es/messages.json +1117 -0
- package/extension/_locales/fr/messages.json +1117 -0
- package/extension/_locales/pt_BR/messages.json +1117 -0
- package/extension/_locales/tr/messages.json +1117 -0
- package/extension/assets/sidepanel-DMjVhmRR.css +1729 -0
- package/extension/background.js +1584 -0
- package/extension/chunks/translate-CM6Phtg_.js +9112 -0
- package/extension/content-isolated.js +934 -0
- package/extension/content-main.js +140 -0
- package/extension/icons/128.png +0 -0
- package/extension/icons/16.png +0 -0
- package/extension/icons/32.png +0 -0
- package/extension/icons/48.png +0 -0
- package/extension/manifest.json +63 -0
- package/extension/sidepanel.html +14 -0
- package/extension/sidepanel.js +8314 -0
- package/package.json +3 -2
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
(function() {
|
|
2
|
+
/** At most `max` UTF-16 code units (the core/router limit), never ending on half a surrogate pair. */
|
|
3
|
+
function capUnits(s, max) {
|
|
4
|
+
if (s.length <= max) return s;
|
|
5
|
+
const cut = s.slice(0, max);
|
|
6
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
7
|
+
return last >= 55296 && last <= 56319 ? cut.slice(0, -1) : cut;
|
|
8
|
+
}
|
|
9
|
+
var STATES = [
|
|
10
|
+
"available",
|
|
11
|
+
"downloadable",
|
|
12
|
+
"downloading",
|
|
13
|
+
"unavailable"
|
|
14
|
+
];
|
|
15
|
+
[...Object.values({
|
|
16
|
+
english: "en",
|
|
17
|
+
ingilizce: "en",
|
|
18
|
+
"i̇ngilizce": "en",
|
|
19
|
+
inglés: "en",
|
|
20
|
+
ingles: "en",
|
|
21
|
+
anglais: "en",
|
|
22
|
+
inglês: "en",
|
|
23
|
+
turkish: "tr",
|
|
24
|
+
türkçe: "tr",
|
|
25
|
+
turkce: "tr",
|
|
26
|
+
turco: "tr",
|
|
27
|
+
turc: "tr",
|
|
28
|
+
spanish: "es",
|
|
29
|
+
español: "es",
|
|
30
|
+
espanol: "es",
|
|
31
|
+
espagnol: "es",
|
|
32
|
+
espanhol: "es",
|
|
33
|
+
french: "fr",
|
|
34
|
+
français: "fr",
|
|
35
|
+
francais: "fr",
|
|
36
|
+
francés: "fr",
|
|
37
|
+
francês: "fr",
|
|
38
|
+
portuguese: "pt",
|
|
39
|
+
português: "pt",
|
|
40
|
+
portugues: "pt",
|
|
41
|
+
portugués: "pt",
|
|
42
|
+
german: "de",
|
|
43
|
+
deutsch: "de",
|
|
44
|
+
arabic: "ar",
|
|
45
|
+
chinese: "zh",
|
|
46
|
+
japanese: "ja",
|
|
47
|
+
korean: "ko",
|
|
48
|
+
russian: "ru",
|
|
49
|
+
hindi: "hi",
|
|
50
|
+
italian: "it",
|
|
51
|
+
italiano: "it",
|
|
52
|
+
dutch: "nl",
|
|
53
|
+
polish: "pl",
|
|
54
|
+
ukrainian: "uk",
|
|
55
|
+
vietnamese: "vi",
|
|
56
|
+
indonesian: "id",
|
|
57
|
+
greek: "el",
|
|
58
|
+
hebrew: "he",
|
|
59
|
+
persian: "fa",
|
|
60
|
+
urdu: "ur",
|
|
61
|
+
bengali: "bn",
|
|
62
|
+
thai: "th"
|
|
63
|
+
})];
|
|
64
|
+
var globalApi = () => {
|
|
65
|
+
const g = globalThis;
|
|
66
|
+
return "Translator" in globalThis ? g.Translator : void 0;
|
|
67
|
+
};
|
|
68
|
+
function createTranslatorCache(api, getGlobal = globalApi) {
|
|
69
|
+
const getApi = () => api ?? getGlobal();
|
|
70
|
+
/** Pair key → translator being created / created (a failed create is forgotten so it can be retried). */
|
|
71
|
+
const translators = /* @__PURE__ */ new Map();
|
|
72
|
+
const results = /* @__PURE__ */ new Map();
|
|
73
|
+
const key = (src, dst) => `${src}\u0000${dst}`;
|
|
74
|
+
async function available(src, dst) {
|
|
75
|
+
try {
|
|
76
|
+
const a = getApi();
|
|
77
|
+
if (!a || typeof a.availability !== "function") return "unavailable";
|
|
78
|
+
const r = await a.availability({
|
|
79
|
+
sourceLanguage: src,
|
|
80
|
+
targetLanguage: dst
|
|
81
|
+
});
|
|
82
|
+
return STATES.includes(r) ? r : "unavailable";
|
|
83
|
+
} catch {
|
|
84
|
+
return "unavailable";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Starts `create` synchronously (keeps the click's user activation); caches the promise. */
|
|
88
|
+
function create(src, dst, onProgress) {
|
|
89
|
+
const k = key(src, dst);
|
|
90
|
+
const existing = translators.get(k);
|
|
91
|
+
if (existing) return existing;
|
|
92
|
+
let p;
|
|
93
|
+
try {
|
|
94
|
+
const a = getApi();
|
|
95
|
+
if (!a || typeof a.create !== "function") return Promise.resolve(null);
|
|
96
|
+
p = Promise.resolve(a.create({
|
|
97
|
+
sourceLanguage: src,
|
|
98
|
+
targetLanguage: dst,
|
|
99
|
+
monitor(m) {
|
|
100
|
+
try {
|
|
101
|
+
m.addEventListener("downloadprogress", (e) => {
|
|
102
|
+
const loaded = e.loaded;
|
|
103
|
+
if (typeof loaded === "number" && onProgress) onProgress(loaded);
|
|
104
|
+
});
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
})).then((t) => t && typeof t.translate === "function" ? t : null, () => null);
|
|
108
|
+
} catch {
|
|
109
|
+
p = Promise.resolve(null);
|
|
110
|
+
}
|
|
111
|
+
translators.set(k, p);
|
|
112
|
+
p.then((t) => {
|
|
113
|
+
if (!t && translators.get(k) === p) translators.delete(k);
|
|
114
|
+
});
|
|
115
|
+
return p;
|
|
116
|
+
}
|
|
117
|
+
async function ensureReady(src, dst, opts) {
|
|
118
|
+
if (opts.userGesture) return await create(src, dst, opts.onProgress) ? "available" : "unavailable";
|
|
119
|
+
const k = key(src, dst);
|
|
120
|
+
if (translators.has(k)) return await translators.get(k) ? "available" : "unavailable";
|
|
121
|
+
const a = await available(src, dst);
|
|
122
|
+
if (a === "downloadable") return "needs_download";
|
|
123
|
+
if (a !== "available") return a;
|
|
124
|
+
return await create(src, dst) ? "available" : "unavailable";
|
|
125
|
+
}
|
|
126
|
+
async function translateOne(t, text) {
|
|
127
|
+
try {
|
|
128
|
+
const out = await t.translate(text);
|
|
129
|
+
if (typeof out !== "string") return null;
|
|
130
|
+
const trimmed = out.trim();
|
|
131
|
+
return trimmed === "" ? null : capUnits(trimmed, 500);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function translateMany(texts, src, dst) {
|
|
137
|
+
try {
|
|
138
|
+
const clean = (Array.isArray(texts) ? texts : []).map((s) => typeof s === "string" ? s.trim() : "");
|
|
139
|
+
if (!clean.some((s) => s !== "")) return clean.map(() => null);
|
|
140
|
+
if (await ensureReady(src, dst, { userGesture: false }) !== "available") return clean.map(() => null);
|
|
141
|
+
const t = await create(src, dst);
|
|
142
|
+
if (!t) return clean.map(() => null);
|
|
143
|
+
const unique = [...new Set(clean.filter((s) => s !== ""))];
|
|
144
|
+
for (const s of unique) {
|
|
145
|
+
const rk = `${key(src, dst)}\u0000${s}`;
|
|
146
|
+
if (!results.has(rk)) {
|
|
147
|
+
const p = translateOne(t, s);
|
|
148
|
+
results.set(rk, p);
|
|
149
|
+
p.then((r) => {
|
|
150
|
+
if (r === null) results.delete(rk);
|
|
151
|
+
});
|
|
152
|
+
await p;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return Promise.all(clean.map((s) => s === "" ? null : results.get(`${key(src, dst)}\u0000${s}`) ?? null));
|
|
156
|
+
} catch {
|
|
157
|
+
return (Array.isArray(texts) ? texts : []).map(() => null);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
available,
|
|
162
|
+
ensureReady,
|
|
163
|
+
translateMany
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/content/native-overlay.ts
|
|
168
|
+
/**
|
|
169
|
+
* Best-effort native-language line under LLL's own meanings ("coffee" → "kahve"). Finds leaf elements
|
|
170
|
+
* whose visible text equals a saved English meaning (or, when no meaning element exists for an entry,
|
|
171
|
+
* the term) and inserts one small marked span right after them. LLL's own nodes are never edited; a
|
|
172
|
+
* debounced MutationObserver re-applies after LLL re-renders. Every failure is swallowed.
|
|
173
|
+
*/
|
|
174
|
+
var NATIVE_ATTR = "data-langcoach-native";
|
|
175
|
+
var SKIP = /* @__PURE__ */ new Set([
|
|
176
|
+
"SCRIPT",
|
|
177
|
+
"STYLE",
|
|
178
|
+
"NOSCRIPT",
|
|
179
|
+
"TEMPLATE",
|
|
180
|
+
"TEXTAREA",
|
|
181
|
+
"INPUT",
|
|
182
|
+
"SELECT",
|
|
183
|
+
"OPTION",
|
|
184
|
+
"TITLE",
|
|
185
|
+
"HEAD"
|
|
186
|
+
]);
|
|
187
|
+
var MAX_ENTRIES = 1e3;
|
|
188
|
+
var MAX_TEXT = 500;
|
|
189
|
+
var norm$1 = (s) => s.replace(/\s+/g, " ").trim();
|
|
190
|
+
var termVariants = (term) => {
|
|
191
|
+
const n = norm$1(term);
|
|
192
|
+
return n.includes(" / ") ? [n, norm$1(n.split(" / ").join(" "))] : [n];
|
|
193
|
+
};
|
|
194
|
+
var isOurs = (el) => el.hasAttribute("data-langcoach-native") || el.closest("[data-langcoach-toast]") !== null;
|
|
195
|
+
function createNativeOverlay(doc, opts) {
|
|
196
|
+
const debounceMs = opts.debounceMs ?? 250;
|
|
197
|
+
/** meaning key → native; term key → native (fallback only). */
|
|
198
|
+
const byMeaning = /* @__PURE__ */ new Map();
|
|
199
|
+
const byTerm = /* @__PURE__ */ new Map();
|
|
200
|
+
let enabled = true;
|
|
201
|
+
let destroyed = false;
|
|
202
|
+
let timer;
|
|
203
|
+
let observer;
|
|
204
|
+
function makeSpan(key, native) {
|
|
205
|
+
const span = doc.createElement("span");
|
|
206
|
+
span.className = "langcoach-native";
|
|
207
|
+
span.setAttribute(NATIVE_ATTR, key);
|
|
208
|
+
span.setAttribute("dir", "auto");
|
|
209
|
+
span.setAttribute("lang", opts.lang);
|
|
210
|
+
span.style.display = "block";
|
|
211
|
+
span.style.fontSize = "0.85em";
|
|
212
|
+
span.style.lineHeight = "1.3";
|
|
213
|
+
span.style.opacity = "0.72";
|
|
214
|
+
span.style.marginTop = "2px";
|
|
215
|
+
span.style.fontStyle = "normal";
|
|
216
|
+
span.textContent = native;
|
|
217
|
+
return span;
|
|
218
|
+
}
|
|
219
|
+
function removeAll() {
|
|
220
|
+
for (const s of [...doc.querySelectorAll(`[${NATIVE_ATTR}]`)]) s.remove();
|
|
221
|
+
}
|
|
222
|
+
/** Puts `native` right after `el` (updating our existing span there); returns true if the DOM changed. */
|
|
223
|
+
function place(el, key, native) {
|
|
224
|
+
const next = el.nextElementSibling;
|
|
225
|
+
if (next && next.hasAttribute("data-langcoach-native")) {
|
|
226
|
+
if (next.getAttribute("data-langcoach-native") !== key) next.setAttribute(NATIVE_ATTR, key);
|
|
227
|
+
if (next.textContent !== native) next.textContent = native;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
el.after(makeSpan(key, native));
|
|
231
|
+
}
|
|
232
|
+
function apply() {
|
|
233
|
+
try {
|
|
234
|
+
if (destroyed || !doc.body) return;
|
|
235
|
+
if (!enabled || byMeaning.size === 0 && byTerm.size === 0) {
|
|
236
|
+
if (doc.querySelector(`[data-langcoach-native]`)) removeAll();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const meaningHits = [];
|
|
240
|
+
const termHits = [];
|
|
241
|
+
for (const el of doc.body.querySelectorAll("*")) {
|
|
242
|
+
if (el.childElementCount !== 0 || SKIP.has(el.tagName) || isOurs(el)) continue;
|
|
243
|
+
const text = el.textContent;
|
|
244
|
+
if (!text || text.length > MAX_TEXT * 2) continue;
|
|
245
|
+
const k = norm$1(text);
|
|
246
|
+
const m = byMeaning.get(k);
|
|
247
|
+
if (m !== void 0) {
|
|
248
|
+
meaningHits.push([
|
|
249
|
+
el,
|
|
250
|
+
k,
|
|
251
|
+
m
|
|
252
|
+
]);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const t = byTerm.get(k);
|
|
256
|
+
if (t !== void 0) termHits.push([
|
|
257
|
+
el,
|
|
258
|
+
k,
|
|
259
|
+
t.native,
|
|
260
|
+
t.meaning
|
|
261
|
+
]);
|
|
262
|
+
}
|
|
263
|
+
const keep = /* @__PURE__ */ new Set();
|
|
264
|
+
const shownMeanings = new Set(meaningHits.map(([, k]) => k));
|
|
265
|
+
for (const [el, k, native] of meaningHits) {
|
|
266
|
+
place(el, k, native);
|
|
267
|
+
keep.add(el.nextElementSibling);
|
|
268
|
+
}
|
|
269
|
+
for (const [el, k, native, meaning] of termHits) {
|
|
270
|
+
if (shownMeanings.has(meaning)) continue;
|
|
271
|
+
place(el, k, native);
|
|
272
|
+
keep.add(el.nextElementSibling);
|
|
273
|
+
}
|
|
274
|
+
for (const s of [...doc.querySelectorAll(`[${NATIVE_ATTR}]`)]) if (!keep.has(s)) s.remove();
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
function schedule() {
|
|
278
|
+
if (destroyed) return;
|
|
279
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
280
|
+
timer = setTimeout(() => {
|
|
281
|
+
timer = void 0;
|
|
282
|
+
apply();
|
|
283
|
+
}, debounceMs);
|
|
284
|
+
}
|
|
285
|
+
function observe() {
|
|
286
|
+
if (observer || destroyed) return;
|
|
287
|
+
try {
|
|
288
|
+
const MO = doc.defaultView?.MutationObserver;
|
|
289
|
+
if (!MO || !doc.body) return;
|
|
290
|
+
observer = new MO((records) => {
|
|
291
|
+
if (records.some((r) => {
|
|
292
|
+
const nodes = [...r.addedNodes, ...r.removedNodes];
|
|
293
|
+
if (r.type === "characterData") {
|
|
294
|
+
const p = r.target.parentElement;
|
|
295
|
+
return !(p && p.hasAttribute("data-langcoach-native"));
|
|
296
|
+
}
|
|
297
|
+
if (r.type === "childList" && r.target instanceof Element && r.target.hasAttribute("data-langcoach-native")) return false;
|
|
298
|
+
return nodes.length === 0 || nodes.some((n) => !(n instanceof Element && n.hasAttribute("data-langcoach-native")));
|
|
299
|
+
})) schedule();
|
|
300
|
+
});
|
|
301
|
+
observer.observe(doc.body, {
|
|
302
|
+
childList: true,
|
|
303
|
+
subtree: true,
|
|
304
|
+
characterData: true
|
|
305
|
+
});
|
|
306
|
+
} catch {
|
|
307
|
+
observer = void 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
add(entries) {
|
|
312
|
+
try {
|
|
313
|
+
if (destroyed || !Array.isArray(entries)) return;
|
|
314
|
+
for (const e of entries) {
|
|
315
|
+
if (!e || typeof e.meaning !== "string" || typeof e.native !== "string") continue;
|
|
316
|
+
const native = capUnits(norm$1(e.native), MAX_TEXT);
|
|
317
|
+
const meaning = norm$1(e.meaning);
|
|
318
|
+
if (!native || !meaning || meaning.length > MAX_TEXT) continue;
|
|
319
|
+
if (byMeaning.size >= MAX_ENTRIES && !byMeaning.has(meaning)) continue;
|
|
320
|
+
byMeaning.set(meaning, native);
|
|
321
|
+
if (typeof e.term === "string" && e.term.trim()) {
|
|
322
|
+
for (const v of termVariants(e.term)) if (v !== meaning) byTerm.set(v, {
|
|
323
|
+
native,
|
|
324
|
+
meaning
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
apply();
|
|
329
|
+
observe();
|
|
330
|
+
} catch {}
|
|
331
|
+
},
|
|
332
|
+
setEnabled(next) {
|
|
333
|
+
enabled = next;
|
|
334
|
+
apply();
|
|
335
|
+
},
|
|
336
|
+
destroy() {
|
|
337
|
+
destroyed = true;
|
|
338
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
339
|
+
observer?.disconnect();
|
|
340
|
+
observer = void 0;
|
|
341
|
+
try {
|
|
342
|
+
removeAll();
|
|
343
|
+
} catch {}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
var TINY_LESSON_PATH = /^\/lll\/[a-z]{2}(?:[-_][a-z]{2,4})?\/experiments\/tiny-lesson\/?$/i;
|
|
348
|
+
var SITE_HOME_PATH = /^\/lll\/[a-z]{2}(?:[-_][a-z]{2,4})?\/?$/i;
|
|
349
|
+
/** "Generate" in the LLL site languages (en, es, fr, pt). */
|
|
350
|
+
var GENERATE_TEXT = /generat|generar|g[ée]n[ée]rer|gerar/i;
|
|
351
|
+
var isTinyLessonPage = (win) => TINY_LESSON_PATH.test(win.location.pathname);
|
|
352
|
+
/** Rendered (has a layout box) and interactive. */
|
|
353
|
+
function isUsable(el) {
|
|
354
|
+
const h = el;
|
|
355
|
+
if (h.disabled || h.hidden || el.closest("[hidden], [aria-hidden=\"true\"], [inert]")) return false;
|
|
356
|
+
return el.getClientRects().length > 0;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The Tiny Lesson purpose field: exactly one usable `input` whose type is text and that is not the
|
|
360
|
+
* language combobox (live markup: no form, no maxLength, no type attribute, rotating placeholder).
|
|
361
|
+
* Anything else is ambiguous, so null; never "the first input on the page".
|
|
362
|
+
*/
|
|
363
|
+
function findPurposeField(win) {
|
|
364
|
+
const candidates = [...win.document.querySelectorAll("input")].filter((el) => el.type === "text" && el.getAttribute("role") !== "combobox" && isUsable(el));
|
|
365
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Writes `theme` into the Tiny Lesson purpose field so that React registers it: the value goes through the
|
|
369
|
+
* element's own prototype setter (bypassing React's instance-level value tracker), then a bubbling `input`
|
|
370
|
+
* event. Returns false (and changes nothing) outside the Tiny Lesson page or when the field is not found.
|
|
371
|
+
*/
|
|
372
|
+
function fillPurposeField(win, theme) {
|
|
373
|
+
if (!isTinyLessonPage(win)) return false;
|
|
374
|
+
const el = findPurposeField(win);
|
|
375
|
+
return el ? writeValue(win, el, theme) : false;
|
|
376
|
+
}
|
|
377
|
+
function writeValue(win, el, theme) {
|
|
378
|
+
const w = win;
|
|
379
|
+
const limit = el.maxLength > 0 ? Math.min(el.maxLength, 50) : 50;
|
|
380
|
+
const value = [...theme].slice(0, limit).join("");
|
|
381
|
+
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
|
|
382
|
+
if (!setter) return false;
|
|
383
|
+
el.focus();
|
|
384
|
+
setter.call(el, value);
|
|
385
|
+
el.dispatchEvent(new w.Event("input", { bubbles: true }));
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
var norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
389
|
+
function languageCombobox(win) {
|
|
390
|
+
return [...win.document.querySelectorAll("[role=\"combobox\"]")].find(isUsable) ?? null;
|
|
391
|
+
}
|
|
392
|
+
/** First visit: the intro splash ("Start" → site home) or the "Before you begin" notice (checkbox). */
|
|
393
|
+
function isSplash(win) {
|
|
394
|
+
if (languageCombobox(win)) return false;
|
|
395
|
+
const doc = win.document;
|
|
396
|
+
const toHome = [...doc.querySelectorAll("a[href]")].some((a) => {
|
|
397
|
+
try {
|
|
398
|
+
const u = new URL(a.href, win.location.href);
|
|
399
|
+
return u.origin === win.location.origin && SITE_HOME_PATH.test(u.pathname) && isUsable(a);
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
const consent = [...doc.querySelectorAll("input[type=\"checkbox\"]")].some(isUsable);
|
|
405
|
+
return toHome || consent;
|
|
406
|
+
}
|
|
407
|
+
function selectControl(combo) {
|
|
408
|
+
return combo.closest("[class*=\"__control\"]") ?? combo.closest("[class*=\"control\"]");
|
|
409
|
+
}
|
|
410
|
+
function selectRoot(combo) {
|
|
411
|
+
return combo.closest("[class*=\"-container\"]") ?? selectControl(combo)?.parentElement ?? null;
|
|
412
|
+
}
|
|
413
|
+
function currentLanguage(combo) {
|
|
414
|
+
return norm(selectControl(combo)?.querySelector("[class*=\"single-value\"]")?.textContent);
|
|
415
|
+
}
|
|
416
|
+
async function waitFor(win, probe, timing) {
|
|
417
|
+
const end = Date.now() + timing.timeoutMs;
|
|
418
|
+
for (;;) {
|
|
419
|
+
const v = probe();
|
|
420
|
+
if (v) return v;
|
|
421
|
+
if (Date.now() >= end) return null;
|
|
422
|
+
await new Promise((r) => win.setTimeout(r, timing.stepMs));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function mouse(win, el, type) {
|
|
426
|
+
const w = win;
|
|
427
|
+
el.dispatchEvent(new w.MouseEvent(type, {
|
|
428
|
+
bubbles: true,
|
|
429
|
+
cancelable: true,
|
|
430
|
+
button: 0
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
/** Chooses `label` in the react-select picker; true when the control then shows it. */
|
|
434
|
+
async function chooseLanguage(win, combo, label, timing) {
|
|
435
|
+
const want = norm(label);
|
|
436
|
+
if (currentLanguage(combo) === want) return true;
|
|
437
|
+
const control = selectControl(combo);
|
|
438
|
+
if (!control) return false;
|
|
439
|
+
mouse(win, control, "mousedown");
|
|
440
|
+
const options = await waitFor(win, () => {
|
|
441
|
+
const list = [...win.document.querySelectorAll("[role=\"listbox\"] [role=\"option\"]")];
|
|
442
|
+
return list.length > 0 ? list : null;
|
|
443
|
+
}, timing);
|
|
444
|
+
const option = options?.find((o) => norm(o.textContent) === want);
|
|
445
|
+
if (!option) {
|
|
446
|
+
if (options) mouse(win, control, "mousedown");
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
mouse(win, option, "mousedown");
|
|
450
|
+
mouse(win, option, "mouseup");
|
|
451
|
+
option.click();
|
|
452
|
+
return await waitFor(win, () => currentLanguage(combo) === want, timing) === true;
|
|
453
|
+
}
|
|
454
|
+
/** The Generate button: after the purpose field, outside the picker, labelled Generate (or the only one). */
|
|
455
|
+
function findGenerate(win, purpose, combo) {
|
|
456
|
+
const picker = combo ? selectRoot(combo) : null;
|
|
457
|
+
const after = [...win.document.querySelectorAll("button")].filter((b) => purpose.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING && !(picker && picker.contains(b)) && b.getClientRects().length > 0);
|
|
458
|
+
const named = after.find((b) => GENERATE_TEXT.test(b.textContent ?? "") || GENERATE_TEXT.test(b.getAttribute("aria-label") ?? ""));
|
|
459
|
+
if (named) return named;
|
|
460
|
+
for (let box = purpose.parentElement; box && box !== win.document.body; box = box.parentElement) {
|
|
461
|
+
const inBox = after.filter((b) => box.contains(b));
|
|
462
|
+
if (inBox.length === 1) return inBox[0];
|
|
463
|
+
if (inBox.length > 1) return null;
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
var enabled = (b) => !b.disabled && b.getAttribute("aria-disabled") !== "true";
|
|
468
|
+
/**
|
|
469
|
+
* Starts a Tiny Lesson for `theme`: (a) splash → reason 'splash'; (b) select the target language when it
|
|
470
|
+
* differs; (c) fill the purpose (≤50 chars, React-safe); (d) wait ≤timeout for Generate, click it.
|
|
471
|
+
*/
|
|
472
|
+
async function startScenario(win, req, timingIn = {}) {
|
|
473
|
+
const timing = {
|
|
474
|
+
timeoutMs: timingIn.timeoutMs ?? 3e3,
|
|
475
|
+
stepMs: timingIn.stepMs ?? 50
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
if (!isTinyLessonPage(win)) return {
|
|
479
|
+
ok: false,
|
|
480
|
+
reason: "not_tiny_lesson"
|
|
481
|
+
};
|
|
482
|
+
await waitFor(win, () => isSplash(win) || !!findPurposeField(win), timing);
|
|
483
|
+
if (isSplash(win)) return {
|
|
484
|
+
ok: false,
|
|
485
|
+
reason: "splash"
|
|
486
|
+
};
|
|
487
|
+
if (!findPurposeField(win)) return {
|
|
488
|
+
ok: false,
|
|
489
|
+
reason: "field"
|
|
490
|
+
};
|
|
491
|
+
const combo = languageCombobox(win);
|
|
492
|
+
if (req.targetLabel) {
|
|
493
|
+
if (!combo || !await chooseLanguage(win, combo, req.targetLabel, timing)) return {
|
|
494
|
+
ok: false,
|
|
495
|
+
reason: "language"
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const purpose = findPurposeField(win);
|
|
499
|
+
if (!purpose || !writeValue(win, purpose, req.theme)) return {
|
|
500
|
+
ok: false,
|
|
501
|
+
reason: "field"
|
|
502
|
+
};
|
|
503
|
+
const button = await waitFor(win, () => {
|
|
504
|
+
const b = findGenerate(win, purpose, combo);
|
|
505
|
+
return b && enabled(b) ? b : null;
|
|
506
|
+
}, timing);
|
|
507
|
+
if (!button) return {
|
|
508
|
+
ok: false,
|
|
509
|
+
reason: "generate"
|
|
510
|
+
};
|
|
511
|
+
button.click();
|
|
512
|
+
return { ok: true };
|
|
513
|
+
} catch {
|
|
514
|
+
return {
|
|
515
|
+
ok: false,
|
|
516
|
+
reason: "error"
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/content/protocol.ts
|
|
522
|
+
/** Attribute the isolated script sets on <html> to hand the per-page nonce to the MAIN-world script. */
|
|
523
|
+
var NONCE_ATTR = "data-langcoach-nonce";
|
|
524
|
+
//#endregion
|
|
525
|
+
//#region src/content/toast.ts
|
|
526
|
+
var TOAST_MS = 6e3;
|
|
527
|
+
var CSS = `
|
|
528
|
+
:host { all: initial; }
|
|
529
|
+
.box {
|
|
530
|
+
position: fixed;
|
|
531
|
+
left: 16px;
|
|
532
|
+
bottom: 16px;
|
|
533
|
+
z-index: 2147483647;
|
|
534
|
+
max-width: 360px;
|
|
535
|
+
box-sizing: border-box;
|
|
536
|
+
display: flex;
|
|
537
|
+
flex-wrap: wrap;
|
|
538
|
+
align-items: center;
|
|
539
|
+
gap: 8px 12px;
|
|
540
|
+
padding: 10px 14px;
|
|
541
|
+
border-radius: 14px;
|
|
542
|
+
font: 500 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
543
|
+
color: #1d2433;
|
|
544
|
+
background: #ffffff;
|
|
545
|
+
border: 1px solid #d9dee8;
|
|
546
|
+
box-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);
|
|
547
|
+
}
|
|
548
|
+
.text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
|
|
549
|
+
.actions { display: flex; gap: 6px; }
|
|
550
|
+
button {
|
|
551
|
+
font: inherit;
|
|
552
|
+
font-weight: 600;
|
|
553
|
+
cursor: pointer;
|
|
554
|
+
padding: 4px 10px;
|
|
555
|
+
border-radius: 999px;
|
|
556
|
+
border: 1px solid #b9c3d6;
|
|
557
|
+
background: #f2f5fb;
|
|
558
|
+
color: #1a4fd6;
|
|
559
|
+
}
|
|
560
|
+
button:hover { background: #e4ebfa; }
|
|
561
|
+
button:focus-visible { outline: 3px solid #1a4fd6; outline-offset: 2px; }
|
|
562
|
+
@media (prefers-color-scheme: dark) {
|
|
563
|
+
.box { color: #e8ecf4; background: #1f2430; border-color: #3a4256; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); }
|
|
564
|
+
button { color: #9ec0ff; background: #2a3142; border-color: #4a5470; }
|
|
565
|
+
button:hover { background: #343c50; }
|
|
566
|
+
button:focus-visible { outline-color: #9ec0ff; }
|
|
567
|
+
}
|
|
568
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
569
|
+
.box { animation: in 160ms ease-out; }
|
|
570
|
+
@keyframes in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
571
|
+
}
|
|
572
|
+
`;
|
|
573
|
+
/**
|
|
574
|
+
* One toast at a time, bottom-left (clear of the lesson page's main buttons), in a closed shadow
|
|
575
|
+
* root on a host appended to <html>. Auto-hides after 6 s; hovering or focusing it pauses the timer.
|
|
576
|
+
*/
|
|
577
|
+
function createToaster(doc = document, duration = TOAST_MS) {
|
|
578
|
+
let host = null;
|
|
579
|
+
let root = null;
|
|
580
|
+
let timer;
|
|
581
|
+
let deadline = 0;
|
|
582
|
+
let remaining = duration;
|
|
583
|
+
let hovered = false;
|
|
584
|
+
let focused = false;
|
|
585
|
+
const clear = () => {
|
|
586
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
587
|
+
timer = void 0;
|
|
588
|
+
};
|
|
589
|
+
const start = (ms) => {
|
|
590
|
+
clear();
|
|
591
|
+
remaining = ms;
|
|
592
|
+
deadline = Date.now() + ms;
|
|
593
|
+
timer = setTimeout(hide, ms);
|
|
594
|
+
};
|
|
595
|
+
const pause = () => {
|
|
596
|
+
if (timer === void 0) return;
|
|
597
|
+
remaining = Math.max(0, deadline - Date.now());
|
|
598
|
+
clear();
|
|
599
|
+
};
|
|
600
|
+
const resume = () => {
|
|
601
|
+
if (!hovered && !focused && host) start(remaining);
|
|
602
|
+
};
|
|
603
|
+
function hide() {
|
|
604
|
+
clear();
|
|
605
|
+
try {
|
|
606
|
+
host?.remove();
|
|
607
|
+
} catch {}
|
|
608
|
+
host = null;
|
|
609
|
+
root = null;
|
|
610
|
+
hovered = focused = false;
|
|
611
|
+
}
|
|
612
|
+
function show(spec) {
|
|
613
|
+
hide();
|
|
614
|
+
const h = doc.createElement("div");
|
|
615
|
+
h.setAttribute("data-langcoach-toast", "");
|
|
616
|
+
const r = h.attachShadow({ mode: "closed" });
|
|
617
|
+
const style = doc.createElement("style");
|
|
618
|
+
style.textContent = CSS;
|
|
619
|
+
const box = doc.createElement("div");
|
|
620
|
+
box.className = "box";
|
|
621
|
+
box.setAttribute("role", "status");
|
|
622
|
+
box.setAttribute("aria-live", "polite");
|
|
623
|
+
const text = doc.createElement("span");
|
|
624
|
+
text.className = "text";
|
|
625
|
+
text.textContent = spec.text;
|
|
626
|
+
box.append(text);
|
|
627
|
+
if (spec.actions?.length) {
|
|
628
|
+
const actions = doc.createElement("span");
|
|
629
|
+
actions.className = "actions";
|
|
630
|
+
for (const a of spec.actions) {
|
|
631
|
+
const b = doc.createElement("button");
|
|
632
|
+
b.type = "button";
|
|
633
|
+
b.textContent = a.label;
|
|
634
|
+
b.addEventListener("click", (e) => {
|
|
635
|
+
e.stopPropagation();
|
|
636
|
+
a.onClick();
|
|
637
|
+
});
|
|
638
|
+
actions.append(b);
|
|
639
|
+
}
|
|
640
|
+
box.append(actions);
|
|
641
|
+
}
|
|
642
|
+
r.append(style, box);
|
|
643
|
+
h.addEventListener("mouseenter", () => {
|
|
644
|
+
hovered = true;
|
|
645
|
+
pause();
|
|
646
|
+
});
|
|
647
|
+
h.addEventListener("mouseleave", () => {
|
|
648
|
+
hovered = false;
|
|
649
|
+
resume();
|
|
650
|
+
});
|
|
651
|
+
h.addEventListener("focusin", () => {
|
|
652
|
+
focused = true;
|
|
653
|
+
pause();
|
|
654
|
+
});
|
|
655
|
+
h.addEventListener("focusout", () => {
|
|
656
|
+
focused = false;
|
|
657
|
+
resume();
|
|
658
|
+
});
|
|
659
|
+
doc.documentElement.append(h);
|
|
660
|
+
host = h;
|
|
661
|
+
root = r;
|
|
662
|
+
start(duration);
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
show,
|
|
666
|
+
hide,
|
|
667
|
+
get root() {
|
|
668
|
+
return root;
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
//#endregion
|
|
673
|
+
//#region src/content/isolated.ts
|
|
674
|
+
var KINDS = [
|
|
675
|
+
"terms",
|
|
676
|
+
"grammar",
|
|
677
|
+
"conversation",
|
|
678
|
+
"object-descriptors"
|
|
679
|
+
];
|
|
680
|
+
var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
681
|
+
function randomNonce(win) {
|
|
682
|
+
const b = /* @__PURE__ */ new Uint8Array(16);
|
|
683
|
+
win.crypto.getRandomValues(b);
|
|
684
|
+
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Isolated-world entry (document_start). Publishes a per-page nonce for the MAIN-world script, accepts
|
|
688
|
+
* only same-window, same-origin captures carrying that nonce, relays them to the background and shows
|
|
689
|
+
* the result as a toast. A page script that reads the nonce could still forge a capture; the worst it
|
|
690
|
+
* can do is add vocabulary to the learner's own deck.
|
|
691
|
+
*/
|
|
692
|
+
function startIsolated(deps) {
|
|
693
|
+
const { win, t } = deps;
|
|
694
|
+
const toaster = deps.toaster ?? createToaster(win.document);
|
|
695
|
+
const nonce = randomNonce(win);
|
|
696
|
+
win.document.documentElement.setAttribute(NONCE_ATTR, nonce);
|
|
697
|
+
/** Resolves to the response data, or undefined on any failure (errors stay in the extension). */
|
|
698
|
+
async function ask(msg) {
|
|
699
|
+
try {
|
|
700
|
+
const res = await deps.sendMessage(msg);
|
|
701
|
+
return res && res.ok ? res.data : void 0;
|
|
702
|
+
} catch {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const openAction = () => ({
|
|
707
|
+
label: t("toastOpen"),
|
|
708
|
+
onClick: () => {
|
|
709
|
+
ask({ type: "openPanel" });
|
|
710
|
+
toaster.hide();
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
const translator = deps.translator ?? createTranslatorCache();
|
|
714
|
+
let overlay = null;
|
|
715
|
+
let overlayLang = "";
|
|
716
|
+
let showOnPage = true;
|
|
717
|
+
function overlayFor(lang) {
|
|
718
|
+
if (!overlay || overlayLang !== lang) {
|
|
719
|
+
overlay?.destroy();
|
|
720
|
+
overlay = createNativeOverlay(win.document, {
|
|
721
|
+
lang,
|
|
722
|
+
debounceMs: deps.overlayDebounceMs
|
|
723
|
+
});
|
|
724
|
+
overlayLang = lang;
|
|
725
|
+
}
|
|
726
|
+
overlay.setEnabled(showOnPage);
|
|
727
|
+
return overlay;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
deps.onShowNativeChanged?.((show) => {
|
|
731
|
+
showOnPage = show;
|
|
732
|
+
overlay?.setEnabled(show);
|
|
733
|
+
});
|
|
734
|
+
} catch {}
|
|
735
|
+
const str = (v) => typeof v === "string" && v.trim() !== "";
|
|
736
|
+
/**
|
|
737
|
+
* After the background saved a capture: translate meanings that have no native translation yet (only
|
|
738
|
+
* when the model is already on this device; no download without a click), store them, and show them
|
|
739
|
+
* under LLL's meanings. Grammar examples are only shown, not stored. Any failure leaves English as is.
|
|
740
|
+
*/
|
|
741
|
+
async function applyNative(r) {
|
|
742
|
+
try {
|
|
743
|
+
const hint = r.native;
|
|
744
|
+
if (!isObj(hint) || !str(hint.source) || !str(hint.target)) return;
|
|
745
|
+
const { source, target } = hint;
|
|
746
|
+
if (typeof hint.overlay === "boolean") showOnPage = hint.overlay;
|
|
747
|
+
const shown = [];
|
|
748
|
+
if (r.status === "saved") {
|
|
749
|
+
const items = (Array.isArray(hint.items) ? hint.items : []).filter((i) => isObj(i) && str(i.cardId) && str(i.meaning)).map((i) => ({
|
|
750
|
+
cardId: i.cardId,
|
|
751
|
+
term: typeof i.term === "string" ? i.term : "",
|
|
752
|
+
meaning: i.meaning,
|
|
753
|
+
native: str(i.native) ? i.native : void 0
|
|
754
|
+
}));
|
|
755
|
+
if (items.length === 0) return;
|
|
756
|
+
const missing = items.filter((i) => !i.native);
|
|
757
|
+
const out = missing.length ? await translator.translateMany(missing.map((i) => i.meaning), source, target) : [];
|
|
758
|
+
const entries = [];
|
|
759
|
+
missing.forEach((i, k) => {
|
|
760
|
+
const text = out[k];
|
|
761
|
+
if (text) {
|
|
762
|
+
i.native = text;
|
|
763
|
+
entries.push({
|
|
764
|
+
cardId: i.cardId,
|
|
765
|
+
text
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
if (entries.length) ask({
|
|
770
|
+
type: "setNativeTranslations",
|
|
771
|
+
entries: entries.slice(0, 500)
|
|
772
|
+
});
|
|
773
|
+
for (const i of items) if (i.native) shown.push({
|
|
774
|
+
term: i.term,
|
|
775
|
+
meaning: i.meaning,
|
|
776
|
+
native: i.native
|
|
777
|
+
});
|
|
778
|
+
} else if (r.status === "grammar") {
|
|
779
|
+
const examples = (Array.isArray(hint.examples) ? hint.examples : []).filter(str).slice(0, 100);
|
|
780
|
+
const out = examples.length ? await translator.translateMany(examples, source, target) : [];
|
|
781
|
+
examples.forEach((ex, k) => {
|
|
782
|
+
const text = out[k];
|
|
783
|
+
if (text) shown.push({
|
|
784
|
+
meaning: ex,
|
|
785
|
+
native: text
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (shown.length) overlayFor(target).add(shown);
|
|
790
|
+
} catch {}
|
|
791
|
+
}
|
|
792
|
+
function withXp(text, xp) {
|
|
793
|
+
return xp > 0 ? `${text} · ${t("toastXp", String(xp))}` : text;
|
|
794
|
+
}
|
|
795
|
+
function showResult(r, capture) {
|
|
796
|
+
if (!isObj(r)) return;
|
|
797
|
+
if (r.status === "saved" || r.status === "grammar") applyNative(r);
|
|
798
|
+
if (r.status === "pending") {
|
|
799
|
+
toaster.show({
|
|
800
|
+
text: t("extName"),
|
|
801
|
+
actions: [{
|
|
802
|
+
label: t("toastSaveN", String(r.count)),
|
|
803
|
+
onClick: () => {
|
|
804
|
+
toaster.hide();
|
|
805
|
+
ask({
|
|
806
|
+
...capture,
|
|
807
|
+
force: true
|
|
808
|
+
}).then((res) => showResult(res, capture));
|
|
809
|
+
}
|
|
810
|
+
}]
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (r.status !== "saved") return;
|
|
815
|
+
const sent = (text) => r.forwarded === true ? `${text} · ${t("toastForwarded")}` : text;
|
|
816
|
+
if (r.newCards === 0) {
|
|
817
|
+
toaster.show({
|
|
818
|
+
text: withXp(sent(t("toastAlready")), r.xp),
|
|
819
|
+
actions: [openAction()]
|
|
820
|
+
});
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
const lessonIds = r.lessonIds;
|
|
824
|
+
toaster.show({
|
|
825
|
+
text: withXp(sent(t("toastSaved", String(r.newCards))), r.xp),
|
|
826
|
+
actions: [{
|
|
827
|
+
label: t("toastUndo"),
|
|
828
|
+
onClick: () => {
|
|
829
|
+
toaster.hide();
|
|
830
|
+
ask({
|
|
831
|
+
type: "undo",
|
|
832
|
+
lessonIds
|
|
833
|
+
}).then((u) => {
|
|
834
|
+
if (u) toaster.show({ text: t("toastRemoved") });
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}, openAction()]
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
win.addEventListener("message", (e) => {
|
|
841
|
+
try {
|
|
842
|
+
if (e.source !== win || e.origin !== win.location.origin) return;
|
|
843
|
+
const d = e.data;
|
|
844
|
+
if (!isObj(d) || d.nonce !== nonce) return;
|
|
845
|
+
if (d.source === "langcoach-ack") {
|
|
846
|
+
win.document.documentElement.removeAttribute(NONCE_ATTR);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (d.source !== "langcoach-capture" || !KINDS.includes(d.kind)) return;
|
|
850
|
+
const capture = {
|
|
851
|
+
type: "capture",
|
|
852
|
+
kind: d.kind,
|
|
853
|
+
request: d.request,
|
|
854
|
+
response: d.response,
|
|
855
|
+
pageUrl: win.location.href
|
|
856
|
+
};
|
|
857
|
+
ask(capture).then((r) => showResult(r, capture));
|
|
858
|
+
} catch {}
|
|
859
|
+
});
|
|
860
|
+
deps.onMessage.addListener((msg, sender, sendResponse) => {
|
|
861
|
+
if (sender?.id !== deps.runtimeId || !isObj(msg)) return;
|
|
862
|
+
if (msg.type === "ping") {
|
|
863
|
+
sendResponse?.({ ready: true });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (msg.type === "startScenario") {
|
|
867
|
+
if (!(typeof msg.theme === "string" && msg.theme.trim() !== "" && (msg.targetLabel === void 0 || typeof msg.targetLabel === "string"))) {
|
|
868
|
+
sendResponse?.({
|
|
869
|
+
ok: false,
|
|
870
|
+
reason: "invalid"
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
startScenario(win, {
|
|
875
|
+
theme: msg.theme,
|
|
876
|
+
...typeof msg.targetLabel === "string" && msg.targetLabel ? { targetLabel: msg.targetLabel } : {}
|
|
877
|
+
}).catch(() => ({
|
|
878
|
+
ok: false,
|
|
879
|
+
reason: "error"
|
|
880
|
+
})).then((r) => {
|
|
881
|
+
try {
|
|
882
|
+
sendResponse?.(r);
|
|
883
|
+
} catch {}
|
|
884
|
+
});
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
if (msg.type === "fillTheme" && typeof msg.theme === "string") {
|
|
888
|
+
let filled = false;
|
|
889
|
+
try {
|
|
890
|
+
filled = fillPurposeField(win, msg.theme);
|
|
891
|
+
} catch {}
|
|
892
|
+
sendResponse?.({ filled });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (msg.type !== "toast") return;
|
|
896
|
+
if (msg.variant === "manual") toaster.show({
|
|
897
|
+
text: t("toastSavedManual"),
|
|
898
|
+
actions: [openAction()]
|
|
899
|
+
});
|
|
900
|
+
else if (msg.variant === "already") toaster.show({
|
|
901
|
+
text: t("toastAlready"),
|
|
902
|
+
actions: [openAction()]
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
return {
|
|
906
|
+
nonce,
|
|
907
|
+
/** Removes the native-language lines and stops observing the page (tests, extension reload). */
|
|
908
|
+
dispose: () => {
|
|
909
|
+
overlay?.destroy();
|
|
910
|
+
overlay = null;
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/content-isolated.ts
|
|
916
|
+
/** Same key as background/router.ts SETTINGS_KEY (not imported: keeps the background out of this bundle). */
|
|
917
|
+
var SETTINGS_KEY = "langcoach:settings";
|
|
918
|
+
startIsolated({
|
|
919
|
+
win: window,
|
|
920
|
+
runtimeId: chrome.runtime.id,
|
|
921
|
+
sendMessage: (msg) => chrome.runtime.sendMessage(msg),
|
|
922
|
+
onMessage: chrome.runtime.onMessage,
|
|
923
|
+
t: (key, subs) => chrome.i18n.getMessage(key, subs),
|
|
924
|
+
onShowNativeChanged: (cb) => {
|
|
925
|
+
try {
|
|
926
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
927
|
+
const next = changes[SETTINGS_KEY]?.newValue;
|
|
928
|
+
if (area === "local" && next && typeof next === "object") cb(next.showNativeOnPage !== false);
|
|
929
|
+
});
|
|
930
|
+
} catch {}
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
//#endregion
|
|
934
|
+
})();
|