diff options
Diffstat (limited to 'toolkit/components/translations/content/translations.mjs')
-rw-r--r-- | toolkit/components/translations/content/translations.mjs | 690 |
1 files changed, 690 insertions, 0 deletions
diff --git a/toolkit/components/translations/content/translations.mjs b/toolkit/components/translations/content/translations.mjs new file mode 100644 index 0000000000..b279b03b8b --- /dev/null +++ b/toolkit/components/translations/content/translations.mjs @@ -0,0 +1,690 @@ +/* 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_destroyTranslationsEngine, AT_createTranslationsEngine, + AT_isTranslationEngineSupported, AT_createLanguageIdEngine, AT_translate, AT_identifyLanguage */ + +// 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 translations engine is only valid for a single language pair, and needs + * to be recreated if the language pair changes. + * + * @type {null | Promise<TranslationsEngine>} + */ + translationsEngine = null; + + /** + * @param {boolean} isSupported + */ + constructor(isSupported) { + /** + * Is the engine supported by the device? + * @type {boolean} + */ + this.isTranslationEngineSupported = isSupported; + + /** + * Allow code to wait for the engine to be created. + * @type {Promise<void>} + */ + this.languageIdEngineCreated = isSupported + ? AT_createLanguageIdEngine() + : Promise.resolve(); + + /** + * @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. + Promise.allSettled([ + this.languageIdEngineCreated, + this.supportedLanguages, + ]).then(() => { + this.ui.setAsReady(); + }); + } + + /** + * 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) { + await this.languageIdEngineCreated; + 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, + translationsEngine, + } = this; + + if (!this.isTranslationEngineSupported) { + // Never translate when the engine isn't supported. + return; + } + + if ( + !fromLanguage || + !toLanguage || + !messageToTranslate || + !translationsEngine + ) { + // Not everything is set for translation. + this.ui.updateTranslation(""); + return; + } + + await Promise.all([ + // Ensure the engine is ready to go. + translationsEngine, + // Ensure the previous translation has finished so that only the latest + // translation goes through. + 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.translationsEngine !== translationsEngine || + this.fromLanguage !== fromLanguage || + this.toLanguage !== toLanguage || + this.messageToTranslate !== messageToTranslate + ) { + return; + } + + const start = performance.now(); + + this.translationRequest = AT_translate([messageToTranslate]); + const [translation] = await this.translationRequest; + + // The measure events will show up in the Firefox 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 Firefox 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, the TranslationsEngine needs to be rebuilt. + */ + async maybeRebuildWorker() { + // 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.translationsEngine) { + // The engine is no longer needed. + AT_destroyTranslationsEngine(); + this.translationsEngine = null; + } + return; + } + + const start = performance.now(); + AT_log( + `Rebuilding the translations worker for "${this.fromLanguage}" to "${this.toLanguage}"` + ); + + this.translationsEngine = AT_createTranslationsEngine( + this.fromLanguage, + this.toLanguage + ); + this.maybeRequestTranslation(); + + try { + await this.translationsEngine; + const duration = performance.now() - start; + AT_log(`Rebuilt the TranslationsEngine in ${duration / 1000} seconds`); + } 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, isBeta } = entry; + await this.setFromLanguage(langTag); + this.ui.setDetectOptionTextContent(displayName, isBeta); + } + } + + /** + * @param {string} lang + */ + async setFromLanguage(lang) { + if (lang !== this.fromLanguage) { + this.fromLanguage = lang; + await this.maybeRebuildWorker(); + } + } + + /** + * @param {string} lang + */ + setToLanguage(lang) { + if (lang !== this.toLanguage) { + this.toLanguage = lang; + this.maybeRebuildWorker(); + } + } + + /** + * @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, + isBeta, + displayName, + } of supportedLanguages.toLanguages) { + const option = document.createElement("option"); + option.value = langTag; + if (isBeta) { + document.l10n.setAttributes( + option, + "about-translations-displayname-beta", + { language: displayName } + ); + } else { + option.text = displayName; + } + this.languageTo.add(option); + } + + for (const { + langTag, + isBeta, + displayName, + } of supportedLanguages.fromLanguages) { + const option = document.createElement("option"); + option.value = langTag; + if (isBeta) { + document.l10n.setAttributes( + option, + "about-translations-displayname-beta", + { language: displayName } + ); + } else { + 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) { + this.translationInfoMessage.setAttribute("data-l10n-id", 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, isBeta = false) { + // Set the text to the fluent value that takes an arg to display the language name. + if (displayName) { + document.l10n.setAttributes( + this.#detectOption, + isBeta + ? "about-translations-detect-lang-beta" + : "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); + }; +} |