From d8bbc7858622b6d9c278469aab701ca0b609cddf Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 15 May 2024 05:35:49 +0200 Subject: Merging upstream version 126.0. Signed-off-by: Daniel Baumann --- .../content/TranslationsPanelShared.sys.mjs | 93 ++- .../content/fullPageTranslationsPanel.js | 91 +-- .../content/selectTranslationsPanel.inc.xhtml | 172 ++-- .../content/selectTranslationsPanel.js | 895 +++++++++++++++++++-- 4 files changed, 1045 insertions(+), 206 deletions(-) (limited to 'browser/components/translations/content') diff --git a/browser/components/translations/content/TranslationsPanelShared.sys.mjs b/browser/components/translations/content/TranslationsPanelShared.sys.mjs index 570528df3f..f5045f57e0 100644 --- a/browser/components/translations/content/TranslationsPanelShared.sys.mjs +++ b/browser/components/translations/content/TranslationsPanelShared.sys.mjs @@ -11,9 +11,53 @@ ChromeUtils.defineESModuleGetters(lazy, { /** * A class containing static functionality that is shared by both * the FullPageTranslationsPanel and SelectTranslationsPanel classes. + * + * It is recommended to read the documentation above the TranslationsParent class + * definition to understand the scope of the Translations architecture throughout + * Firefox. + * + * @see TranslationsParent + * + * The static instance of this class is a singleton in the parent process, and is + * available throughout all windows and tabs, just like the static instance of + * the TranslationsParent class. + * + * Unlike the TranslationsParent, this class is never instantiated as an actor + * outside of the static-context functionality defined below. */ export class TranslationsPanelShared { - static #langListsInitState = new Map(); + /** + * A map from Translations Panel instances to their initialized states. + * There is one instance of each panel per top ChromeWindow in Firefox. + * + * See the documentation above the TranslationsParent class for a detailed + * explanation of the translations architecture throughout Firefox. + * + * @see TranslationsParent + * + * @type {Map} + */ + static #langListsInitState = new WeakMap(); + + /** + * True if the next language-list initialization to fail for testing. + * + * @see TranslationsPanelShared.ensureLangListsBuilt + * + * @type {boolean} + */ + static #simulateLangListError = false; + + /** + * Clears cached data regarding the initialization state of the + * FullPageTranslationsPanel or the SelectTranslationsPanel. + * + * This is only needed for test runners to ensure that each test + * starts from a clean slate. + */ + static clearCache() { + this.#langListsInitState = new WeakMap(); + } /** * Defines lazy getters for accessing elements in the document based on provided entries. @@ -45,6 +89,18 @@ export class TranslationsPanelShared { } } + /** + * Ensures that the next call to ensureLangListBuilt wil fail + * for the purpose of testing the error state. + * + * @see TranslationsPanelShared.ensureLangListsBuilt + * + * @type {boolean} + */ + static simulateLangListError() { + this.#simulateLangListError = true; + } + /** * Retrieves the initialization state of language lists for the specified panel. * @@ -52,7 +108,7 @@ export class TranslationsPanelShared { * - The panel for which to look up the state. */ static getLangListsInitState(panel) { - return TranslationsPanelShared.#langListsInitState.get(panel.id); + return TranslationsPanelShared.#langListsInitState.get(panel); } /** @@ -64,17 +120,17 @@ export class TranslationsPanelShared { * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel * - The panel for which to ensure language lists are built. */ - static async ensureLangListsBuilt(document, panel, innerWindowId) { - const { id } = panel; - switch ( - TranslationsPanelShared.#langListsInitState.get(`${id}-${innerWindowId}`) - ) { + static async ensureLangListsBuilt(document, panel) { + const { panel: panelElement } = panel.elements; + switch (TranslationsPanelShared.#langListsInitState.get(panel)) { case "initialized": // This has already been initialized. return; case "error": case undefined: - // attempt to initialize + // Set the error state in case there is an early exit at any point. + // This will be set to "initialized" if everything succeeds. + TranslationsPanelShared.#langListsInitState.set(panel, "error"); break; default: throw new Error( @@ -88,18 +144,28 @@ export class TranslationsPanelShared { await lazy.TranslationsParent.getSupportedLanguages(); // Verify that we are in a proper state. - if (languagePairs.length === 0) { + if (languagePairs.length === 0 || this.#simulateLangListError) { + this.#simulateLangListError = false; throw new Error("No translation languages were retrieved."); } - const fromPopups = panel.querySelectorAll( + const fromPopups = panelElement.querySelectorAll( ".translations-panel-language-menupopup-from" ); - const toPopups = panel.querySelectorAll( + const toPopups = panelElement.querySelectorAll( ".translations-panel-language-menupopup-to" ); for (const popup of fromPopups) { + // For the moment, the FullPageTranslationsPanel includes its own + // menu item for "Choose another language" as the first item in the list + // with an empty-string for its value. The SelectTranslationsPanel has + // only languages in its list with BCP-47 tags for values. As such, + // this loop works for both panels, to remove all of the languages + // from the list, but ensuring that any empty-string items are retained. + while (popup.lastChild?.value) { + popup.lastChild.remove(); + } for (const { langTag, displayName } of fromLanguages) { const fromMenuItem = document.createXULElement("menuitem"); fromMenuItem.setAttribute("value", langTag); @@ -109,6 +175,9 @@ export class TranslationsPanelShared { } for (const popup of toPopups) { + while (popup.lastChild?.value) { + popup.lastChild.remove(); + } for (const { langTag, displayName } of toLanguages) { const toMenuItem = document.createXULElement("menuitem"); toMenuItem.setAttribute("value", langTag); @@ -117,6 +186,6 @@ export class TranslationsPanelShared { } } - TranslationsPanelShared.#langListsInitState.set(id, "initialized"); + TranslationsPanelShared.#langListsInitState.set(panel, "initialized"); } } diff --git a/browser/components/translations/content/fullPageTranslationsPanel.js b/browser/components/translations/content/fullPageTranslationsPanel.js index 2e35440160..eddd3566f1 100644 --- a/browser/components/translations/content/fullPageTranslationsPanel.js +++ b/browser/components/translations/content/fullPageTranslationsPanel.js @@ -188,12 +188,19 @@ class CheckboxPageAction { } /** - * This singleton class controls the Translations popup panel. + * This singleton class controls the FullPageTranslations panel. * * This component is a `/browser` component, and the actor is a `/toolkit` actor, so care * must be taken to keep the presentation (this component) from the state management * (the Translations actor). This class reacts to state changes coming from the * Translations actor. + * + * A global instance of this class is created once per top ChromeWindow and is initialized + * when the new window is created. + * + * See the comment above TranslationsParent for more details. + * + * @see TranslationsParent */ var FullPageTranslationsPanel = new (class { /** @type {Console?} */ @@ -373,21 +380,6 @@ var FullPageTranslationsPanel = new (class { } } - /** - * @returns {TranslationsParent} - */ - #getTranslationsActor() { - const actor = - gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( - "Translations" - ); - - if (!actor) { - throw new Error("Unable to get the TranslationsParent"); - } - return actor; - } - /** * Fetches the language tags for the document and the user and caches the results * Use `#getCachedDetectedLanguages` when the lang tags do not need to be re-fetched. @@ -396,8 +388,9 @@ var FullPageTranslationsPanel = new (class { * @returns {Promise} */ async #fetchDetectedLanguages() { - this.detectedLanguages = - await this.#getTranslationsActor().getDetectedLanguages(); + this.detectedLanguages = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).getDetectedLanguages(); return this.detectedLanguages; } @@ -421,11 +414,7 @@ var FullPageTranslationsPanel = new (class { */ async #ensureLangListsBuilt() { try { - await TranslationsPanelShared.ensureLangListsBuilt( - document, - this.elements.panel, - gBrowser.selectedBrowser.innerWindowID - ); + await TranslationsPanelShared.ensureLangListsBuilt(document, this); } catch (error) { this.console?.error(error); } @@ -438,7 +427,9 @@ var FullPageTranslationsPanel = new (class { * @param {TranslationsLanguageState} languageState */ #updateViewFromTranslationStatus( - languageState = this.#getTranslationsActor().languageState + languageState = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState ) { const { translateButton, toMenuList, fromMenuList, header, cancelButton } = this.elements; @@ -553,7 +544,7 @@ var FullPageTranslationsPanel = new (class { // Unconditionally hide the intro text in case the panel is re-shown. intro.hidden = true; - if (TranslationsPanelShared.getLangListsInitState(panel) === "error") { + if (TranslationsPanelShared.getLangListsInitState(this) === "error") { // There was an error, display it in the view rather than the language // dropdowns. const { cancelButton, errorHintAction } = this.elements; @@ -722,8 +713,9 @@ var FullPageTranslationsPanel = new (class { const neverTranslateSiteMenuItems = panel.ownerDocument.querySelectorAll( ".never-translate-site-menuitem" ); - const neverTranslateSite = - await this.#getTranslationsActor().shouldNeverTranslateSite(); + const neverTranslateSite = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).shouldNeverTranslateSite(); for (const menuitem of neverTranslateSiteMenuItems) { menuitem.setAttribute("checked", neverTranslateSite ? "true" : "false"); @@ -801,7 +793,9 @@ var FullPageTranslationsPanel = new (class { async #showRevisitView({ fromLanguage, toLanguage }) { const { fromMenuList, toMenuList, intro } = this.elements; if (!this.#isShowingDefaultView()) { - await this.#showDefaultView(this.#getTranslationsActor()); + await this.#showDefaultView( + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser) + ); } intro.hidden = true; fromMenuList.value = fromLanguage; @@ -897,7 +891,7 @@ var FullPageTranslationsPanel = new (class { PanelMultiView.hidePopup(panel); await this.#showDefaultView( - this.#getTranslationsActor(), + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser), true /* force this view to be shown */ ); @@ -1119,8 +1113,10 @@ var FullPageTranslationsPanel = new (class { const { button } = this.buttonElements; - const { requestedTranslationPair, locationChangeId } = - this.#getTranslationsActor().languageState; + const { requestedTranslationPair } = + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState; // Store this value because it gets modified when #showDefaultView is called below. const isFirstUserInteraction = !this._hasShownPanel; @@ -1132,7 +1128,9 @@ var FullPageTranslationsPanel = new (class { this.console?.error(error); }); } else { - await this.#showDefaultView(this.#getTranslationsActor()).catch(error => { + await this.#showDefaultView( + TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser) + ).catch(error => { this.console?.error(error); }); } @@ -1145,16 +1143,6 @@ var FullPageTranslationsPanel = new (class { ? button : this.elements.appMenuButton; - if (!TranslationsParent.isActiveLocation(locationChangeId)) { - this.console?.log(`A translation panel open request was stale.`, { - locationChangeId, - newlocationChangeId: - this.#getTranslationsActor().languageState.locationChangeId, - currentURISpec: gBrowser.currentURI.spec, - }); - return; - } - this.console?.log(`Showing a translation panel`, gBrowser.currentURI.spec); await this.#openPanelPopup(targetButton, { @@ -1173,7 +1161,9 @@ var FullPageTranslationsPanel = new (class { */ #isTranslationsActive() { const { requestedTranslationPair } = - this.#getTranslationsActor().languageState; + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).languageState; return requestedTranslationPair !== null; } @@ -1183,7 +1173,9 @@ var FullPageTranslationsPanel = new (class { async onTranslate() { PanelMultiView.hidePopup(this.elements.panel); - const actor = this.#getTranslationsActor(); + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); actor.translate( this.elements.fromMenuList.value, this.elements.toMenuList.value, @@ -1205,7 +1197,7 @@ var FullPageTranslationsPanel = new (class { this.#updateSettingsMenuLanguageCheckboxStates(); this.#updateSettingsMenuSiteCheckboxStates(); const popup = button.ownerDocument.getElementById( - "translations-panel-settings-menupopup" + "full-page-translations-panel-settings-menupopup" ); popup.openPopup(button, "after_end"); } @@ -1331,8 +1323,9 @@ var FullPageTranslationsPanel = new (class { */ async onNeverTranslateSite() { const pageAction = this.getCheckboxPageActionFor().neverTranslateSite(); - const toggledOn = - await this.#getTranslationsActor().toggleNeverTranslateSitePermissions(); + const toggledOn = await TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).toggleNeverTranslateSitePermissions(); TranslationsParent.telemetry().panel().onNeverTranslateSite(toggledOn); this.#updateSettingsMenuSiteCheckboxStates(); await this.#doPageAction(pageAction); @@ -1349,7 +1342,9 @@ var FullPageTranslationsPanel = new (class { throw new Error("Expected to have a document language tag."); } - this.#getTranslationsActor().restorePage(docLangTag); + TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ).restorePage(docLangTag); } /** diff --git a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml index 72e2bd7095..8c643ea3f6 100644 --- a/browser/components/translations/content/selectTranslationsPanel.inc.xhtml +++ b/browser/components/translations/content/selectTranslationsPanel.inc.xhtml @@ -4,99 +4,99 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + orient="vertical" + onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)" + onpopuphidden="SelectTranslationsPanel.handlePanelPopupHiddenEvent(event)"> + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + + + + + - - - + + + - - - - - - + + + + diff --git a/browser/components/translations/content/selectTranslationsPanel.js b/browser/components/translations/content/selectTranslationsPanel.js index b4fe3e9735..bb825eaefa 100644 --- a/browser/components/translations/content/selectTranslationsPanel.js +++ b/browser/components/translations/content/selectTranslationsPanel.js @@ -4,15 +4,27 @@ /* eslint-env mozilla/browser-window */ +/** + * @typedef {import("../../../../toolkit/components/translations/translations").SelectTranslationsPanelState} SelectTranslationsPanelState + */ + ChromeUtils.defineESModuleGetters(this, { LanguageDetector: "resource://gre/modules/translation/LanguageDetector.sys.mjs", TranslationsPanelShared: "chrome://browser/content/translations/TranslationsPanelShared.sys.mjs", + Translator: "chrome://global/content/translations/Translator.mjs", }); /** - * This singleton class controls the Translations popup panel. + * This singleton class controls the SelectTranslations panel. + * + * A global instance of this class is created once per top ChromeWindow and is initialized + * when the context menu is opened in that window. + * + * See the comment above TranslationsParent for more details. + * + * @see TranslationsParent */ var SelectTranslationsPanel = new (class { /** @type {Console?} */ @@ -39,6 +51,69 @@ var SelectTranslationsPanel = new (class { return this.#console; } + /** + * The textarea height for shorter text. + * + * @type {string} + */ + #shortTextHeight = "8em"; + + /** + * Retrieves the read-only textarea height for shorter text. + * + * @see #shortTextHeight + */ + get shortTextHeight() { + return this.#shortTextHeight; + } + + /** + * The textarea height for shorter text. + * + * @type {string} + */ + #longTextHeight = "16em"; + + /** + * Retrieves the read-only textarea height for longer text. + * + * @see #longTextHeight + */ + get longTextHeight() { + return this.#longTextHeight; + } + + /** + * The threshold used to determine when the panel should + * use the short text-height vs. the long-text height. + * + * @type {number} + */ + #textLengthThreshold = 800; + + /** + * Retrieves the read-only text-length threshold. + * + * @see #textLengthThreshold + */ + get textLengthThreshold() { + return this.#textLengthThreshold; + } + + /** + * The localized placeholder text to display when idle. + * + * @type {string} + */ + #idlePlaceholderText; + + /** + * The localized placeholder text to display when translating. + * + * @type {string} + */ + #translatingPlaceholderText; + /** * Where the lazy elements are stored. * @@ -46,6 +121,29 @@ var SelectTranslationsPanel = new (class { */ #lazyElements; + /** + * The internal state of the SelectTranslationsPanel. + * + * @type {SelectTranslationsPanelState} + */ + #translationState = { phase: "closed" }; + + /** + * The Translator for the current language pair. + * + * @type {Translator} + */ + #translator; + + /** + * An Id that increments with each translation, used to help keep track + * of whether an active translation request continue its progression or + * stop due to the existence of a newer translation request. + * + * @type {number} + */ + #translationId = 0; + /** * Lazily creates the dom elements, and lazily selects them. * @@ -77,11 +175,12 @@ var SelectTranslationsPanel = new (class { doneButton: "select-translations-panel-done-button", fromLabel: "select-translations-panel-from-label", fromMenuList: "select-translations-panel-from", + fromMenuPopup: "select-translations-panel-from-menupopup", header: "select-translations-panel-header", - multiview: "select-translations-panel-multiview", - textArea: "select-translations-panel-translation-area", + textArea: "select-translations-panel-text-area", toLabel: "select-translations-panel-to-label", toMenuList: "select-translations-panel-to", + toMenuPopup: "select-translations-panel-to-menupopup", translateFullPageButton: "select-translations-panel-translate-full-page-button", }); @@ -90,6 +189,43 @@ var SelectTranslationsPanel = new (class { return this.#lazyElements; } + /** + * Attempts to determine the best language tag to use as the source language for translation. + * If the detected language is not supported, attempts to fallback to the document's language tag. + * + * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed. + * + * @returns {Promise} - The code of a supported language, a supported document language, or the top detected language. + */ + async getTopSupportedDetectedLanguage(textToTranslate) { + // First see if any of the detected languages are supported and return it if so. + const { language, languages } = await LanguageDetector.detectLanguage( + textToTranslate + ); + for (const { languageCode } of languages) { + const isSupported = await TranslationsParent.isSupportedAsFromLang( + languageCode + ); + if (isSupported) { + return languageCode; + } + } + + // Since none of the detected languages were supported, check to see if the + // document has a specified language tag that is supported. + const actor = TranslationsParent.getTranslationsActor( + gBrowser.selectedBrowser + ); + const detectedLanguages = actor.languageState.detectedLanguages; + if (detectedLanguages?.isDocLangTagSupported) { + return detectedLanguages.docLangTag; + } + + // No supported language was found, so return the top detected language + // to inform the panel's unsupported language state. + return language; + } + /** * Detects the language of the provided text and retrieves a language pair for translation * based on user settings. @@ -101,9 +237,7 @@ var SelectTranslationsPanel = new (class { */ async getLangPairPromise(textToTranslate) { const [fromLang, toLang] = await Promise.all([ - LanguageDetector.detectLanguage(textToTranslate).then( - ({ language }) => language - ), + SelectTranslationsPanel.getTopSupportedDetectedLanguage(textToTranslate), TranslationsParent.getTopPreferredSupportedToLang(), ]); @@ -122,99 +256,740 @@ var SelectTranslationsPanel = new (class { } /** - * Builds the of languages for both the "from" and "to". This can be - * called every time the popup is shown, as it will retry when there is an error - * (such as a network error) or be a noop if it's already initialized. + * Ensures that the from-language and to-language dropdowns are built. + * + * This can be called every time the popup is shown, since it will retry + * when there is an error (such as a network error) or be a no-op if the + * dropdowns have already been initialized. */ async #ensureLangListsBuilt() { - try { - await TranslationsPanelShared.ensureLangListsBuilt( - document, - this.elements.panel - ); - } catch (error) { - this.console?.error(error); - } + await TranslationsPanelShared.ensureLangListsBuilt(document, this); } /** - * Updates the language dropdown based on the provided language tag. + * Initializes the selected value of the given language dropdown based on the language tag. * * @param {string} langTag - A BCP-47 language tag. - * @param {Element} menuList - The dropdown menu element that will be updated based on language support. + * @param {Element} menuList - The menu list element to update. + * * @returns {Promise} */ - async #updateLanguageDropdown(langTag, menuList) { - const langTagIsSupported = + async #initializeLanguageMenuList(langTag, menuList) { + const isLangTagSupported = menuList.id === this.elements.fromMenuList.id ? await TranslationsParent.isSupportedAsFromLang(langTag) : await TranslationsParent.isSupportedAsToLang(langTag); - if (langTagIsSupported) { + if (isLangTagSupported) { // Remove the data-l10n-id because the menulist label will // be populated from the supported language's display name. - menuList.value = langTag; menuList.removeAttribute("data-l10n-id"); + menuList.value = langTag; } else { - // Set the data-l10n-id placeholder because no valid - // language will be selected when the panel opens. - menuList.value = undefined; - document.l10n.setAttributes( - menuList, - "translations-panel-choose-language" - ); - await document.l10n.translateElements([menuList]); + await this.#deselectLanguage(menuList); } } /** - * Updates the language selection dropdowns based on the given langPairPromise. + * Initializes the selected values of the from-language and to-language menu + * lists based on the result of the given language pair promise. * * @param {Promise<{fromLang?: string, toLang?: string}>} langPairPromise + * * @returns {Promise} */ - async #updateLanguageDropdowns(langPairPromise) { + async #initializeLanguageMenuLists(langPairPromise) { const { fromLang, toLang } = await langPairPromise; - - this.console?.debug(`fromLang(${fromLang})`); - this.console?.debug(`toLang(${toLang})`); - const { fromMenuList, toMenuList } = this.elements; - await Promise.all([ - this.#updateLanguageDropdown(fromLang, fromMenuList), - this.#updateLanguageDropdown(toLang, toMenuList), + this.#initializeLanguageMenuList(fromLang, fromMenuList), + this.#initializeLanguageMenuList(toLang, toMenuList), ]); } /** - * Opens the panel and populates the currently selected fromLang and toLang based - * on the result of the langPairPromise. + * Opens the panel, ensuring the panel's UI and state are initialized correctly. * * @param {Event} event - The triggering event for opening the panel. + * @param {number} screenX - The x-axis location of the screen at which to open the popup. + * @param {number} screenY - The y-axis location of the screen at which to open the popup. + * @param {string} sourceText - The text to translate. * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. + * * @returns {Promise} */ - async open(event, langPairPromise) { - this.console?.log("Showing a translation panel."); + async open(event, screenX, screenY, sourceText, langPairPromise) { + if (this.#isOpen()) { + return; + } + this.#registerSourceText(sourceText); await this.#ensureLangListsBuilt(); - await this.#updateLanguageDropdowns(langPairPromise); - - // TODO(Bug 1878721) Rework the logic of where to open the panel. - // - // For the moment, the Select Translations panel opens at the - // AppMenu Button, but it will eventually need to open near - // to the selected content. - const appMenuButton = document.getElementById("PanelUI-menu-button"); - const { panel, textArea } = this.elements; - - panel.addEventListener("popupshown", () => textArea.focus(), { - once: true, + + await Promise.all([ + this.#cachePlaceholderText(), + this.#initializeLanguageMenuLists(langPairPromise), + ]); + + this.#displayIdlePlaceholder(); + this.#maybeRequestTranslation(); + await this.#openPopup(event, screenX, screenY); + } + + /** + * Opens a the panel popup at a location on the screen. + * + * @param {Event} event - The event that triggers the popup opening. + * @param {number} screenX - The x-axis location of the screen at which to open the popup. + * @param {number} screenY - The y-axis location of the screen at which to open the popup. + */ + async #openPopup(event, screenX, screenY) { + await window.ensureCustomElements("moz-button-group"); + + this.console?.log("Showing SelectTranslationsPanel"); + const { panel } = this.elements; + panel.openPopupAtScreen(screenX, screenY, /* isContextMenu */ false, event); + } + + /** + * Adds the source text to the translation state and adapts the size of the text area based + * on the length of the text. + * + * @param {string} sourceText - The text to translate. + * + * @returns {Promise} + */ + #registerSourceText(sourceText) { + const { textArea } = this.elements; + this.#changeStateTo("idle", /* retainEntries */ false, { + sourceText, }); - await PanelMultiView.openPopup(panel, appMenuButton, { - position: "bottomright topright", - triggerEvent: event, - }).catch(error => this.console?.error(error)); + + if (sourceText.length < SelectTranslationsPanel.textLengthThreshold) { + textArea.style.height = SelectTranslationsPanel.shortTextHeight; + } else { + textArea.style.height = SelectTranslationsPanel.longTextHeight; + } + } + + /** + * Caches the localized text to use as placeholders. + */ + async #cachePlaceholderText() { + const [idleText, translatingText] = await document.l10n.formatValues([ + { id: "select-translations-panel-idle-placeholder-text" }, + { id: "select-translations-panel-translating-placeholder-text" }, + ]); + this.#idlePlaceholderText = idleText; + this.#translatingPlaceholderText = translatingText; + } + + /** + * Handles events when a popup is shown within the panel, including showing + * the panel itself. + * + * @param {Event} event - The event that triggered the popup to show. + */ + handlePanelPopupShownEvent(event) { + const { panel, fromMenuPopup, toMenuPopup } = this.elements; + switch (event.target.id) { + case panel.id: { + this.#updatePanelUIFromState(); + break; + } + case fromMenuPopup.id: { + this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup); + break; + } + case toMenuPopup.id: { + this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup); + break; + } + } + } + + /** + * Handles events when a popup is closed within the panel, including closing + * the panel itself. + * + * @param {Event} event - The event that triggered the popup to close. + */ + handlePanelPopupHiddenEvent(event) { + const { panel } = this.elements; + switch (event.target.id) { + case panel.id: { + this.#changeStateToClosed(); + break; + } + } + } + + /** + * Handles events when the panels select from-language is changed. + */ + onChangeFromLanguage() { + const { fromMenuList, toMenuList } = this.elements; + this.#maybeTranslateOnEvents(["blur", "keypress"], fromMenuList); + this.#maybeStealLanguageFrom(toMenuList); + } + + /** + * Handles events when the panels select to-language is changed. + */ + onChangeToLanguage() { + const { toMenuList, fromMenuList } = this.elements; + this.#maybeTranslateOnEvents(["blur", "keypress"], toMenuList); + this.#maybeStealLanguageFrom(fromMenuList); + } + + /** + * Clears the selected language and ensures that the menu list displays + * the proper placeholder text. + * + * @param {Element} menuList - The target menu list element to update. + */ + async #deselectLanguage(menuList) { + menuList.value = ""; + document.l10n.setAttributes(menuList, "translations-panel-choose-language"); + await document.l10n.translateElements([menuList]); + } + + /** + * Deselects the language from the target menu list if both menu lists + * have the same language selected, simulating the effect of one menu + * list stealing the selected language value from the other. + * + * @param {Element} menuList - The target menu list element to update. + */ + async #maybeStealLanguageFrom(menuList) { + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + if (fromLanguage === toLanguage) { + await this.#deselectLanguage(menuList); + this.#maybeFocusMenuList(menuList); + } + } + + /** + * Focuses on the given menu list if provided and empty, or defaults to focusing one + * of the from-menu or to-menu lists if either is empty. + * + * @param {Element} [menuList] - The menu list to focus if specified. + */ + #maybeFocusMenuList(menuList) { + if (menuList && !menuList.value) { + menuList.focus({ focusVisible: true }); + return; + } + + const { fromMenuList, toMenuList } = this.elements; + if (!fromMenuList.value) { + fromMenuList.focus({ focusVisible: true }); + } else if (!toMenuList.value) { + toMenuList.focus({ focusVisible: true }); + } + } + + /** + * Focuses the translated-text area and sets its overflow to auto post-animation. + */ + #indicateTranslatedTextArea({ overflow }) { + const { textArea } = this.elements; + textArea.focus({ focusVisible: true }); + requestAnimationFrame(() => { + // We want to set overflow to auto as the final animation, because if it is + // set before the translated text is displayed, then the scrollTop will + // move to the bottom as the text is populated. + // + // Setting scrollTop = 0 on its own works, but it sometimes causes an animation + // of the text jumping from the bottom to the top. It looks a lot cleaner to + // disable overflow before rendering the text, then re-enable it after it renders. + requestAnimationFrame(() => { + textArea.style.overflow = overflow; + textArea.scrollTop = 0; + }); + }); + } + + /** + * Checks if the given language pair matches the panel's currently selected language pair. + * + * @param {string} fromLanguage - The from-language to compare. + * @param {string} toLanguage - The to-language to compare. + * + * @returns {boolean} - True if the given language pair matches the selected languages in the panel UI, otherwise false. + */ + #isSelectedLangPair(fromLanguage, toLanguage) { + const { fromLanguage: selectedFromLang, toLanguage: selectedToLang } = + this.#getSelectedLanguagePair(); + return fromLanguage === selectedFromLang && toLanguage === selectedToLang; + } + + /** + * Checks if the translator's language configuration matches the given language pair. + * + * @param {string} fromLanguage - The from-language to compare. + * @param {string} toLanguage - The to-language to compare. + * + * @returns {boolean} - True if the translator's languages match the given pair, otherwise false. + */ + #translatorMatchesLangPair(fromLanguage, toLanguage) { + return ( + this.#translator?.fromLanguage === fromLanguage && + this.#translator?.toLanguage === toLanguage + ); + } + + /** + * Retrieves the currently selected language pair from the menu lists. + * + * @returns {{fromLanguage: string, toLanguage: string}} An object containing the selected languages. + */ + #getSelectedLanguagePair() { + const { fromMenuList, toMenuList } = this.elements; + return { + fromLanguage: fromMenuList.value, + toLanguage: toMenuList.value, + }; + } + + /** + * Retrieves the source text from the translation state. + * This value is not available when the panel is closed. + * + * @returns {string | undefined} The source text. + */ + getSourceText() { + return this.#translationState?.sourceText; + } + + /** + * Retrieves the source text from the translation state. + * This value is only available in the translated phase. + * + * @returns {string | undefined} The translated text. + */ + getTranslatedText() { + return this.#translationState?.translatedText; + } + + /** + * Retrieves the current phase of the translation state. + * + * @returns {SelectTranslationsPanelState} + */ + #phase() { + return this.#translationState.phase; + } + + /** + * @returns {boolean} True if the panel is open, otherwise false. + */ + #isOpen() { + return this.#phase() !== "closed"; + } + + /** + * @returns {boolean} True if the panel is closed, otherwise false. + */ + #isClosed() { + return this.#phase() === "closed"; + } + + /** + * Changes the translation state to a new phase with options to retain or overwrite existing entries. + * + * @param {SelectTranslationsPanelState} phase - The new phase to transition to. + * @param {boolean} [retainEntries] - Whether to retain existing state entries that are not overwritten. + * @param {object | null} [data=null] - Additional data to merge into the state. + * @throws {Error} If an invalid phase is specified. + */ + #changeStateTo(phase, retainEntries, data = null) { + const { textArea } = this.elements; + switch (phase) { + case "translating": { + textArea.classList.add("translating"); + break; + } + case "closed": + case "idle": + case "translatable": + case "translated": { + textArea.classList.remove("translating"); + break; + } + default: { + throw new Error(`Invalid state change to '${phase}'`); + } + } + + const previousPhase = this.#phase(); + if (data && retainEntries) { + // Change the phase and apply new entries from data, but retain non-overwritten entries from previous state. + this.#translationState = { ...this.#translationState, phase, ...data }; + } else if (data) { + // Change the phase and apply new entries from data, but drop any entries that are not overwritten by data. + this.#translationState = { phase, ...data }; + } else if (retainEntries) { + // Change only the phase and retain all entries from previous data. + this.#translationState.phase = phase; + } else { + // Change the phase and delete all entries from previous data. + this.#translationState = { phase }; + } + + if (previousPhase === this.#phase()) { + // Do not continue on to update the UI because the phase didn't change. + return; + } + + const { fromLanguage, toLanguage } = this.#translationState; + this.console?.debug( + `SelectTranslationsPanel (${fromLanguage ? fromLanguage : "??"}-${ + toLanguage ? toLanguage : "??" + }) state change (${previousPhase} => ${phase})` + ); + + this.#updatePanelUIFromState(); + } + + /** + * Changes the phase to closed, discarding any entries in the translation state. + */ + #changeStateToClosed() { + this.#changeStateTo("closed", /* retainEntries */ false); + } + + /** + * Changes the phase from "translatable" to "translating". + * + * @throws {Error} If the current state is not "translatable". + */ + #changeStateToTranslating() { + const phase = this.#phase(); + if (phase !== "translatable") { + throw new Error(`Invalid state change (${phase} => translating)`); + } + this.#changeStateTo("translating", /* retainEntries */ true); + } + + /** + * Changes the phase from "translating" to "translated". + * + * @throws {Error} If the current state is not "translating". + */ + #changeStateToTranslated(translatedText) { + const phase = this.#phase(); + if (phase !== "translating") { + throw new Error(`Invalid state change (${phase} => translated)`); + } + this.#changeStateTo("translated", /* retainEntries */ true, { + translatedText, + }); + } + + /** + * Transitions the phase of the state based on the given language pair. + * + * @param {string} fromLanguage - The BCP-47 from-language tag. + * @param {string} toLanguage - The BCP-47 to-language tag. + * + * @returns {SelectTranslationsPanelState} The new phase of the translation state. + */ + #changeStateByLanguagePair(fromLanguage, toLanguage) { + const { + phase: previousPhase, + fromLanguage: previousFromLanguage, + toLanguage: previousToLanguage, + } = this.#translationState; + + let nextPhase = "translatable"; + + if ( + // No from-language is selected, so we cannot translate. + !fromLanguage || + // No to-language is selected, so we cannot translate. + !toLanguage || + // The same language has been selected, so we cannot translate. + fromLanguage === toLanguage + ) { + nextPhase = "idle"; + } else if ( + // The languages have not changed, so there is nothing to do. + previousFromLanguage === fromLanguage && + previousToLanguage === toLanguage + ) { + nextPhase = previousPhase; + } + + this.#changeStateTo(nextPhase, /* retainEntries */ true, { + fromLanguage, + toLanguage, + }); + + return nextPhase; + } + + /** + * Determines whether translation should continue based on panel state and language pair. + * + * @param {number} translationId - The id of the translation request to match. + * @param {string} fromLanguage - The from-language to analyze. + * @param {string} toLanguage - The to-language to analyze. + * + * @returns {boolean} True if translation should continue with the given pair, otherwise false. + */ + #shouldContinueTranslation(translationId, fromLanguage, toLanguage) { + return ( + // Continue only if the panel is still open. + this.#isOpen() && + // Continue only if the current translationId matches. + translationId === this.#translationId && + // Continue only if the given language pair is still the actively selected pair. + this.#isSelectedLangPair(fromLanguage, toLanguage) && + // Continue only if the given language pair matches the current translator. + this.#translatorMatchesLangPair(fromLanguage, toLanguage) + ); + } + + /** + * Displays the placeholder text for the translation state's "idle" phase. + */ + #displayIdlePlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.#idlePlaceholderText; + this.#updateTextDirection(); + this.#updateConditionalUIEnabledState(); + this.#maybeFocusMenuList(); + } + + /** + * Displays the placeholder text for the translation state's "translating" phase. + */ + #displayTranslatingPlaceholder() { + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.#translatingPlaceholderText; + this.#updateTextDirection(); + this.#updateConditionalUIEnabledState(); + this.#indicateTranslatedTextArea({ overflow: "hidden" }); + } + + /** + * Displays the translated text for the translation state's "translated" phase. + */ + #displayTranslatedText() { + const { toLanguage } = this.#getSelectedLanguagePair(); + const { textArea } = SelectTranslationsPanel.elements; + textArea.value = this.getTranslatedText(); + this.#updateTextDirection(toLanguage); + this.#updateConditionalUIEnabledState(); + this.#indicateTranslatedTextArea({ overflow: "auto" }); + } + + /** + * Enables or disables UI components that are conditional on a valid language pair being selected. + */ + #updateConditionalUIEnabledState() { + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + const { copyButton, translateFullPageButton, textArea } = this.elements; + + const invalidLangPairSelected = !fromLanguage || !toLanguage; + const isTranslating = this.#phase() === "translating"; + + textArea.disabled = invalidLangPairSelected; + translateFullPageButton.disabled = invalidLangPairSelected; + copyButton.disabled = invalidLangPairSelected || isTranslating; + } + + /** + * Updates the panel UI based on the current phase of the translation state. + */ + #updatePanelUIFromState() { + switch (this.#phase()) { + case "idle": { + this.#displayIdlePlaceholder(); + break; + } + case "translating": { + this.#displayTranslatingPlaceholder(); + break; + } + case "translated": { + this.#displayTranslatedText(); + break; + } + } + } + + /** + * Sets the text direction attribute in the text areas based on the specified language. + * Uses the given language tag if provided, otherwise uses the current app locale. + * + * @param {string} [langTag] - The language tag to determine text direction. + */ + #updateTextDirection(langTag) { + const { textArea } = this.elements; + if (langTag) { + const scriptDirection = Services.intl.getScriptDirection(langTag); + textArea.setAttribute("dir", scriptDirection); + } else { + textArea.removeAttribute("dir"); + } + } + + /** + * Requests a translations port for a given language pair. + * + * @param {string} fromLanguage - The from-language. + * @param {string} toLanguage - The to-language. + * + * @returns {Promise} The message port promise. + */ + async #requestTranslationsPort(fromLanguage, toLanguage) { + const innerWindowId = + gBrowser.selectedBrowser.browsingContext.top.embedderElement + .innerWindowID; + if (!innerWindowId) { + return undefined; + } + const port = await TranslationsParent.requestTranslationsPort( + innerWindowId, + fromLanguage, + toLanguage + ); + return port; + } + + /** + * Retrieves the existing translator for the specified language pair if it matches, + * otherwise creates a new translator. + * + * @param {string} fromLanguage - The source language code. + * @param {string} toLanguage - The target language code. + * + * @returns {Promise} A promise that resolves to a `Translator` instance for the given language pair. + */ + async #getOrCreateTranslator(fromLanguage, toLanguage) { + if (this.#translatorMatchesLangPair(fromLanguage, toLanguage)) { + return this.#translator; + } + + this.console?.log( + `Creating new Translator (${fromLanguage}-${toLanguage})` + ); + if (this.#translator) { + this.#translator.destroy(); + this.#translator = null; + } + + this.#translator = await Translator.create( + fromLanguage, + toLanguage, + this.#requestTranslationsPort + ); + return this.#translator; + } + + /** + * Initiates the translation process if the panel state and selected languages + * meet the conditions for translation. + */ + #maybeRequestTranslation() { + if (this.#isClosed()) { + return; + } + const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair(); + const nextState = this.#changeStateByLanguagePair(fromLanguage, toLanguage); + if (nextState !== "translatable") { + return; + } + + const translationId = ++this.#translationId; + this.#getOrCreateTranslator(fromLanguage, toLanguage) + .then(translator => { + if ( + this.#shouldContinueTranslation( + translationId, + fromLanguage, + toLanguage + ) + ) { + this.#changeStateToTranslating(); + return translator.translate(this.getSourceText()); + } + return null; + }) + .then(translatedText => { + if ( + translatedText && + this.#shouldContinueTranslation( + translationId, + fromLanguage, + toLanguage + ) + ) { + this.#changeStateToTranslated(translatedText); + } else if (this.#isOpen()) { + this.#changeStateTo("idle", /* retainEntires */ false, { + sourceText: this.getSourceText(), + }); + } + }) + .catch(error => this.console?.error(error)); + } + + /** + * Attaches event listeners to the target element for initiating translation on specified event types. + * + * @param {string[]} eventTypes - An array of event types to listen for. + * @param {object} target - The target element to attach event listeners to. + * @throws {Error} If an unrecognized event type is provided. + */ + #maybeTranslateOnEvents(eventTypes, target) { + if (!target.translationListenerCallbacks) { + target.translationListenerCallbacks = []; + } + if (target.translationListenerCallbacks.length === 0) { + for (const eventType of eventTypes) { + let callback; + switch (eventType) { + case "blur": + case "popuphidden": { + callback = () => { + this.#maybeRequestTranslation(); + this.#removeTranslationListeners(target); + }; + break; + } + case "keypress": { + callback = event => { + if (event.key === "Enter") { + this.#maybeRequestTranslation(); + } + this.#removeTranslationListeners(target); + }; + break; + } + default: { + throw new Error( + `Invalid translation event type given: '${eventType}` + ); + } + } + target.addEventListener(eventType, callback, { once: true }); + target.translationListenerCallbacks.push({ eventType, callback }); + } + } + } + + /** + * Removes all translation event listeners from the target element. + * + * @param {Element} target - The element from which event listeners are to be removed. + */ + #removeTranslationListeners(target) { + for (const { eventType, callback } of target.translationListenerCallbacks) { + target.removeEventListener(eventType, callback); + } + target.translationListenerCallbacks = []; } })(); -- cgit v1.2.3