here to handle orphan 's which otherwise
+ // get stripped by Chromium.
+ const scratch = parser.parseFromString(`
+
+
+
+
+
+
+ ${translated}
+
+ `, 'text/html');
const originalHTML = node.innerHTML;
@@ -1068,7 +1079,7 @@ export default class InPageTranslation {
});
};
- merge(node, scratch.body);
+ merge(node, scratch.body.firstElementChild.content);
};
const updateTextNode = ({id, translated}, node) => {
diff --git a/src/content/OutboundTranslation.js b/src/content/OutboundTranslation.js
index 39c816e8..2cc878b0 100644
--- a/src/content/OutboundTranslation.js
+++ b/src/content/OutboundTranslation.js
@@ -412,11 +412,14 @@ export default class OutboundTranslation {
document.body.appendChild(this.element);
- this.#restore(); // async, does focus() when time is ready
-
- this.#renderFocusRing();
+ // After painting…
+ requestIdleCallback(() => {
+ this.#restore(); // async, does focus() when time is ready
+
+ this.#renderFocusRing();
- this.#scrollIfNecessary();
+ this.#scrollIfNecessary(); // makes sure original input field is in view
+ });
}
/**
@@ -605,7 +608,7 @@ export default class OutboundTranslation {
*/
#scrollIfNecessary() {
// `20` is about a line height, `320` is line height + bottom panel
- if (!isElementInViewport(this.#target, {top: 20, bottom: 320}))
+ if (!isElementInViewport(this.#target, {top: 20, bottom: 20 + this.height}))
this.#target.scrollIntoView();
// TODO: replace this by something that prefers to scroll it closer to
diff --git a/src/content/content-script.js b/src/content/content-script.js
index c16d7f59..167885e1 100644
--- a/src/content/content-script.js
+++ b/src/content/content-script.js
@@ -1,104 +1,88 @@
import compat from '../shared/compat.js';
+import { MessageHandler } from '../shared/common.js';
import LanguageDetection from './LanguageDetection.js';
import InPageTranslation from './InPageTranslation.js';
import SelectionTranslation from './SelectionTranslation.js';
import OutboundTranslation from './OutboundTranslation.js';
import { LatencyOptimisedTranslator } from '@browsermt/bergamot-translator';
import preferences from '../shared/preferences.js';
-
-let backgroundScript;
+import { lazy } from '../shared/func.js';
const listeners = new Map();
-const state = {
- state: 'page-loaded'
-};
-
// Loading indicator for html element translation
preferences.bind('progressIndicator', progressIndicator => {
document.body.setAttribute('x-bergamot-indicator', progressIndicator);
}, {default: ''})
-function on(command, callback) {
- if (!listeners.has(command))
- listeners.set(command, []);
+preferences.bind('debug', debug => {
+ if (debug)
+ document.querySelector('html').setAttribute('x-bergamot-debug', true);
+ else
+ document.querySelector('html').removeAttribute('x-bergamot-debug');
+}, {default: false});
+
+const sessionID = new Date().getTime();
- listeners.get(command).push(callback);
+async function detectPageLanguage() {
+ // request the language detection class to extract a page's snippet
+ const languageDetection = new LanguageDetection();
+ const sample = await languageDetection.extractPageContent();
+ const suggested = languageDetection.extractSuggestedLanguages();
+
+ // Once we have the snippet, send it to background script for analysis
+ // and possibly further action (like showing the popup)
+ compat.runtime.sendMessage({
+ command: "DetectLanguage",
+ data: {
+ url: document.location.href,
+ sample,
+ suggested
+ }
+ });
}
-on('Update', diff => {
- Object.assign(state, diff);
- // document.body.dataset.xBergamotState = JSON.stringify(state);
-});
+// Changed by translation start requests.
+const state = {
+ from: null,
+ to: null
+};
-on('Update', diff => {
- if ('state' in diff) {
- switch (diff.state) {
- // Not sure why we have the page-loading event here, like, as soon
- // as frame 0 connects we know we're in page-loaded territory.
- case 'page-loading':
- backgroundScript.postMessage({
- command: 'UpdateRequest',
- data: {state: 'page-loaded'}
- });
- break;
-
- case 'translation-in-progress':
- inPageTranslation.addElement(document.querySelector("head > title"));
- inPageTranslation.addElement(document.body);
- inPageTranslation.start(state.from);
+// background-script connection is only used for translation
+let connection = lazy(async (self) => {
+ const port = compat.runtime.connect({name: 'content-script'});
+
+ // Reset lazy connection instance if port gets disconnected
+ port.onDisconnect.addListener(() => self.reset());
+
+ // Likewise, if the connection is reset from outside, disconnect port.
+ self.onReset(() => port.disconnect());
+
+ const handler = new MessageHandler(callback => {
+ port.onMessage.addListener(callback);
+ })
+
+ handler.on('TranslateResponse', data => {
+ switch (data.request.user?.source) {
+ case 'InPageTranslation':
+ inPageTranslation.enqueueTranslationResponse(data);
break;
-
- default:
- inPageTranslation.restore();
+ case 'SelectionTranslation':
+ selectionTranslation.enqueueTranslationResponse(data);
break;
}
- }
-});
-
-on('Update', async diff => {
- if ('state' in diff && diff.state === 'page-loaded') {
- // request the language detection class to extract a page's snippet
- const languageDetection = new LanguageDetection();
- const sample = await languageDetection.extractPageContent();
- const suggested = languageDetection.extractSuggestedLanguages();
-
- // Once we have the snippet, send it to background script for analysis
- // and possibly further action (like showing the popup)
- backgroundScript.postMessage({
- command: "DetectLanguage",
- data: {
- url: document.location.href,
- sample,
- suggested
- }
- });
- }
-});
+ });
-on('Update', diff => {
- if ('debug' in diff) {
- if (diff.debug)
- document.querySelector('html').setAttribute('x-bergamot-debug', JSON.stringify(state));
- else
- document.querySelector('html').removeAttribute('x-bergamot-debug');
- }
+ return port;
});
-const sessionID = new Date().getTime();
-
-// Used to track the last text selection translation request, so we don't show
-// the response to an old request by accident.
-let selectionTranslationId = null;
-
-function translate(text, user) {
- console.assert(state.from !== undefined && state.to !== undefined, "state.from or state.to is not set");
- backgroundScript.postMessage({
+async function translate(text, user) {
+ (await connection).postMessage({
command: "TranslateRequest",
data: {
// translation request
- from: state.from,
- to: state.to,
+ from: user.from || state.from,
+ to: user.to || state.to,
html: user.html,
text,
@@ -136,113 +120,96 @@ const selectionTranslation = new SelectionTranslation({
}
});
-/**
- * Matches the interface of Proxy but wraps the actual
- * translator running in the background script that we communicate with through
- * message passing. With this we can use that instance & models with the
- * LatencyOptimisedTranslator class thinking it is a Worker running the WASM
- * code.
- */
-class BackgroundScriptWorkerProxy {
- /**
- * Serial that provides a unique number for each translation request.
- * @type {Number}
- */
- #serial = 0;
-
- /**
- * Map of submitted requests and their promises waiting to be resolved.
- * @type {Map Null,
- * reject: (error:Error) => null,
- * request: Object
- * }>}
- */
- #pending = new Map();
-
- async hasTranslationModel({from, to}) {
- return true;
- }
+const outboundTranslation = new OutboundTranslation(new class ErzatsTranslatorBacking {
+ constructor() {
+ // TranslatorBacking that is really just a proxy, but mimics just enough
+ // for LatencyOptimisedTranslator to do its work.
+ const backing = {
+ async loadWorker() {
+ // Pending translation promises.
+ const pending = new Map();
- /**
- * Because `hasTranslationModel()` always returns true this function should
- * never get called.
- */
- async getTranslationModel({from, to}, options) {
- throw new Error('getTranslationModel is not expected to be called');
- }
+ // Connection to the background script. Pretty close match to
+ // the one used in the global scope, but by having a separate
+ // connection we can close either to cancel translations without
+ // affecting the others.
+ const connection = lazy(async (self) => {
+ const port = compat.runtime.connect({name: 'content-script'});
- /**
- * @param {{
- * models: {from:String, to:String}[],
- * texts: {
- * text: String,
- * html: Boolean,
- * }[]
- * }}
- * @returns {Promise<{request:TranslationRequest, target: {text: String}}>[]}
- */
- translate({models, texts}) {
- if (texts.length !== 1)
- throw new TypeError('Only batches of 1 are expected');
-
- return new Promise((accept, reject) => {
- const request = {
- // translation request
- from: models[0].from,
- to: models[0].to,
- html: texts[0].html,
- text: texts[0].text,
-
- // data useful for the response
- user: {
- id: ++this.#serial,
- source: 'OutboundTranslation'
- },
-
- // data useful for the scheduling
- priority: 3,
-
- // data useful for recording
- session: {
- id: sessionID,
- url: document.location.href
- }
- };
-
- this.#pending.set(request.user.id, {request, accept, reject});
- backgroundScript.postMessage({
- command: "TranslateRequest",
- data: request
- });
- })
- }
+ // Reset lazy connection instance if port gets disconnected
+ port.onDisconnect.addListener(() => self.reset());
- enqueueTranslationResponse({request: {user: {id}}, target, error}) {
- const {request, accept, reject} = this.#pending.get(id);
- this.#pending.delete(id);
- if (error)
- reject(error)
- else
- accept([{request, target}]);
- }
-}
+ // Likewise, if the connection is reset from outside, disconnect port.
+ self.onReset(() => port.disconnect());
-// Fake worker that really just delegates all the actual work to
-// the background script. Lives in this scope as to be able to receive
-// `TranslateResponse` messages (see down below this script.)
-const outboundTranslationWorker = new BackgroundScriptWorkerProxy();
+ const handler = new MessageHandler(callback => {
+ port.onMessage.addListener(callback);
+ })
+
+ handler.on('TranslateResponse', ({request: {user: {id}}, target, error}) => {
+ const {request, accept, reject} = pending.get(id);
+ pending.delete(id);
+
+ if (error)
+ reject(error)
+ else
+ accept([{request, target}]);
+ });
+
+ return port;
+ });
-const outboundTranslation = new OutboundTranslation(new class {
- constructor() {
- // TranslatorBacking that mimics just enough for
- // LatencyOptimisedTranslator to do its work.
- const backing = {
- async loadWorker() {
return {
- exports: outboundTranslationWorker,
+ // Mimics @browsermt/bergamot-translator/BergamotTranslatorWorker
+ exports: new class ErzatsBergamotTranslatorWorker {
+ /**
+ * Serial that provides a unique number for each translation request.
+ * @type {Number}
+ */
+ #serial = 0;
+
+ async hasTranslationModel({from, to}) {
+ return true;
+ }
+
+ async getTranslationModel({from, to}, options) {
+ throw new Error('getTranslationModel is not expected to be called');
+ }
+
+ translate({models, texts}) {
+ if (texts.length !== 1)
+ throw new TypeError('Only batches of 1 are expected');
+
+ return new Promise(async (accept, reject) => {
+ const request = {
+ from: models[0].from,
+ to: models[models.length-1].to,
+ html: texts[0].html,
+ text: texts[0].text,
+ user: {
+ id: ++this.#serial
+ },
+ priority: 3
+ };
+
+ pending.set(request.user.id, {
+ request,
+ accept,
+ reject
+ });
+
+ (await connection).postMessage({
+ command: "TranslateRequest",
+ data: request
+ });
+ })
+ }
+ },
worker: {
- terminate() { return; }
+ terminate() {
+ connection.reset();
+ pending.clear();
+ }
}
};
},
@@ -272,93 +239,42 @@ const outboundTranslation = new OutboundTranslation(new class {
}
}());
-// This one is mainly for the TRANSLATION_AVAILABLE event
-on('Update', async (diff) => {
- if ('from' in diff)
- outboundTranslation.setPageLanguage(diff.from);
+const handler = new MessageHandler(callback => {
+ compat.runtime.onMessage.addListener(callback);
+})
- const preferredLanguage = await preferences.get('preferredLanguageForOutboundTranslation');
+handler.on('TranslatePage', ({from,to}) => {
+ // Save for the translate() function
+ Object.assign(state, {from,to});
- if ('to' in diff)
- outboundTranslation.setUserLanguage(preferredLanguage || diff.to);
+ inPageTranslation.addElement(document.querySelector("head > title"));
+ inPageTranslation.addElement(document.body);
+ inPageTranslation.start(from);
+})
- if ('from' in diff || 'models' in diff) {
- outboundTranslation.setUserLanguageOptions(state.models.reduce((options, entry) => {
- // `state` has already been updated at this point as well and we know
- // that is complete. `diff` might not contain all the keys we need.
- if (entry.to === state.from && !options.has(entry.from))
- options.add(entry.from)
- return options
- }, new Set()));
- }
-});
-
-on('TranslateResponse', data => {
- switch (data.request.user?.source) {
- case 'InPageTranslation':
- inPageTranslation.enqueueTranslationResponse(data);
- break;
- case 'SelectionTranslation':
- selectionTranslation.enqueueTranslationResponse(data);
- break;
- case 'OutboundTranslation':
- outboundTranslationWorker.enqueueTranslationResponse(data);
- break;
- }
-});
-
-// Timeout of retrying connectToBackgroundScript()
-let retryTimeout = 100;
-
-function connectToBackgroundScript() {
- // If we're already connected (e.g. when this function was called directly
- // but then also through 'pageshow' event caused by 'onload') ignore it.
- if (backgroundScript)
- return;
+handler.on('RestorePage', () => {
+ // Put original content back
+ inPageTranslation.restore();
- // Connect to our background script, telling it we're the content-script.
- backgroundScript = compat.runtime.connect({name: 'content-script'});
+ // Close translator connection which will cancel pending translations.
+ connection.reset();
+})
- // Connect all message listeners (the "on()" calls above)
- backgroundScript.onMessage.addListener(({command, data}) => {
- if (listeners.has(command))
- listeners.get(command).forEach(callback => callback(data));
-
- // (We're connected, reset the timeout)
- retryTimeout = 100;
- });
-
- // When the background script disconnects, also pause in-page translation
- backgroundScript.onDisconnect.addListener(() => {
- inPageTranslation.stop();
-
- // If we cannot connect because the backgroundScript is not (yet?)
- // available, try again in a bit.
- if (backgroundScript.error && backgroundScript.error.toString().includes('Receiving end does not exist')) {
- // Exponential back-off sounds like a safe thing, right?
- retryTimeout *= 2;
-
- // Fallback fallback: if we keep retrying, stop. We're just wasting CPU at this point.
- if (retryTimeout < 5000)
- setTimeout(connectToBackgroundScript, retryTimeout);
- }
-
- // Mark as disconnected
- backgroundScript = null;
- });
-}
-
-connectToBackgroundScript();
+detectPageLanguage();
// When this page shows up (either through onload or through history navigation)
-window.addEventListener('pageshow', connectToBackgroundScript);
+window.addEventListener('pageshow', () => {
+ // TODO: inPageTranslation.resume()???
+});
// When this page disappears (either onunload, or through history navigation)
window.addEventListener('pagehide', e => {
- if (backgroundScript) {
- backgroundScript.disconnect();
- backgroundScript = null;
- }
+ // Ditch the inPageTranslation state for pending translation requests.
+ inPageTranslation.stop();
+
+ // Disconnect from the background page, which will trigger it to prune
+ // our outstanding translation requests.
+ connection.reset();
});
let lastClickedElement = null;
@@ -367,12 +283,30 @@ window.addEventListener('contextmenu', e => {
lastClickedElement = e.target;
}, {capture: true});
-on('TranslateSelection', () => {
+handler.on('TranslateSelection', ({from, to}) => {
+ Object.assign(state, {from, to}); // TODO: HACK!
const selection = document.getSelection();
selectionTranslation.start(selection);
});
-on('ShowOutboundTranslation', () => {
+handler.on('ShowOutboundTranslation', async ({from, to, models}) => {
+ if (from)
+ outboundTranslation.setPageLanguage(from);
+
+ const {preferredLanguageForOutboundTranslation} = await preferences.get({preferredLanguageForOutboundTranslation:undefined});
+ if (to)
+ outboundTranslation.setUserLanguage(preferredLanguageForOutboundTranslation || to);
+
+ if (from || models) {
+ outboundTranslation.setUserLanguageOptions(models.reduce((options, entry) => {
+ // `state` has already been updated at this point as well and we know
+ // that is complete. `diff` might not contain all the keys we need.
+ if (entry.to === from && !options.has(entry.from))
+ options.add(entry.from)
+ return options
+ }, new Set()));
+ }
+
outboundTranslation.target = lastClickedElement;
outboundTranslation.start();
});
diff --git a/src/manifest.json b/src/manifest.json
index d2412f13..eeaa9280 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -1,29 +1,44 @@
{
- "manifest_version": 2,
- "name": "TranslateLocally for Firefox",
+ "manifest_version": 3,
+ "name": "TranslateLocally Webextension",
+ "name": "TranslateLocally for Firefox",
+ "name": "TranslateLocally for Chrome",
"version": "0.0.1",
- "version_name": "development",
- "browser_specific_settings":
+ "browser_specific_settings":
{
"gecko":
{
- "strict_min_version": "90.0",
+ "strict_min_version": "101.0",
"id": "{2fa36771-561b-452c-b6c3-7486f42c25ae}"
}
},
+ "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4LNwumwaTSyvYwCcqt+l7kAb2UR5V3JWC9fdVVj2ripvYu+ZAT0wT2/nrVWwduJ287cb1q00lQ/PafSGKSxRKKZQY0iPp1g6n0s2xYGcRvbB/cpGc969EE7ZkMzGyrz9UVM593Os1ilrrCzjjn5tOhipo63q1B8kas6sY+E1vLX019NfwGgsNXovlU6jWaG7s4vMF6VyE5ZEQUYL+Qs7FMeeZGuwC4Zip/Ij1l9ZWtNXg6u7S7Df91xs8MkxG4eKITYGvn35JEpt4wCi7P5y6x5SF00z9io0hoKT9VfVBd94r1XB03ZHgo5W832Aru1oGTCKc9TeSWdhb8tGxmwLmwIDAQAB",
"permissions":
[
- "",
"contextMenus",
"tabs",
"storage",
"nativeMessaging"
],
- "background":
+ "permissions":
+ [
+ "offscreen"
+ ],
+ "host_permissions": [
+ "https://translatelocally.com/models.json",
+ "https://data.statmt.org/bergamot/models/*"
+ ],
+ "background":
{
"scripts": ["background-script.js"]
},
- "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
+ "background":
+ {
+ "service_worker": "background-script.js"
+ },
+ "content_security_policy": {
+ "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
+ },
"content_scripts":
[
{
@@ -38,9 +53,8 @@
}
],
"incognito": "spanning",
- "browser_action":
+ "action":
{
- "browser_style": true,
"default_icon":
{
"16": "/assets/icons/translateLocally_logo.16.png",
@@ -51,9 +65,7 @@
"default_popup": "popup.html"
},
"options_ui": {
- "page": "options.html",
- "browser_style": true,
- "chrome_style": true
+ "page": "options.html"
},
"icons":
{
@@ -61,10 +73,19 @@
"96": "/assets/icons/translateLocally_logo.96.png"
},
"web_accessible_resources": [
- "assets/fonts/FlowBlock-Regular.woff2",
- "assets/fonts/FlowCircular-Regular.woff2",
- "assets/fonts/FlowRounded-Regular.woff2"
+ {
+ "matches": ["*://*/*"],
+ "resources": [
+ "assets/fonts/FlowBlock-Regular.woff2",
+ "assets/fonts/FlowCircular-Regular.woff2",
+ "assets/fonts/FlowRounded-Regular.woff2",
+ "OutboundTranslation.css",
+ "InPageTranslation.css",
+ "SelectionTranslation.css"
+ ]
+ }
],
- "description": "TranslateLocally for Firefox is a webextension that enables client side in-page translations for web browsers. It is a stand-alone extension, but can integrate with TranslateLocally for custom models and even better performance.",
- "homepage_url": "https://github.com/jelmervdl/translatelocally-webext"
+ "description": "TranslateLocally for Firefox is a webextension that enables local and private translation of web pages.",
+ "description": "TranslateLocally for Chrome is a webextension that enables local and private translation of web pages.",
+ "homepage_url": "https://github.com/jelmervdl/translatelocally-web-ext"
}
\ No newline at end of file
diff --git a/src/options/options.html b/src/options/options.html
index 59acb610..3cfcd1d1 100644
--- a/src/options/options.html
+++ b/src/options/options.html
@@ -13,10 +13,17 @@
overflow: hidden; /* Risky but Chrome always shows a bar otherwise? */
background: Canvas;
color: CanvasText;
+ font: 1rem system-ui;
}
+
+ .panel-section {
+ margin: 1rem 0;
+ }
+
.panel-formElements-item {
flex-wrap: wrap;
}
+
.panel-formElements-item p {
flex: 0 0 100%;
font-size: 1rem;
diff --git a/src/options/options.js b/src/options/options.js
index 7ac086f7..fec1c28d 100644
--- a/src/options/options.js
+++ b/src/options/options.js
@@ -25,7 +25,7 @@ globalState.addListener(render);
// Store value if we changed it on the options page
addBoundElementListeners(document.body, (key, value) => {
- preferences.set(key, value);
+ preferences.set({[key]: value});
});
function canTranslateLocally() {
diff --git a/src/popup/popup.html b/src/popup/popup.html
index 72b022a3..be10009a 100644
--- a/src/popup/popup.html
+++ b/src/popup/popup.html
@@ -21,6 +21,7 @@
overflow: hidden;
background: Canvas;
color: CanvasText;
+ font: caption;
}
.states > * {
@@ -66,48 +67,44 @@
-
+
Wanna translate this page?
-
+
-
+
Downloading language model…
-
+
Translating page from to …
-
+
Translated page from to .
-
+
Error during translation:
-
+
-
+
Translations not available for this page.
-
+
Downloading list of available language models…
-
-
- Translations not available for this page.
-
- |