675 lines
20 KiB
JavaScript
675 lines
20 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
// The following globals are injected via the AboutTranslationsChild actor.
|
|
// translations.mjs is running in an unprivileged context, and these injected functions
|
|
// allow for the page to get access to additional privileged features.
|
|
|
|
/* global AT_getSupportedLanguages, AT_log, AT_getScriptDirection,
|
|
AT_logError, AT_createTranslationsPort, AT_isHtmlTranslation,
|
|
AT_isTranslationEngineSupported, AT_identifyLanguage */
|
|
|
|
import { Translator } from "chrome://global/content/translations/Translator.mjs";
|
|
|
|
// Allow tests to override this value so that they can run faster.
|
|
// This is the delay in milliseconds.
|
|
window.DEBOUNCE_DELAY = 200;
|
|
// Allow tests to test the debounce behavior by counting debounce runs.
|
|
window.DEBOUNCE_RUN_COUNT = 0;
|
|
|
|
/**
|
|
* @typedef {import("../translations").SupportedLanguages} SupportedLanguages
|
|
*/
|
|
|
|
/**
|
|
* The model and controller for initializing about:translations.
|
|
*/
|
|
class TranslationsState {
|
|
/**
|
|
* This class is responsible for all UI updated.
|
|
*
|
|
* @type {TranslationsUI}
|
|
*/
|
|
ui;
|
|
|
|
/**
|
|
* The language to translate from, in the form of a BCP 47 language tag,
|
|
* e.g. "en" or "fr".
|
|
*
|
|
* @type {string}
|
|
*/
|
|
fromLanguage = "";
|
|
|
|
/**
|
|
* The language to translate to, in the form of a BCP 47 language tag,
|
|
* e.g. "en" or "fr".
|
|
*
|
|
* @type {string}
|
|
*/
|
|
toLanguage = "";
|
|
|
|
/**
|
|
* The message to translate, cached so that it can be determined if the text
|
|
* needs to be re-translated.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
messageToTranslate = "";
|
|
|
|
/**
|
|
* Only send one translation in at a time to the worker.
|
|
*
|
|
* @type {Promise<string[]>}
|
|
*/
|
|
translationRequest = Promise.resolve([]);
|
|
|
|
/**
|
|
* The translator is only valid for a single language pair, and needs
|
|
* to be recreated if the language pair changes.
|
|
*
|
|
* @type {null | Translator}
|
|
*/
|
|
translator = null;
|
|
|
|
/**
|
|
* @param {boolean} isSupported
|
|
*/
|
|
constructor(isSupported) {
|
|
/**
|
|
* Is the engine supported by the device?
|
|
*
|
|
* @type {boolean}
|
|
*/
|
|
this.isTranslationEngineSupported = isSupported;
|
|
|
|
/**
|
|
* @type {SupportedLanguages}
|
|
*/
|
|
this.supportedLanguages = isSupported
|
|
? AT_getSupportedLanguages()
|
|
: Promise.resolve([]);
|
|
|
|
this.ui = new TranslationsUI(this);
|
|
this.ui.setup();
|
|
|
|
// Set the UI as ready after all of the state promises have settled.
|
|
this.supportedLanguages
|
|
.then(() => {
|
|
this.ui.setAsReady();
|
|
})
|
|
.catch(error => {
|
|
AT_logError("Failed to load the supported languages", error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Identifies the human language in which the message is written and returns
|
|
* the BCP 47 language tag of the language it is determined to be.
|
|
*
|
|
* e.g. "en" for English.
|
|
*
|
|
* @param {string} message
|
|
*/
|
|
async identifyLanguage(message) {
|
|
const start = performance.now();
|
|
const { langTag, confidence } = await AT_identifyLanguage(message);
|
|
const duration = performance.now() - start;
|
|
AT_log(
|
|
`[ ${langTag}(${(confidence * 100).toFixed(2)}%) ]`,
|
|
`Source language identified in ${duration / 1000} seconds`
|
|
);
|
|
return langTag;
|
|
}
|
|
|
|
/**
|
|
* Only request a translation when it's ready.
|
|
*/
|
|
maybeRequestTranslation = debounce({
|
|
/**
|
|
* Debounce the translation requests so that the worker doesn't fire for every
|
|
* single keyboard input, but instead the keyboard events are ignored until
|
|
* there is a short break, or enough events have happened that it's worth sending
|
|
* in a new translation request.
|
|
*/
|
|
onDebounce: async () => {
|
|
// The contents of "this" can change between async steps, store a local variable
|
|
// binding of these values.
|
|
const { fromLanguage, toLanguage, messageToTranslate, translator } = this;
|
|
|
|
if (!this.isTranslationEngineSupported) {
|
|
// Never translate when the engine isn't supported.
|
|
return;
|
|
}
|
|
|
|
if (!fromLanguage || !toLanguage || !messageToTranslate || !translator) {
|
|
// Not everything is set for translation.
|
|
this.ui.updateTranslation("");
|
|
return;
|
|
}
|
|
|
|
// Ensure the previous translation has finished so that only the latest
|
|
// translation goes through.
|
|
await this.translationRequest;
|
|
|
|
if (
|
|
// Check if the current configuration has changed and if this is stale. If so
|
|
// then skip this request, as there is already a newer request with more up to
|
|
// date information.
|
|
this.translator !== translator ||
|
|
this.fromLanguage !== fromLanguage ||
|
|
this.toLanguage !== toLanguage ||
|
|
this.messageToTranslate !== messageToTranslate
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const start = performance.now();
|
|
this.translationRequest = this.translator.translate(
|
|
messageToTranslate,
|
|
AT_isHtmlTranslation()
|
|
);
|
|
const translation = await this.translationRequest;
|
|
|
|
// The measure events will show up in the IceCat Profiler.
|
|
performance.measure(
|
|
`Translations: Translate "${this.fromLanguage}" to "${this.toLanguage}" with ${messageToTranslate.length} characters.`,
|
|
{
|
|
start,
|
|
end: performance.now(),
|
|
}
|
|
);
|
|
|
|
this.ui.updateTranslation(translation);
|
|
const duration = performance.now() - start;
|
|
AT_log(`Translation done in ${duration / 1000} seconds`);
|
|
},
|
|
|
|
// Mark the events so that they show up in the IceCat Profiler. This makes it handy
|
|
// to visualize the debouncing behavior.
|
|
doEveryTime: () => {
|
|
performance.mark(
|
|
`Translations: input changed to ${this.messageToTranslate.length} characters`
|
|
);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Any time a language pair is changed, a new Translator needs to be created.
|
|
*/
|
|
async maybeCreateNewTranslator() {
|
|
// If we may need to re-building the worker, the old translation is no longer valid.
|
|
this.ui.updateTranslation("");
|
|
|
|
// These are cases in which it wouldn't make sense or be possible to load any translations models.
|
|
if (
|
|
// If fromLanguage or toLanguage are unpopulated we cannot load anything.
|
|
!this.fromLanguage ||
|
|
!this.toLanguage ||
|
|
// If fromLanguage's value is "detect", rather than a BCP 47 language tag, then no language
|
|
// has been detected yet.
|
|
this.fromLanguage === "detect" ||
|
|
// If fromLanguage and toLanguage are the same, this means that the detected language
|
|
// is the same as the toLanguage, and we do not want to translate from one language to itself.
|
|
this.fromLanguage === this.toLanguage
|
|
) {
|
|
if (this.translator) {
|
|
// The engine is no longer needed.
|
|
this.translator.then(translator => translator.destroy());
|
|
this.translator = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const start = performance.now();
|
|
AT_log(
|
|
`Creating a new translator for "${this.fromLanguage}" to "${this.toLanguage}"`
|
|
);
|
|
|
|
const translationPortPromise = (fromLanguage, toLanguage) => {
|
|
const { promise, resolve } = Promise.withResolvers();
|
|
|
|
const getResponse = ({ data }) => {
|
|
if (
|
|
data.type == "GetTranslationsPort" &&
|
|
data.fromLanguage === fromLanguage &&
|
|
data.toLanguage === toLanguage
|
|
) {
|
|
window.removeEventListener("message", getResponse);
|
|
resolve(data.port);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", getResponse);
|
|
AT_createTranslationsPort(fromLanguage, toLanguage);
|
|
|
|
return promise;
|
|
};
|
|
|
|
try {
|
|
const translatorPromise = Translator.create(
|
|
this.fromLanguage,
|
|
this.toLanguage,
|
|
{
|
|
allowSameLanguage: false,
|
|
requestTranslationsPort: translationPortPromise,
|
|
}
|
|
);
|
|
const duration = performance.now() - start;
|
|
|
|
// Signal to tests that the translator was created so they can exit.
|
|
window.postMessage("translator-ready");
|
|
AT_log(`Created a new Translator in ${duration / 1000} seconds`);
|
|
|
|
this.translator = await translatorPromise;
|
|
this.maybeRequestTranslation();
|
|
} catch (error) {
|
|
this.ui.showInfo("about-translations-engine-error");
|
|
AT_logError("Failed to get the Translations worker", error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates the fromLanguage to match the detected language only if the
|
|
* about-translations-detect option is selected in the language-from dropdown.
|
|
*
|
|
* If the new fromLanguage is different than the previous fromLanguage this
|
|
* may update the UI to display the new language and may rebuild the translations
|
|
* worker if there is a valid selected target language.
|
|
*/
|
|
async maybeUpdateDetectedLanguage() {
|
|
if (!this.ui.detectOptionIsSelected() || this.messageToTranslate === "") {
|
|
// If we are not detecting languages or if the message has been cleared
|
|
// we should ensure that the UI is not displaying a detected language
|
|
// and there is no need to run any language detection.
|
|
this.ui.setDetectOptionTextContent("");
|
|
return;
|
|
}
|
|
|
|
const [langTag, supportedLanguages] = await Promise.all([
|
|
this.identifyLanguage(this.messageToTranslate),
|
|
this.supportedLanguages,
|
|
]);
|
|
|
|
// Only update the language if the detected language matches
|
|
// one of our supported languages.
|
|
const entry = supportedLanguages.fromLanguages.find(
|
|
({ langTag: existingTag }) => existingTag === langTag
|
|
);
|
|
if (entry) {
|
|
const { displayName } = entry;
|
|
await this.setFromLanguage(langTag);
|
|
this.ui.setDetectOptionTextContent(displayName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} lang
|
|
*/
|
|
async setFromLanguage(lang) {
|
|
if (lang !== this.fromLanguage) {
|
|
this.fromLanguage = lang;
|
|
await this.maybeCreateNewTranslator();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} lang
|
|
*/
|
|
setToLanguage(lang) {
|
|
if (lang !== this.toLanguage) {
|
|
this.toLanguage = lang;
|
|
this.maybeCreateNewTranslator();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} message
|
|
*/
|
|
async setMessageToTranslate(message) {
|
|
if (message !== this.messageToTranslate) {
|
|
this.messageToTranslate = message;
|
|
await this.maybeUpdateDetectedLanguage();
|
|
this.maybeRequestTranslation();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
class TranslationsUI {
|
|
/** @type {HTMLSelectElement} */
|
|
languageFrom = document.getElementById("language-from");
|
|
/** @type {HTMLSelectElement} */
|
|
languageTo = document.getElementById("language-to");
|
|
/** @type {HTMLTextAreaElement} */
|
|
translationFrom = document.getElementById("translation-from");
|
|
/** @type {HTMLDivElement} */
|
|
translationTo = document.getElementById("translation-to");
|
|
/** @type {HTMLDivElement} */
|
|
translationToBlank = document.getElementById("translation-to-blank");
|
|
/** @type {HTMLDivElement} */
|
|
translationInfo = document.getElementById("translation-info");
|
|
/** @type {HTMLDivElement} */
|
|
translationInfoMessage = document.getElementById("translation-info-message");
|
|
/** @type {TranslationsState} */
|
|
state;
|
|
|
|
/**
|
|
* The detect-language option element. We want to maintain a handle to this so that
|
|
* we can dynamically update its display text to include the detected language.
|
|
*
|
|
* @type {HTMLOptionElement}
|
|
*/
|
|
#detectOption;
|
|
|
|
/**
|
|
* @param {TranslationsState} state
|
|
*/
|
|
constructor(state) {
|
|
this.state = state;
|
|
this.translationTo.style.visibility = "visible";
|
|
this.#detectOption = document.querySelector('option[value="detect"]');
|
|
}
|
|
|
|
/**
|
|
* Do the initial setup.
|
|
*/
|
|
setup() {
|
|
if (!this.state.isTranslationEngineSupported) {
|
|
this.showInfo("about-translations-no-support");
|
|
this.disableUI();
|
|
return;
|
|
}
|
|
this.setupDropdowns();
|
|
this.setupTextarea();
|
|
}
|
|
|
|
/**
|
|
* Signals that the UI is ready, for tests.
|
|
*/
|
|
setAsReady() {
|
|
document.body.setAttribute("ready", "");
|
|
}
|
|
|
|
/**
|
|
* Once the models have been synced from remote settings, populate them with the display
|
|
* names of the languages.
|
|
*/
|
|
async setupDropdowns() {
|
|
const supportedLanguages = await this.state.supportedLanguages;
|
|
|
|
// Update the DOM elements with the display names.
|
|
for (const { langTag, displayName } of supportedLanguages.toLanguages) {
|
|
const option = document.createElement("option");
|
|
option.value = langTag;
|
|
option.text = displayName;
|
|
this.languageTo.add(option);
|
|
}
|
|
|
|
for (const { langTag, displayName } of supportedLanguages.fromLanguages) {
|
|
const option = document.createElement("option");
|
|
option.value = langTag;
|
|
option.text = displayName;
|
|
this.languageFrom.add(option);
|
|
}
|
|
|
|
// Enable the controls.
|
|
this.languageFrom.disabled = false;
|
|
this.languageTo.disabled = false;
|
|
|
|
// Focus the language dropdowns if they are empty.
|
|
if (this.languageFrom.value == "") {
|
|
this.languageFrom.focus();
|
|
} else if (this.languageTo.value == "") {
|
|
this.languageTo.focus();
|
|
}
|
|
|
|
this.state.setFromLanguage(this.languageFrom.value);
|
|
this.state.setToLanguage(this.languageTo.value);
|
|
this.updateOnLanguageChange();
|
|
|
|
this.languageFrom.addEventListener("input", () => {
|
|
this.state.setFromLanguage(this.languageFrom.value);
|
|
this.updateOnLanguageChange();
|
|
});
|
|
|
|
this.languageTo.addEventListener("input", () => {
|
|
this.state.setToLanguage(this.languageTo.value);
|
|
this.updateOnLanguageChange();
|
|
this.translationTo.setAttribute("lang", this.languageTo.value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Show an info message to the user.
|
|
*
|
|
* @param {string} l10nId
|
|
*/
|
|
showInfo(l10nId) {
|
|
document.l10n.setAttributes(this.translationInfoMessage, l10nId);
|
|
this.translationInfo.style.display = "flex";
|
|
}
|
|
|
|
/**
|
|
* Hides the info UI.
|
|
*/
|
|
hideInfo() {
|
|
this.translationInfo.style.display = "none";
|
|
}
|
|
|
|
/**
|
|
* Returns true if about-translations-detect is the currently
|
|
* selected option in the language-from dropdown, otherwise false.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
detectOptionIsSelected() {
|
|
return this.languageFrom.value === "detect";
|
|
}
|
|
|
|
/**
|
|
* Sets the textContent of the about-translations-detect option in the
|
|
* language-from dropdown to include the detected language's display name.
|
|
*
|
|
* @param {string} displayName
|
|
*/
|
|
setDetectOptionTextContent(displayName) {
|
|
// Set the text to the fluent value that takes an arg to display the language name.
|
|
if (displayName) {
|
|
document.l10n.setAttributes(
|
|
this.#detectOption,
|
|
"about-translations-detect-lang",
|
|
{ language: displayName }
|
|
);
|
|
} else {
|
|
// Reset the text to the fluent value that does not display any language name.
|
|
document.l10n.setAttributes(
|
|
this.#detectOption,
|
|
"about-translations-detect"
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* React to language changes.
|
|
*/
|
|
updateOnLanguageChange() {
|
|
this.#updateDropdownLanguages();
|
|
this.#updateMessageDirections();
|
|
}
|
|
|
|
/**
|
|
* You cant translate from one language to another language. Hide the options
|
|
* if this is the case.
|
|
*/
|
|
#updateDropdownLanguages() {
|
|
for (const option of this.languageFrom.options) {
|
|
option.hidden = false;
|
|
}
|
|
for (const option of this.languageTo.options) {
|
|
option.hidden = false;
|
|
}
|
|
if (this.state.toLanguage) {
|
|
const option = this.languageFrom.querySelector(
|
|
`[value=${this.state.toLanguage}]`
|
|
);
|
|
if (option) {
|
|
option.hidden = true;
|
|
}
|
|
}
|
|
if (this.state.fromLanguage) {
|
|
const option = this.languageTo.querySelector(
|
|
`[value=${this.state.fromLanguage}]`
|
|
);
|
|
if (option) {
|
|
option.hidden = true;
|
|
}
|
|
}
|
|
this.state.maybeUpdateDetectedLanguage();
|
|
}
|
|
|
|
/**
|
|
* Define the direction of the language message text, otherwise it might not display
|
|
* correctly. For instance English in an RTL UI would display incorrectly like so:
|
|
*
|
|
* LTR text in LTR UI:
|
|
*
|
|
* ┌──────────────────────────────────────────────┐
|
|
* │ This is in English. │
|
|
* └──────────────────────────────────────────────┘
|
|
*
|
|
* LTR text in RTL UI:
|
|
* ┌──────────────────────────────────────────────┐
|
|
* │ .This is in English │
|
|
* └──────────────────────────────────────────────┘
|
|
*
|
|
* LTR text in RTL UI, but in an LTR container:
|
|
* ┌──────────────────────────────────────────────┐
|
|
* │ This is in English. │
|
|
* └──────────────────────────────────────────────┘
|
|
*
|
|
* The effects are similar, but reversed for RTL text in an LTR UI.
|
|
*/
|
|
#updateMessageDirections() {
|
|
if (this.state.toLanguage) {
|
|
this.translationTo.setAttribute(
|
|
"dir",
|
|
AT_getScriptDirection(this.state.toLanguage)
|
|
);
|
|
} else {
|
|
this.translationTo.removeAttribute("dir");
|
|
}
|
|
if (this.state.fromLanguage) {
|
|
this.translationFrom.setAttribute(
|
|
"dir",
|
|
AT_getScriptDirection(this.state.fromLanguage)
|
|
);
|
|
} else {
|
|
this.translationFrom.removeAttribute("dir");
|
|
}
|
|
}
|
|
|
|
setupTextarea() {
|
|
this.state.setMessageToTranslate(this.translationFrom.value);
|
|
this.translationFrom.addEventListener("input", () => {
|
|
this.state.setMessageToTranslate(this.translationFrom.value);
|
|
});
|
|
}
|
|
|
|
disableUI() {
|
|
this.translationFrom.disabled = true;
|
|
this.languageFrom.disabled = true;
|
|
this.languageTo.disabled = true;
|
|
}
|
|
|
|
/**
|
|
* @param {string} message
|
|
*/
|
|
updateTranslation(message) {
|
|
this.translationTo.innerText = message;
|
|
if (message) {
|
|
this.translationTo.style.visibility = "visible";
|
|
this.translationToBlank.style.visibility = "hidden";
|
|
this.hideInfo();
|
|
} else {
|
|
this.translationTo.style.visibility = "hidden";
|
|
this.translationToBlank.style.visibility = "visible";
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listen for events coming from the AboutTranslations actor.
|
|
*/
|
|
window.addEventListener("AboutTranslationsChromeToContent", ({ detail }) => {
|
|
switch (detail.type) {
|
|
case "enable": {
|
|
// While the feature is in development, hide the feature behind a pref. See the
|
|
// "browser.translations.enable" pref in modules/libpref/init/all.js and Bug 971044
|
|
// for the status of enabling this project.
|
|
if (window.translationsState) {
|
|
throw new Error("about:translations was already initialized.");
|
|
}
|
|
AT_isTranslationEngineSupported().then(isSupported => {
|
|
window.translationsState = new TranslationsState(isSupported);
|
|
});
|
|
document.body.style.visibility = "visible";
|
|
break;
|
|
}
|
|
default:
|
|
throw new Error("Unknown AboutTranslationsChromeToContent event.");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Debounce a function so that it is only called after some wait time with no activity.
|
|
* This is good for grouping text entry via keyboard.
|
|
*
|
|
* @param {object} settings
|
|
* @param {Function} settings.onDebounce
|
|
* @param {Function} settings.doEveryTime
|
|
* @returns {Function}
|
|
*/
|
|
function debounce({ onDebounce, doEveryTime }) {
|
|
/** @type {number | null} */
|
|
let timeoutId = null;
|
|
let lastDispatch = null;
|
|
|
|
return (...args) => {
|
|
doEveryTime(...args);
|
|
|
|
const now = Date.now();
|
|
if (lastDispatch === null) {
|
|
// This is the first call to the function.
|
|
lastDispatch = now;
|
|
}
|
|
|
|
const timeLeft = lastDispatch + window.DEBOUNCE_DELAY - now;
|
|
|
|
// Always discard the old timeout, either the function will run, or a new
|
|
// timer will be scheduled.
|
|
clearTimeout(timeoutId);
|
|
|
|
if (timeLeft <= 0) {
|
|
// It's been long enough to go ahead and call the function.
|
|
timeoutId = null;
|
|
lastDispatch = null;
|
|
window.DEBOUNCE_RUN_COUNT += 1;
|
|
onDebounce(...args);
|
|
return;
|
|
}
|
|
|
|
// Re-set the timeout with the current time left.
|
|
clearTimeout(timeoutId);
|
|
|
|
timeoutId = setTimeout(() => {
|
|
// Timeout ended, call the function.
|
|
timeoutId = null;
|
|
lastDispatch = null;
|
|
window.DEBOUNCE_RUN_COUNT += 1;
|
|
onDebounce(...args);
|
|
}, timeLeft);
|
|
};
|
|
}
|