tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

selectTranslationsPanel.js (78085B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /**
      6 * @typedef {import("../../../../toolkit/components/translations/translations").SelectTranslationsPanelState} SelectTranslationsPanelState
      7 * @typedef {import("../../../../toolkit/components/translations/translations").LanguagePair} LanguagePair
      8 */
      9 
     10 ChromeUtils.defineESModuleGetters(this, {
     11  LanguageDetector:
     12    "resource://gre/modules/translations/LanguageDetector.sys.mjs",
     13  TranslationsPanelShared:
     14    "chrome://browser/content/translations/TranslationsPanelShared.sys.mjs",
     15  TranslationsUtils:
     16    "chrome://global/content/translations/TranslationsUtils.mjs",
     17  // NOTE: Translator.mjs is missing but should be unused. tor-browser#44045.
     18  Translator: "chrome://global/content/translations/Translator.mjs",
     19 });
     20 
     21 XPCOMUtils.defineLazyServiceGetter(
     22  this,
     23  "ClipboardHelper",
     24  "@mozilla.org/widget/clipboardhelper;1",
     25  Ci.nsIClipboardHelper
     26 );
     27 
     28 XPCOMUtils.defineLazyServiceGetter(
     29  this,
     30  "GfxInfo",
     31  "@mozilla.org/gfx/info;1",
     32  Ci.nsIGfxInfo
     33 );
     34 
     35 /**
     36 * This singleton class controls the SelectTranslations panel.
     37 *
     38 * A global instance of this class is created once per top ChromeWindow and is initialized
     39 * when the context menu is opened in that window.
     40 *
     41 * See the comment above TranslationsParent for more details.
     42 *
     43 * @see TranslationsParent
     44 */
     45 var SelectTranslationsPanel = new (class {
     46  /** @type {Console?} */
     47  #console;
     48 
     49  /**
     50   * Lazily get a console instance. Note that this script is loaded in very early to
     51   * the browser loading process, and may run before the console is available. In
     52   * this case the console will return as `undefined`.
     53   *
     54   * @returns {Console | void}
     55   */
     56  get console() {
     57    if (!this.#console) {
     58      try {
     59        this.#console = console.createInstance({
     60          maxLogLevelPref: "browser.translations.logLevel",
     61          prefix: "Translations",
     62        });
     63      } catch {
     64        // The console may not be initialized yet.
     65      }
     66    }
     67    return this.#console;
     68  }
     69 
     70  /**
     71   * The textarea height for shorter text.
     72   *
     73   * @type {string}
     74   */
     75  #shortTextHeight = "8em";
     76 
     77  /**
     78   * Retrieves the read-only textarea height for shorter text.
     79   *
     80   * @see #shortTextHeight
     81   */
     82  get shortTextHeight() {
     83    return this.#shortTextHeight;
     84  }
     85 
     86  /**
     87   * The textarea height for shorter text.
     88   *
     89   * @type {string}
     90   */
     91  #longTextHeight = "16em";
     92 
     93  /**
     94   * The alignment position value of the panel when it opened.
     95   *
     96   * We want to cache this value because some alignments, such as "before_start"
     97   * and "before_end" will cause the panel to expand upward from the top edge
     98   * when the user is trying to resize the text-area by dragging the resizer downward.
     99   *
    100   * Knowing this value helps us determine if we should disable the textarea resizer
    101   * based on how and where the panel was opened.
    102   *
    103   * @see #maybeEnableTextAreaResizer
    104   */
    105  #alignmentPosition = "";
    106 
    107  /**
    108   * A value to cache the most recent state that caused the panel's UI to update.
    109   *
    110   * The event-driven nature of this code can sometimes make redundant calls to
    111   * idempotent UI updates, however the telemetry data is not idempotent and will
    112   * be double counted.
    113   *
    114   * This value allows us to avoid double-counting telemetry if we're making a
    115   * redundant call to a UI update.
    116   *
    117   * @type {string}
    118   */
    119  #mostRecentUIPhase = "closed";
    120 
    121  /**
    122   * A cached value for the count of words in the source text as determined by the Intl.Segmenter
    123   * for the currently selected from-language, which is reported to telemetry. This prevents us
    124   * from having to allocate resource for the segmenter multiple times if the user changes the target
    125   * language.
    126   *
    127   * This value should be invalidated when the panel opens and when the from-language is changed.
    128   *
    129   * @type {number}
    130   */
    131  #sourceTextWordCount = undefined;
    132 
    133  /**
    134   * Cached information about the document's detected language and the user's
    135   * current language settings, useful for populating telemetry events.
    136   *
    137   * @type {object}
    138   */
    139  #languageInfo = {
    140    docLangTag: undefined,
    141    isDocLangTagSupported: undefined,
    142    topPreferredLanguage: undefined,
    143  };
    144 
    145  /**
    146   * Retrieves the read-only textarea height for longer text.
    147   *
    148   * @see #longTextHeight
    149   */
    150  get longTextHeight() {
    151    return this.#longTextHeight;
    152  }
    153 
    154  /**
    155   * The threshold used to determine when the panel should
    156   * use the short text-height vs. the long-text height.
    157   *
    158   * @type {number}
    159   */
    160  #textLengthThreshold = 800;
    161 
    162  /**
    163   * Retrieves the read-only text-length threshold.
    164   *
    165   * @see #textLengthThreshold
    166   */
    167  get textLengthThreshold() {
    168    return this.#textLengthThreshold;
    169  }
    170 
    171  /**
    172   * The localized placeholder text to display when idle.
    173   *
    174   * @type {string}
    175   */
    176  #idlePlaceholderText;
    177 
    178  /**
    179   * The localized placeholder text to display when translating.
    180   *
    181   * @type {string}
    182   */
    183  #translatingPlaceholderText;
    184 
    185  /**
    186   * Where the lazy elements are stored.
    187   *
    188   * @type {Record<string, Element>?}
    189   */
    190  #lazyElements;
    191 
    192  /**
    193   * Set to true the first time event listeners are initialized.
    194   *
    195   * @type {boolean}
    196   */
    197  #eventListenersInitialized = false;
    198 
    199  /**
    200   * This value is true if this page does not allow Full Page Translations,
    201   * e.g. PDFs, reader mode, internal Firefox pages.
    202   *
    203   * Many of these are cases where the SelectTranslationsPanel is available
    204   * even though the FullPageTranslationsPanel is not, so this helps inform
    205   * whether the translate-full-page button should be allowed in this context.
    206   *
    207   * @type {boolean}
    208   */
    209  #isFullPageTranslationsRestrictedForPage = true;
    210 
    211  /**
    212   * The BCP-47 language tag of the active target language for Full-Page Translations,
    213   * if available. This may not be available if Full-Page Translations is not currently
    214   * active in the current tab of the current window, or if Full-Page Translations is
    215   * restricted on the current page.
    216   *
    217   * @type { string | undefined }
    218   */
    219  #activeFullPageTranslationsTargetLanguage = undefined;
    220 
    221  /**
    222   * The internal state of the SelectTranslationsPanel.
    223   *
    224   * @type {SelectTranslationsPanelState}
    225   */
    226  #translationState = { phase: "closed" };
    227 
    228  /**
    229   * An Id that increments with each translation, used to help keep track
    230   * of whether an active translation request continue its progression or
    231   * stop due to the existence of a newer translation request.
    232   *
    233   * @type {number}
    234   */
    235  #translationId = 0;
    236 
    237  /**
    238   * Lazily creates the dom elements, and lazily selects them.
    239   *
    240   * @returns {Record<string, Element>}
    241   */
    242  get elements() {
    243    if (!this.#lazyElements) {
    244      // Lazily turn the template into a DOM element.
    245      /** @type {HTMLTemplateElement} */
    246      const wrapper = document.getElementById(
    247        "template-select-translations-panel"
    248      );
    249 
    250      const panel = wrapper.content.firstElementChild;
    251      wrapper.replaceWith(wrapper.content);
    252 
    253      // Lazily select the elements.
    254      this.#lazyElements = {
    255        panel,
    256      };
    257 
    258      TranslationsPanelShared.defineLazyElements(document, this.#lazyElements, {
    259        cancelButton: "select-translations-panel-cancel-button",
    260        copyButton: "select-translations-panel-copy-button",
    261        doneButtonPrimary: "select-translations-panel-done-button-primary",
    262        doneButtonSecondary: "select-translations-panel-done-button-secondary",
    263        fromLabel: "select-translations-panel-from-label",
    264        fromMenuList: "select-translations-panel-from",
    265        fromMenuPopup: "select-translations-panel-from-menupopup",
    266        header: "select-translations-panel-header",
    267        initFailureContent: "select-translations-panel-init-failure-content",
    268        initFailureMessageBar:
    269          "select-translations-panel-init-failure-message-bar",
    270        mainContent: "select-translations-panel-main-content",
    271        settingsButton: "select-translations-panel-settings-button",
    272        textArea: "select-translations-panel-text-area",
    273        toLabel: "select-translations-panel-to-label",
    274        toMenuList: "select-translations-panel-to",
    275        toMenuPopup: "select-translations-panel-to-menupopup",
    276        translateButton: "select-translations-panel-translate-button",
    277        translateFullPageButton:
    278          "select-translations-panel-translate-full-page-button",
    279        translationFailureMessageBar:
    280          "select-translations-panel-translation-failure-message-bar",
    281        tryAgainButton: "select-translations-panel-try-again-button",
    282        tryAnotherSourceMenuList:
    283          "select-translations-panel-try-another-language",
    284        tryAnotherSourceMenuPopup:
    285          "select-translations-panel-try-another-language-menupopup",
    286        unsupportedLanguageContent:
    287          "select-translations-panel-unsupported-language-content",
    288        unsupportedLanguageMessageBar:
    289          "select-translations-panel-unsupported-language-message-bar",
    290      });
    291    }
    292 
    293    return this.#lazyElements;
    294  }
    295 
    296  /**
    297   * Attempts to determine the best language tag to use as the source language for translation.
    298   * If the detected language is not supported, attempts to fallback to the document's language tag.
    299   *
    300   * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed.
    301   *
    302   * @returns {Promise<string>} - The code of a supported language, a supported document language, or the top detected language.
    303   */
    304  async getTopSupportedDetectedLanguage(textToTranslate) {
    305    // We want to refresh our cache every time we make a determination about the detected source language,
    306    // even if we never make it to the section of the logic below where we consider the document language,
    307    // otherwise the incorrect, cached document language may be reported to telemetry.
    308    const { docLangTag, isDocLangTagSupported } = this.#getLanguageInfo(
    309      /* forceFetch */ true
    310    );
    311 
    312    // First see if any of the detected languages are supported and return it if so.
    313    const { language, languages } =
    314      await LanguageDetector.detectLanguage(textToTranslate);
    315    const languagePairs = await TranslationsParent.getNonPivotLanguagePairs();
    316    for (const { languageCode } of languages) {
    317      const compatibleLangTag =
    318        TranslationsParent.findCompatibleSourceLangTagSync(
    319          languageCode,
    320          languagePairs
    321        );
    322      if (compatibleLangTag) {
    323        return compatibleLangTag;
    324      }
    325    }
    326 
    327    // Since none of the detected languages were supported, check to see if the
    328    // document has a specified language tag that is supported.
    329    if (isDocLangTagSupported) {
    330      return docLangTag;
    331    }
    332 
    333    // No supported language was found, so return the top detected language
    334    // to inform the panel's unsupported language state.
    335    return language;
    336  }
    337 
    338  /**
    339   * Attempts to cache the languageInformation for this page and the user's current settings.
    340   * This data is helpful for telemetry. Leaves the cache unpopulated if the info failed to be
    341   * retrieved.
    342   *
    343   * @param {boolean} forceFetch - Clears the cache and attempts to refetch data if true.
    344   *
    345   * @returns {object} - The cached language-info object.
    346   */
    347  #getLanguageInfo(forceFetch = false) {
    348    if (!forceFetch && this.#languageInfo.docLangTag !== undefined) {
    349      return this.#languageInfo;
    350    }
    351 
    352    this.#isFullPageTranslationsRestrictedForPage =
    353      TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser);
    354    this.#activeFullPageTranslationsTargetLanguage = this
    355      .#isFullPageTranslationsRestrictedForPage
    356      ? undefined
    357      : this.#maybeGetActiveFullPageTranslationsTargetLanguage();
    358 
    359    this.#languageInfo = {
    360      docLangTag: undefined,
    361      isDocLangTagSupported: undefined,
    362      topPreferredLanguage: undefined,
    363    };
    364 
    365    try {
    366      const actor = TranslationsParent.getTranslationsActor(
    367        gBrowser.selectedBrowser
    368      );
    369      const {
    370        detectedLanguages: { docLangTag, isDocLangTagSupported },
    371      } = actor.languageState;
    372      const preferredLanguages = TranslationsParent.getPreferredLanguages();
    373      const topPreferredLanguage = preferredLanguages?.[0];
    374      this.#languageInfo = {
    375        docLangTag:
    376          // If Full-Page Translations (FPT) is active, we need to assume that the effective
    377          // document language tag matches the language of the FPT target language, otherwise,
    378          // if FPT is not active, we can take the real docLangTag value.
    379          this.#activeFullPageTranslationsTargetLanguage ?? docLangTag,
    380        isDocLangTagSupported,
    381        topPreferredLanguage,
    382      };
    383    } catch (error) {
    384      // Failed to retrieve the Translations actor to detect the document language.
    385      // This is most likely due to attempting to retrieve the actor in a page that
    386      // is restricted for Full Page Translations, such as a PDF or reader mode, but
    387      // Select Translations is often still available, so we can safely continue to
    388      // the final return fallback.
    389      if (
    390        !TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser)
    391      ) {
    392        // If we failed to retrieve the TranslationsParent actor on a non-restricted page,
    393        // we should warn about this, because it is unexpected. The SelectTranslationsPanel
    394        // itself will display an error state if this causes a failure, and this will help
    395        // diagnose the issue if this scenario should ever occur.
    396        this.console?.warn(
    397          "Failed to retrieve the TranslationsParent actor on a page where Full Page Translations is not restricted."
    398        );
    399        this.console?.error(error);
    400      }
    401    }
    402 
    403    return this.#languageInfo;
    404  }
    405 
    406  /**
    407   * Detects the language of the provided text and retrieves a language pair for translation
    408   * based on user settings.
    409   *
    410   * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed.
    411   * @returns {Promise<{sourceLanguage?: string, targetLanguage?: string}>} - An object containing the language pair for the translation.
    412   *   The `sourceLanguage` property is omitted if it is a language that is not currently supported by Firefox Translations.
    413   */
    414  async getLangPairPromise(textToTranslate) {
    415    if (
    416      TranslationsParent.isInAutomation() &&
    417      !TranslationsParent.isTranslationsEngineMocked()
    418    ) {
    419      // If we are in automation, and the Translations Engine is NOT mocked, then that means
    420      // we are in a test case in which we are not explicitly testing Select Translations,
    421      // and the code to get the supported languages below will not be available. However,
    422      // we still need to ensure that the translate-selection menuitem in the context menu
    423      // is compatible with all code in other tests, so we will return "en" for the purpose
    424      // of being able to localize and display the context-menu item in other test cases.
    425      return { targetLanguage: "en" };
    426    }
    427 
    428    const sourceLanguage =
    429      await SelectTranslationsPanel.getTopSupportedDetectedLanguage(
    430        textToTranslate
    431      );
    432    const targetLanguage =
    433      await TranslationsParent.getTopPreferredSupportedToLang({
    434        // Avoid offering a same-language to same-language translation if we can.
    435        excludeLangTags: [sourceLanguage],
    436      });
    437 
    438    return { sourceLanguage, targetLanguage };
    439  }
    440 
    441  /**
    442   * Close the Select Translations Panel.
    443   */
    444  close() {
    445    PanelMultiView.hidePopup(this.elements.panel);
    446    this.#mostRecentUIPhase = "closed";
    447  }
    448 
    449  /**
    450   * Ensures that the from-language and to-language dropdowns are built.
    451   *
    452   * This can be called every time the popup is shown, since it will retry
    453   * when there is an error (such as a network error) or be a no-op if the
    454   * dropdowns have already been initialized.
    455   */
    456  async #ensureLangListsBuilt() {
    457    await TranslationsPanelShared.ensureLangListsBuilt(document, this);
    458  }
    459 
    460  /**
    461   * Initializes the selected value of the given language dropdown based on the language tag.
    462   *
    463   * @param {string} langTag - A BCP-47 language tag.
    464   * @param {Element} menuList - The menu list element to update.
    465   *
    466   * @returns {Promise<void>}
    467   */
    468  async #initializeLanguageMenuList(langTag, menuList) {
    469    const compatibleLangTag =
    470      menuList.id === this.elements.fromMenuList.id
    471        ? await TranslationsParent.findCompatibleSourceLangTag(langTag)
    472        : await TranslationsParent.findCompatibleTargetLangTag(langTag);
    473 
    474    if (compatibleLangTag) {
    475      // Remove the data-l10n-id because the menulist label will
    476      // be populated from the supported language's display name.
    477      menuList.removeAttribute("data-l10n-id");
    478      menuList.value = compatibleLangTag;
    479    } else {
    480      await this.#deselectLanguage(menuList);
    481    }
    482  }
    483 
    484  /**
    485   * Initializes the selected values of the from-language and to-language menu
    486   * lists based on the result of the given language pair promise.
    487   *
    488   * @param {Promise<{sourceLanguage?: string, targetLanguage?: string}>} langPairPromise
    489   *
    490   * @returns {Promise<void>}
    491   */
    492  async #initializeLanguageMenuLists(langPairPromise) {
    493    const { sourceLanguage, targetLanguage } = await langPairPromise;
    494    const {
    495      fromMenuList,
    496      fromMenuPopup,
    497      toMenuList,
    498      toMenuPopup,
    499      tryAnotherSourceMenuList,
    500    } = this.elements;
    501 
    502    await Promise.all([
    503      this.#initializeLanguageMenuList(sourceLanguage, fromMenuList),
    504      this.#initializeLanguageMenuList(targetLanguage, toMenuList),
    505      this.#initializeLanguageMenuList(null, tryAnotherSourceMenuList),
    506    ]);
    507 
    508    this.#maybeTranslateOnEvents(["keypress"], fromMenuList);
    509    this.#maybeTranslateOnEvents(["keypress"], toMenuList);
    510 
    511    this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup);
    512    this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup);
    513  }
    514 
    515  /**
    516   * Initializes event listeners on the panel class the first time
    517   * this function is called, and is a no-op on subsequent calls.
    518   */
    519  #initializeEventListeners() {
    520    if (this.#eventListenersInitialized) {
    521      // Event listeners have already been initialized, do nothing.
    522      return;
    523    }
    524 
    525    const { panel, fromMenuList, toMenuList, tryAnotherSourceMenuList } =
    526      this.elements;
    527 
    528    // XUL buttons on macOS do not handle the Enter key by default for
    529    // the focused element, so we must listen for the Enter key manually:
    530    // https://searchfox.org/mozilla-central/rev/4c8627a76e2e0a9b49c2b673424da478e08715ad/dom/xul/XULButtonElement.cpp#563-579
    531    if (AppConstants.platform === "macosx") {
    532      panel.addEventListener("keypress", this);
    533    }
    534    panel.addEventListener("popupshown", this);
    535    panel.addEventListener("popuphidden", this);
    536 
    537    panel.addEventListener("command", this);
    538    fromMenuList.addEventListener("command", this);
    539    toMenuList.addEventListener("command", this);
    540    tryAnotherSourceMenuList.addEventListener("command", this);
    541 
    542    this.#eventListenersInitialized = true;
    543  }
    544 
    545  /**
    546   * Opens the panel, ensuring the panel's UI and state are initialized correctly.
    547   *
    548   * @param {Event} event - The triggering event for opening the panel.
    549   * @param {number} screenX - The x-axis location of the screen at which to open the popup.
    550   * @param {number} screenY - The y-axis location of the screen at which to open the popup.
    551   * @param {string} sourceText - The text to translate.
    552   * @param {boolean} isTextSelected - True if the text comes from a selection, false if it comes from a hyperlink.
    553   * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns.
    554   * @param {boolean} maintainFlow - Whether the telemetry flow-id should be persisted or assigned a new id.
    555   *
    556   * @returns {Promise<void>}
    557   */
    558  async open(
    559    event,
    560    screenX,
    561    screenY,
    562    sourceText,
    563    isTextSelected,
    564    langPairPromise,
    565    maintainFlow = false
    566  ) {
    567    if (this.#isOpen()) {
    568      await this.#forceReopen(
    569        event,
    570        screenX,
    571        screenY,
    572        sourceText,
    573        isTextSelected,
    574        langPairPromise
    575      );
    576      return;
    577    }
    578 
    579    const { sourceLanguage, targetLanguage } = await langPairPromise;
    580    const { docLangTag, topPreferredLanguage } = this.#getLanguageInfo();
    581 
    582    TranslationsParent.telemetry()
    583      .selectTranslationsPanel()
    584      .onOpen({
    585        maintainFlow,
    586        docLangTag,
    587        sourceLanguage,
    588        targetLanguage,
    589        topPreferredLanguage,
    590        textSource: isTextSelected ? "selection" : "hyperlink",
    591      });
    592 
    593    try {
    594      this.#sourceTextWordCount = undefined;
    595      this.#initializeEventListeners();
    596      await this.#ensureLangListsBuilt();
    597      await Promise.all([
    598        this.#cachePlaceholderText(),
    599        this.#initializeLanguageMenuLists(langPairPromise),
    600        this.#registerSourceText(sourceText, langPairPromise),
    601      ]);
    602      this.#maybeRequestTranslation();
    603    } catch (error) {
    604      this.console?.error(error);
    605      this.#changeStateToInitFailure(
    606        event,
    607        screenX,
    608        screenY,
    609        sourceText,
    610        isTextSelected,
    611        langPairPromise
    612      );
    613    }
    614 
    615    this.#openPopup(event, screenX, screenY);
    616  }
    617 
    618  /**
    619   * Attempts to retrieve the language tag of the requested target language
    620   * for Full Page Translations, if Full Page Translations is active on the page
    621   * within the active tab of the active window.
    622   *
    623   * @returns {string | undefined} - The BCP-47 language tag.
    624   */
    625  #maybeGetActiveFullPageTranslationsTargetLanguage() {
    626    try {
    627      const { requestedLanguagePair } = TranslationsParent.getTranslationsActor(
    628        gBrowser.selectedBrowser
    629      ).languageState;
    630      return requestedLanguagePair?.targetLanguage;
    631    } catch {
    632      this.console.warn("Failed to retrieve the TranslationsParent actor.");
    633    }
    634    return undefined;
    635  }
    636 
    637  /**
    638   * Forces the panel to close and reopen at the same location.
    639   *
    640   * This should never be called in the regular flow of events, but is good to have in case
    641   * the panel somehow gets into an invalid state.
    642   *
    643   * @param {Event} event - The triggering event for opening the panel.
    644   * @param {number} screenX - The x-axis location of the screen at which to open the popup.
    645   * @param {number} screenY - The y-axis location of the screen at which to open the popup.
    646   * @param {string} sourceText - The text to translate.
    647   * @param {boolean} isTextSelected - True if the text comes from a selection, false if it comes from a hyperlink.
    648   * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns.
    649   *
    650   * @returns {Promise<void>}
    651   */
    652  async #forceReopen(
    653    event,
    654    screenX,
    655    screenY,
    656    sourceText,
    657    isTextSelected,
    658    langPairPromise
    659  ) {
    660    this.console?.warn("The SelectTranslationsPanel was forced to reopen.");
    661    this.close();
    662    this.#changeStateToClosed();
    663    await this.open(
    664      event,
    665      screenX,
    666      screenY,
    667      sourceText,
    668      isTextSelected,
    669      langPairPromise
    670    );
    671  }
    672 
    673  /**
    674   * Opens the panel popup at a location on the screen.
    675   *
    676   * @param {Event} event - The event that triggers the popup opening.
    677   * @param {number} screenX - The x-axis location of the screen at which to open the popup.
    678   * @param {number} screenY - The y-axis location of the screen at which to open the popup.
    679   */
    680  #openPopup(event, screenX, screenY) {
    681    this.console?.log("Showing SelectTranslationsPanel");
    682    const { panel } = this.elements;
    683    this.#cacheAlignmentPositionOnOpen();
    684    panel.openPopupAtScreenRect(
    685      "after_start",
    686      screenX,
    687      screenY,
    688      /* width */ 0,
    689      /* height */ 0,
    690      /* isContextMenu */ false,
    691      /* attributesOverride */ false,
    692      event
    693    );
    694  }
    695 
    696  /**
    697   * Resets the cached alignment-position value and adds an event listener
    698   * to set the value again when the panel is positioned before opening.
    699   * See the comment on the data member for more details.
    700   *
    701   * @see #alignmentPosition
    702   */
    703  #cacheAlignmentPositionOnOpen() {
    704    const { panel } = this.elements;
    705    this.#alignmentPosition = "";
    706    panel.addEventListener(
    707      "popuppositioned",
    708      popupPositionedEvent => {
    709        // Cache the alignment position when the popup is opened.
    710        this.#alignmentPosition = popupPositionedEvent.alignmentPosition;
    711      },
    712      { once: true }
    713    );
    714  }
    715 
    716  /**
    717   * Adds the source text to the translation state and adapts the size of the text area based
    718   * on the length of the text.
    719   *
    720   * @param {string} sourceText - The text to translate.
    721   * @param {Promise<{sourceLanguage?: string, targetLanguage?: string}>} langPairPromise
    722   *
    723   * @returns {Promise<void>}
    724   */
    725  async #registerSourceText(sourceText, langPairPromise) {
    726    const { textArea } = this.elements;
    727    const { sourceLanguage, targetLanguage } = await langPairPromise;
    728    const compatibleFromLang =
    729      await TranslationsParent.findCompatibleSourceLangTag(sourceLanguage);
    730 
    731    if (compatibleFromLang) {
    732      this.#changeStateTo("idle", /* retainEntries */ false, {
    733        sourceText,
    734        sourceLanguage: compatibleFromLang,
    735        targetLanguage,
    736      });
    737    } else {
    738      this.#changeStateTo("unsupported", /* retainEntries */ false, {
    739        sourceText,
    740        detectedLanguage: sourceLanguage,
    741        targetLanguage,
    742      });
    743    }
    744 
    745    textArea.value = "";
    746    textArea.style.resize = "none";
    747    textArea.style.maxHeight = null;
    748    if (sourceText.length < SelectTranslationsPanel.textLengthThreshold) {
    749      textArea.style.height = SelectTranslationsPanel.shortTextHeight;
    750    } else {
    751      textArea.style.height = SelectTranslationsPanel.longTextHeight;
    752    }
    753 
    754    this.#maybeTranslateOnEvents(["focus"], textArea);
    755  }
    756 
    757  /**
    758   * Caches the localized text to use as placeholders.
    759   */
    760  async #cachePlaceholderText() {
    761    const [idleText, translatingText] = await document.l10n.formatValues([
    762      { id: "select-translations-panel-idle-placeholder-text" },
    763      { id: "select-translations-panel-translating-placeholder-text" },
    764    ]);
    765    this.#idlePlaceholderText = idleText;
    766    this.#translatingPlaceholderText = translatingText;
    767  }
    768 
    769  /**
    770   * Opens the settings menu popup at the settings button gear-icon.
    771   */
    772  #openSettingsPopup() {
    773    TranslationsParent.telemetry()
    774      .selectTranslationsPanel()
    775      .onOpenSettingsMenu();
    776 
    777    const { settingsButton } = this.elements;
    778    const popup = settingsButton.ownerDocument.getElementById(
    779      "select-translations-panel-settings-menupopup"
    780    );
    781 
    782    popup.openPopup(settingsButton, "after_start");
    783  }
    784 
    785  /**
    786   * Opens the "About translation in Firefox" Mozilla support page in a new tab.
    787   */
    788  onAboutTranslations() {
    789    TranslationsParent.telemetry()
    790      .selectTranslationsPanel()
    791      .onAboutTranslations();
    792 
    793    this.close();
    794    const window =
    795      gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
    796    window.openTrustedLinkIn(
    797      "https://support.mozilla.org/kb/website-translation",
    798      "tab",
    799      {
    800        forceForeground: true,
    801        triggeringPrincipal:
    802          Services.scriptSecurityManager.getSystemPrincipal(),
    803      }
    804    );
    805  }
    806 
    807  /**
    808   * Opens the Translations section of about:preferences in a new tab.
    809   */
    810  openTranslationsSettingsPage() {
    811    TranslationsParent.telemetry()
    812      .selectTranslationsPanel()
    813      .onTranslationSettings();
    814 
    815    this.close();
    816    const window =
    817      gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
    818    window.openTrustedLinkIn("about:preferences#general-translations", "tab");
    819  }
    820 
    821  /**
    822   * Handles events when a command event is triggered within the panel.
    823   *
    824   * @param {Element} target - The event target
    825   */
    826  #handleCommandEvent(target) {
    827    const {
    828      cancelButton,
    829      copyButton,
    830      doneButtonPrimary,
    831      doneButtonSecondary,
    832      fromMenuList,
    833      fromMenuPopup,
    834      settingsButton,
    835      toMenuList,
    836      toMenuPopup,
    837      translateButton,
    838      translateFullPageButton,
    839      tryAgainButton,
    840      tryAnotherSourceMenuList,
    841      tryAnotherSourceMenuPopup,
    842    } = this.elements;
    843    switch (target.id) {
    844      case cancelButton.id: {
    845        this.onClickCancelButton();
    846        break;
    847      }
    848      case copyButton.id: {
    849        this.onClickCopyButton();
    850        break;
    851      }
    852      case doneButtonPrimary.id:
    853      case doneButtonSecondary.id: {
    854        this.onClickDoneButton();
    855        break;
    856      }
    857      case fromMenuList.id:
    858      case fromMenuPopup.id: {
    859        this.onChangeFromLanguage();
    860        break;
    861      }
    862      case settingsButton.id: {
    863        this.#openSettingsPopup();
    864        break;
    865      }
    866      case toMenuList.id:
    867      case toMenuPopup.id: {
    868        this.onChangeToLanguage();
    869        break;
    870      }
    871      case translateButton.id: {
    872        this.onClickTranslateButton();
    873        break;
    874      }
    875      case translateFullPageButton.id: {
    876        this.onClickTranslateFullPageButton();
    877        break;
    878      }
    879      case tryAgainButton.id: {
    880        this.onClickTryAgainButton();
    881        break;
    882      }
    883      case tryAnotherSourceMenuList.id:
    884      case tryAnotherSourceMenuPopup.id: {
    885        this.onChangeTryAnotherSourceLanguage();
    886        break;
    887      }
    888    }
    889  }
    890 
    891  /**
    892   * Handles events when the Enter key is pressed within the panel.
    893   *
    894   * @param {Element} target - The event target
    895   */
    896  #handleEnterKeyPressed(target) {
    897    const {
    898      cancelButton,
    899      copyButton,
    900      doneButtonPrimary,
    901      doneButtonSecondary,
    902      settingsButton,
    903      translateButton,
    904      translateFullPageButton,
    905      tryAgainButton,
    906    } = this.elements;
    907 
    908    switch (target.id) {
    909      case cancelButton.id: {
    910        this.onClickCancelButton();
    911        break;
    912      }
    913      case copyButton.id: {
    914        this.onClickCopyButton();
    915        break;
    916      }
    917      case doneButtonPrimary.id:
    918      case doneButtonSecondary.id: {
    919        this.onClickDoneButton();
    920        break;
    921      }
    922      case settingsButton.id: {
    923        this.#openSettingsPopup();
    924        break;
    925      }
    926      case translateButton.id: {
    927        this.onClickTranslateButton();
    928        break;
    929      }
    930      case translateFullPageButton.id: {
    931        this.onClickTranslateFullPageButton();
    932        break;
    933      }
    934      case tryAgainButton.id: {
    935        this.onClickTryAgainButton();
    936        break;
    937      }
    938    }
    939  }
    940 
    941  /**
    942   * Conditionally enables the resizer component at the bottom corner of the text area,
    943   * and limits the maximum height that the textarea can be resized.
    944   *
    945   * For systems using Wayland, this function ensures that the panel cannot be resized past
    946   * the border of the current Firefox window.
    947   *
    948   * For all other systems, this function ensures that the panel cannot be resized past the
    949   * bottom edge of the available screen space.
    950   */
    951  #maybeEnableTextAreaResizer() {
    952    // The alignment position of the panel is determined during the "popuppositioned" event
    953    // when the panel opens. The alignment positions help us determine in which orientation
    954    // the panel is anchored to the screen space.
    955    //
    956    // *  "after_start": The panel is anchored at the top-left     corner in LTR locales, top-right    in RTL locales.
    957    // *    "after_end": The panel is anchored at the top-right    corner in LTR locales, top-left     in RTL locales.
    958    // * "before_start": The panel is anchored at the bottom-left  corner in LTR locales, bottom-right in RTL locales.
    959    // *   "before_end": The panel is anchored at the bottom-right corner in LTR locales, bottom-left  in RTL locales.
    960    //
    961    //   ┌─Anchor(LTR)          ┌─Anchor(RTL)
    962    //   │       Anchor(RTL)─┐  │       Anchor(LTR)─┐
    963    //   │                   │  │                   │
    964    //   x───────────────────x  x───────────────────x
    965    //   │                   │  │                   │
    966    //   │       Panel       │  │       Panel       │
    967    //   │   "after_start"   │  │    "after_end"    │
    968    //   │                   │  │                   │
    969    //   └───────────────────┘  └───────────────────┘
    970    //
    971    //   ┌───────────────────┐  ┌───────────────────┐
    972    //   │                   │  │                   │
    973    //   │       Panel       │  │       Panel       │
    974    //   │   "before_start"  │  │    "before_end"   │
    975    //   │                   │  │                   │
    976    //   x───────────────────x  x───────────────────x
    977    //   │                   │  │                   │
    978    //   │       Anchor(RTL)─┘  │       Anchor(LTR)─┘
    979    //   └─Anchor(LTR)          └─Anchor(RTL)
    980    //
    981    // The default choice for the panel is "after_start", to match the content context menu's alignment. However, it is
    982    // possible to end up with any of the four combinations. Before the panel is opened, the XUL popup manager needs to
    983    // make a determination about the size of the panel and whether or not it will fit within the visible screen area with
    984    // the intended alignment. The manager may change the panel's alignment before opening to ensure the panel is fully visible.
    985    //
    986    // For example, if the panel is opened such that the bottom edge would be rendered off screen, then the XUL popup manager
    987    // will change the alignment from "after_start" to "before_start", anchoring the panel's bottom corner to the target screen
    988    // location instead of its top corner. This transformation ensures that the whole of the panel is visible on the screen.
    989    //
    990    // When the panel is anchored by one of its bottom corners (the "before_..." options), then it causes unintentionally odd
    991    // behavior where dragging the text-area resizer downward with the mouse actually grows the panel's top edge upward, since
    992    // the bottom of the panel is anchored in place. We want to disable the resizer if the panel was positioned to be anchored
    993    // from one of its bottom corners.
    994    switch (this.#alignmentPosition) {
    995      case "after_start":
    996      case "after_end": {
    997        // The text-area resizer will act normally.
    998        break;
    999      }
   1000      case "before_start":
   1001      case "before_end": {
   1002        // The text-area resizer increase the size of the panel from the top edge even
   1003        // though the user is dragging the resizer downward with the mouse.
   1004        this.console?.debug(
   1005          `Disabling text-area resizer due to panel alignment position: "${
   1006            this.#alignmentPosition
   1007          }"`
   1008        );
   1009        return;
   1010      }
   1011      default: {
   1012        this.console?.debug(
   1013          `Disabling text-area resizer due to unexpected panel alignment position: "${
   1014            this.#alignmentPosition
   1015          }"`
   1016        );
   1017        return;
   1018      }
   1019    }
   1020 
   1021    const { panel, textArea } = this.elements;
   1022 
   1023    if (textArea.style.maxHeight) {
   1024      this.console?.debug(
   1025        "The text-area resizer has already been enabled at the current panel location."
   1026      );
   1027      return;
   1028    }
   1029 
   1030    // The visible height of the text area on the screen.
   1031    const textAreaClientHeight = textArea.clientHeight;
   1032 
   1033    // The height of the text in the text area, including text that has overflowed beyond the client height.
   1034    const textAreaScrollHeight = textArea.scrollHeight;
   1035 
   1036    if (textAreaScrollHeight <= textAreaClientHeight) {
   1037      this.console?.debug(
   1038        "Disabling text-area resizer because the text content fits within the text area."
   1039      );
   1040      return;
   1041    }
   1042 
   1043    // Wayland has no concept of "screen coordinates" which causes getOuterScreenRect to always
   1044    // return { x: 0, y: 0 } for the location. As such, we cannot tell on Wayland where the panel
   1045    // is positioned relative to the screen, so we must restrict the panel's resizing limits to be
   1046    // within the Firefox window itself.
   1047    let isWayland = false;
   1048    try {
   1049      isWayland = GfxInfo.windowProtocol === "wayland";
   1050    } catch (error) {
   1051      if (AppConstants.platform === "linux") {
   1052        this.console?.warn(error);
   1053        this.console?.debug(
   1054          "Disabling text-area resizer because we were unable to retrieve the window protocol on Linux."
   1055        );
   1056        return;
   1057      }
   1058      // Since we're not on Linux, we can safely continue with isWayland = false.
   1059    }
   1060 
   1061    const {
   1062      top: panelTop,
   1063      left: panelLeft,
   1064      bottom: panelBottom,
   1065      right: panelRight,
   1066    } = isWayland
   1067      ? // The panel's location relative to the Firefox window.
   1068        panel.getBoundingClientRect()
   1069      : // The panel's location relative to the screen.
   1070        panel.getOuterScreenRect();
   1071 
   1072    const window =
   1073      gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
   1074 
   1075    if (isWayland) {
   1076      if (panelTop < 0) {
   1077        this.console?.debug(
   1078          "Disabling text-area resizer because the panel outside the top edge of the window on Wayland."
   1079        );
   1080        return;
   1081      }
   1082      if (panelBottom > window.innerHeight) {
   1083        this.console?.debug(
   1084          "Disabling text-area resizer because the panel is outside the bottom edge of the window on Wayland."
   1085        );
   1086        return;
   1087      }
   1088      if (panelLeft < 0) {
   1089        this.console?.debug(
   1090          "Disabling text-area resizer because the panel outside the left edge of the window on Wayland."
   1091        );
   1092        return;
   1093      }
   1094      if (panelRight > window.innerWidth) {
   1095        this.console?.debug(
   1096          "Disabling text-area resizer because the panel is outside the right edge of the window on Wayland."
   1097        );
   1098        return;
   1099      }
   1100    } else if (!panelBottom) {
   1101      // The location of the panel was unable to be retrieved by getOuterScreenRect() so we should not enable
   1102      // resizing the text area because we cannot accurately guard against the user resizing the panel off of
   1103      // the bottom edge of the screen. The worst case for the user here is that they have to utilize the scroll
   1104      // bar instead of resizing. This happens intermittently, but infrequently.
   1105      this.console?.debug(
   1106        "Disabling text-area resizer because the location of the bottom edge of the panel was unavailable."
   1107      );
   1108      return;
   1109    }
   1110 
   1111    const availableHeight = isWayland
   1112      ? // The available height of the Firefox window.
   1113        window.innerHeight
   1114      : // The available height of the screen.
   1115        screen.availHeight;
   1116 
   1117    // The distance in pixels between the bottom edge of the panel to the bottom
   1118    // edge of our available height, which will either be the bottom of the Firefox
   1119    // window on Wayland, otherwise the bottom of the available screen space.
   1120    const panelBottomToBottomEdge = availableHeight - panelBottom;
   1121 
   1122    // We want to maintain some buffer of pixels between the panel's bottom edge
   1123    // and the bottom edge of our available space, because if they touch, it can
   1124    // cause visual glitching to occur.
   1125    const BOTTOM_EDGE_PIXEL_BUFFER = Math.abs(panelBottom - panelTop) / 5;
   1126 
   1127    if (panelBottomToBottomEdge < BOTTOM_EDGE_PIXEL_BUFFER) {
   1128      this.console?.debug(
   1129        "Disabling text-area resizer because the bottom of the panel is already close to the bottom edge."
   1130      );
   1131      return;
   1132    }
   1133 
   1134    // The height that the textarea could grow to before hitting the threshold of the buffer that we
   1135    // intend to keep between the bottom edge of the panel and the bottom edge of available space.
   1136    const textAreaHeightLimitForEdge =
   1137      textAreaClientHeight + panelBottomToBottomEdge - BOTTOM_EDGE_PIXEL_BUFFER;
   1138 
   1139    // This is an arbitrary ratio, but allowing the panel's text area to span 1/2 of the available
   1140    // vertical real estate, even if it could expand farther, seems like a reasonable constraint.
   1141    const textAreaHeightLimitUpperBound = Math.trunc(availableHeight / 2);
   1142 
   1143    // The final maximum height that the text area will be allowed to resize to at its current location.
   1144    const textAreaMaxHeight = Math.min(
   1145      textAreaScrollHeight,
   1146      textAreaHeightLimitForEdge,
   1147      textAreaHeightLimitUpperBound
   1148    );
   1149 
   1150    textArea.style.resize = "vertical";
   1151    textArea.style.maxHeight = `${textAreaMaxHeight}px`;
   1152    this.console?.debug(
   1153      `Enabling text-area resizer with a maximum height of ${textAreaMaxHeight} pixels`
   1154    );
   1155  }
   1156 
   1157  /**
   1158   * Handles events when a popup is shown within the panel, including showing
   1159   * the panel itself.
   1160   *
   1161   * @param {Element} target - The event target
   1162   */
   1163  #handlePopupShownEvent(target) {
   1164    const { panel } = this.elements;
   1165    switch (target.id) {
   1166      case panel.id: {
   1167        this.#updatePanelUIFromState();
   1168        break;
   1169      }
   1170    }
   1171  }
   1172 
   1173  /**
   1174   * Handles events when a popup is closed within the panel, including closing
   1175   * the panel itself.
   1176   *
   1177   * @param {Element} target - The event target
   1178   */
   1179  #handlePopupHiddenEvent(target) {
   1180    const { panel } = this.elements;
   1181    switch (target.id) {
   1182      case panel.id: {
   1183        TranslationsParent.telemetry().selectTranslationsPanel().onClose();
   1184        this.#changeStateToClosed();
   1185        this.#removeActiveTranslationListeners();
   1186        break;
   1187      }
   1188    }
   1189  }
   1190 
   1191  /**
   1192   * Handles events in the SelectTranslationsPanel.
   1193   *
   1194   * @param {Event} event - The event to handle.
   1195   */
   1196  handleEvent(event) {
   1197    let target = event.target;
   1198 
   1199    // If a menuitem within a menulist is the target, those don't have ids,
   1200    // so we want to traverse until we get to a parent element with an id.
   1201    while (!target.id && target.parentElement) {
   1202      target = target.parentElement;
   1203    }
   1204 
   1205    switch (event.type) {
   1206      case "command": {
   1207        this.#handleCommandEvent(target);
   1208        break;
   1209      }
   1210      case "keypress": {
   1211        if (event.key === "Enter") {
   1212          this.#handleEnterKeyPressed(target);
   1213        }
   1214        break;
   1215      }
   1216      case "popupshown": {
   1217        this.#handlePopupShownEvent(target);
   1218        break;
   1219      }
   1220      case "popuphidden": {
   1221        this.#handlePopupHiddenEvent(target);
   1222        break;
   1223      }
   1224    }
   1225  }
   1226 
   1227  /**
   1228   * Handles events when the panels select from-language is changed.
   1229   */
   1230  onChangeFromLanguage() {
   1231    this.#sourceTextWordCount = undefined;
   1232    this.#updateConditionalUIEnabledState();
   1233  }
   1234 
   1235  /**
   1236   * Handles events when the panels select to-language is changed.
   1237   */
   1238  onChangeToLanguage() {
   1239    this.#updateConditionalUIEnabledState();
   1240  }
   1241 
   1242  /**
   1243   * Handles events when the panel's try-another-source language is changed.
   1244   */
   1245  onChangeTryAnotherSourceLanguage() {
   1246    const { tryAnotherSourceMenuList, translateButton } = this.elements;
   1247    if (tryAnotherSourceMenuList.value) {
   1248      translateButton.disabled = false;
   1249    }
   1250  }
   1251 
   1252  /**
   1253   * Handles events when the panel's cancel button is clicked.
   1254   */
   1255  onClickCancelButton() {
   1256    TranslationsParent.telemetry().selectTranslationsPanel().onCancelButton();
   1257    this.close();
   1258  }
   1259 
   1260  /**
   1261   * Handles events when the panel's copy button is clicked.
   1262   */
   1263  onClickCopyButton() {
   1264    TranslationsParent.telemetry().selectTranslationsPanel().onCopyButton();
   1265 
   1266    try {
   1267      ClipboardHelper.copyString(this.getTranslatedText());
   1268    } catch (error) {
   1269      this.console?.error(error);
   1270      return;
   1271    }
   1272 
   1273    this.#checkCopyButton();
   1274  }
   1275 
   1276  /**
   1277   * Handles events when the panel's done button is clicked.
   1278   */
   1279  onClickDoneButton() {
   1280    TranslationsParent.telemetry().selectTranslationsPanel().onDoneButton();
   1281    this.close();
   1282  }
   1283 
   1284  /**
   1285   * Handles events when the panel's translate button is clicked.
   1286   */
   1287  onClickTranslateButton() {
   1288    const { fromMenuList, tryAnotherSourceMenuList } = this.elements;
   1289    const { detectedLanguage, targetLanguage } = this.#translationState;
   1290 
   1291    fromMenuList.value = tryAnotherSourceMenuList.value;
   1292 
   1293    TranslationsParent.telemetry().selectTranslationsPanel().onTranslateButton({
   1294      detectedLanguage,
   1295      sourceLanguage: fromMenuList.value,
   1296      targetLanguage,
   1297    });
   1298 
   1299    this.#maybeRequestTranslation();
   1300  }
   1301 
   1302  /**
   1303   * Handles events when the panel's translate-full-page button is clicked.
   1304   */
   1305  onClickTranslateFullPageButton() {
   1306    TranslationsParent.telemetry()
   1307      .selectTranslationsPanel()
   1308      .onTranslateFullPageButton();
   1309 
   1310    const { panel } = this.elements;
   1311    const languagePair = this.#getSelectedLanguagePair();
   1312 
   1313    try {
   1314      const actor = TranslationsParent.getTranslationsActor(
   1315        gBrowser.selectedBrowser
   1316      );
   1317      panel.addEventListener(
   1318        "popuphidden",
   1319        () =>
   1320          actor.translate(
   1321            languagePair,
   1322            false // reportAsAutoTranslate
   1323          ),
   1324        { once: true }
   1325      );
   1326    } catch (error) {
   1327      // This situation would only occur if the translate-full-page button as invoked
   1328      // while Translations actor is not available. the logic within this class explicitly
   1329      // hides the button in this case, and this should not be possible under normal conditions,
   1330      // but if this button were to somehow still be invoked, the best thing we can do here is log
   1331      // an error to the console because the FullPageTranslationsPanel assumes that the actor is available.
   1332      this.console?.error(error);
   1333    }
   1334 
   1335    this.close();
   1336  }
   1337 
   1338  /**
   1339   * Handles events when the panel's try-again button is clicked.
   1340   */
   1341  onClickTryAgainButton() {
   1342    TranslationsParent.telemetry().selectTranslationsPanel().onTryAgainButton();
   1343 
   1344    switch (this.phase()) {
   1345      case "translation-failure": {
   1346        // If the translation failed, we just need to try translating again.
   1347        this.#maybeRequestTranslation();
   1348        break;
   1349      }
   1350      case "init-failure": {
   1351        // If the initialization failed, we need to close the panel and try reopening it
   1352        // which will attempt to initialize everything again after failure.
   1353        const { panel } = this.elements;
   1354        const {
   1355          event,
   1356          screenX,
   1357          screenY,
   1358          sourceText,
   1359          isTextSelected,
   1360          langPairPromise,
   1361        } = this.#translationState;
   1362 
   1363        panel.addEventListener(
   1364          "popuphidden",
   1365          () =>
   1366            this.open(
   1367              event,
   1368              screenX,
   1369              screenY,
   1370              sourceText,
   1371              isTextSelected,
   1372              langPairPromise,
   1373              /* maintainFlow */ true
   1374            ),
   1375          { once: true }
   1376        );
   1377 
   1378        this.close();
   1379        break;
   1380      }
   1381      default: {
   1382        this.console?.error(
   1383          `Unexpected state "${this.phase()}" on try-again button click.`
   1384        );
   1385      }
   1386    }
   1387  }
   1388 
   1389  /**
   1390   * Changes the copy button's visual icon to checked, and its localized text to "Copied".
   1391   */
   1392  #checkCopyButton() {
   1393    const { copyButton } = this.elements;
   1394    copyButton.classList.add("copied");
   1395    document.l10n.setAttributes(
   1396      copyButton,
   1397      "select-translations-panel-copy-button-copied"
   1398    );
   1399  }
   1400 
   1401  /**
   1402   * Changes the copy button's visual icon to unchecked, and its localized text to "Copy".
   1403   */
   1404  #uncheckCopyButton() {
   1405    const { copyButton } = this.elements;
   1406    copyButton.classList.remove("copied");
   1407    document.l10n.setAttributes(
   1408      copyButton,
   1409      "select-translations-panel-copy-button"
   1410    );
   1411  }
   1412 
   1413  /**
   1414   * Clears the selected language and ensures that the menu list displays
   1415   * the proper placeholder text.
   1416   *
   1417   * @param {Element} menuList - The target menu list element to update.
   1418   */
   1419  async #deselectLanguage(menuList) {
   1420    menuList.value = "";
   1421    document.l10n.setAttributes(menuList, "translations-panel-choose-language");
   1422    await document.l10n.translateElements([menuList]);
   1423  }
   1424 
   1425  /**
   1426   * Focuses on the given menu list if provided and empty, or defaults to focusing one
   1427   * of the from-menu or to-menu lists if either is empty.
   1428   *
   1429   * @param {Element} [menuList] - The menu list to focus if specified.
   1430   */
   1431  #maybeFocusMenuList(menuList) {
   1432    if (menuList && !menuList.value) {
   1433      menuList.focus({ focusVisible: false });
   1434      return;
   1435    }
   1436 
   1437    const { fromMenuList, toMenuList } = this.elements;
   1438    if (!fromMenuList.value) {
   1439      fromMenuList.focus({ focusVisible: false });
   1440    } else if (!toMenuList.value) {
   1441      toMenuList.focus({ focusVisible: false });
   1442    }
   1443  }
   1444 
   1445  /**
   1446   * Focuses the translated-text area and sets its overflow to auto post-animation.
   1447   */
   1448  #indicateTranslatedTextArea({ overflow }) {
   1449    const { textArea } = this.elements;
   1450    textArea.focus({ focusVisible: true });
   1451    requestAnimationFrame(() => {
   1452      // We want to set overflow to auto as the final animation, because if it is
   1453      // set before the translated text is displayed, then the scrollTop will
   1454      // move to the bottom as the text is populated.
   1455      //
   1456      // Setting scrollTop = 0 on its own works, but it sometimes causes an animation
   1457      // of the text jumping from the bottom to the top. It looks a lot cleaner to
   1458      // disable overflow before rendering the text, then re-enable it after it renders.
   1459      requestAnimationFrame(() => {
   1460        textArea.style.overflow = overflow;
   1461        textArea.setSelectionRange(0, 0);
   1462        textArea.scrollTop = 0;
   1463      });
   1464    });
   1465  }
   1466 
   1467  /**
   1468   * Checks if the given language pair matches the panel's currently selected language pair.
   1469   *
   1470   * @param {string} sourceLanguage - The from-language to compare.
   1471   * @param {string} targetLanguage - The to-language to compare.
   1472   *
   1473   * @returns {boolean} - True if the given language pair matches the selected languages in the panel UI, otherwise false.
   1474   */
   1475  #isSelectedLangPair(sourceLanguage, targetLanguage) {
   1476    const selected = this.#getSelectedLanguagePair();
   1477    return (
   1478      TranslationsUtils.langTagsMatch(
   1479        sourceLanguage,
   1480        selected.sourceLanguage
   1481      ) &&
   1482      TranslationsUtils.langTagsMatch(targetLanguage, selected.targetLanguage)
   1483    );
   1484  }
   1485 
   1486  /**
   1487   * Retrieves the currently selected language pair from the menu lists.
   1488   *
   1489   * @returns {LanguagePair}
   1490   */
   1491  #getSelectedLanguagePair() {
   1492    const { fromMenuList, toMenuList } = this.elements;
   1493    const [sourceLanguage, sourceVariant] = fromMenuList.value.split(",");
   1494    const [targetLanguage, targetVariant] = toMenuList.value.split(",");
   1495    return {
   1496      sourceLanguage,
   1497      targetLanguage,
   1498      sourceVariant,
   1499      targetVariant,
   1500    };
   1501  }
   1502 
   1503  /**
   1504   * Retrieves the source text from the translation state.
   1505   * This value is not available when the panel is closed.
   1506   *
   1507   * @returns {string | undefined} The source text.
   1508   */
   1509  getSourceText() {
   1510    return this.#translationState?.sourceText;
   1511  }
   1512 
   1513  /**
   1514   * Retrieves the source text from the translation state.
   1515   * This value is only available in the translated phase.
   1516   *
   1517   * @returns {string | undefined} The translated text.
   1518   */
   1519  getTranslatedText() {
   1520    return this.#translationState?.translatedText;
   1521  }
   1522 
   1523  /**
   1524   * Retrieves the current phase of the translation state.
   1525   *
   1526   * @returns {string}
   1527   */
   1528  phase() {
   1529    return this.#translationState.phase;
   1530  }
   1531 
   1532  /**
   1533   * @returns {boolean} True if the panel is open, otherwise false.
   1534   */
   1535  #isOpen() {
   1536    return this.phase() !== "closed";
   1537  }
   1538 
   1539  /**
   1540   * @returns {boolean} True if the panel is closed, otherwise false.
   1541   */
   1542  #isClosed() {
   1543    return this.phase() === "closed";
   1544  }
   1545 
   1546  /**
   1547   * Changes the translation state to a new phase with options to retain or overwrite existing entries.
   1548   *
   1549   * @param {SelectTranslationsPanelState} phase - The new phase to transition to.
   1550   * @param {boolean} [retainEntries] - Whether to retain existing state entries that are not overwritten.
   1551   * @param {object | null} [data=null] - Additional data to merge into the state.
   1552   * @throws {Error} If an invalid phase is specified.
   1553   */
   1554  #changeStateTo(phase, retainEntries, data = null) {
   1555    switch (phase) {
   1556      case "closed":
   1557      case "idle":
   1558      case "init-failure":
   1559      case "translation-failure":
   1560      case "translatable":
   1561      case "translating":
   1562      case "translated":
   1563      case "unsupported": {
   1564        // Phase is valid, continue on.
   1565        break;
   1566      }
   1567      default: {
   1568        throw new Error(`Invalid state change to '${phase}'`);
   1569      }
   1570    }
   1571 
   1572    const previousPhase = this.phase();
   1573    if (data && retainEntries) {
   1574      // Change the phase and apply new entries from data, but retain non-overwritten entries from previous state.
   1575      this.#translationState = { ...this.#translationState, phase, ...data };
   1576    } else if (data) {
   1577      // Change the phase and apply new entries from data, but drop any entries that are not overwritten by data.
   1578      this.#translationState = { phase, ...data };
   1579    } else if (retainEntries) {
   1580      // Change only the phase and retain all entries from previous data.
   1581      this.#translationState.phase = phase;
   1582    } else {
   1583      // Change the phase and delete all entries from previous data.
   1584      this.#translationState = { phase };
   1585    }
   1586 
   1587    if (previousPhase === this.phase()) {
   1588      // Do not continue on to update the UI because the phase didn't change.
   1589      return;
   1590    }
   1591 
   1592    const { sourceLanguage, targetLanguage, detectedLanguage } =
   1593      this.#translationState;
   1594    this.console?.debug(
   1595      `SelectTranslationsPanel (${sourceLanguage ?? detectedLanguage ?? "??"}-${
   1596        targetLanguage ? targetLanguage : "??"
   1597      }) state change (${previousPhase} => ${phase})`
   1598    );
   1599 
   1600    this.#updatePanelUIFromState();
   1601    document.dispatchEvent(
   1602      new CustomEvent("SelectTranslationsPanelStateChanged", {
   1603        detail: { phase },
   1604      })
   1605    );
   1606  }
   1607 
   1608  /**
   1609   * Changes the phase to closed, discarding any entries in the translation state.
   1610   */
   1611  #changeStateToClosed() {
   1612    this.#changeStateTo("closed", /* retainEntries */ false);
   1613  }
   1614 
   1615  /**
   1616   * Changes the phase from "translatable" to "translating".
   1617   *
   1618   * @throws {Error} If the current state is not "translatable".
   1619   */
   1620  #changeStateToTranslating() {
   1621    const phase = this.phase();
   1622    if (phase !== "translatable") {
   1623      throw new Error(`Invalid state change (${phase} => translating)`);
   1624    }
   1625    this.#changeStateTo("translating", /* retainEntries */ true);
   1626  }
   1627 
   1628  /**
   1629   * Changes the phase from "translating" to "translated".
   1630   *
   1631   * @throws {Error} If the current state is not "translating".
   1632   */
   1633  #changeStateToTranslated(translatedText) {
   1634    const phase = this.phase();
   1635    if (phase !== "translating") {
   1636      throw new Error(`Invalid state change (${phase} => translated)`);
   1637    }
   1638    this.#changeStateTo("translated", /* retainEntries */ true, {
   1639      translatedText,
   1640    });
   1641  }
   1642 
   1643  /**
   1644   * Changes the phase to "init-failure".
   1645   *
   1646   * @param {Event} event - The triggering event for opening the panel.
   1647   * @param {number} screenX - The x-axis location of the screen at which to open the popup.
   1648   * @param {number} screenY - The y-axis location of the screen at which to open the popup.
   1649   * @param {string} sourceText - The text to translate.
   1650   * @param {boolean} isTextSelected - True if the text comes from a hyperlink, false if it is from a selection.
   1651   * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns.
   1652   */
   1653  #changeStateToInitFailure(
   1654    event,
   1655    screenX,
   1656    screenY,
   1657    sourceText,
   1658    isTextSelected,
   1659    langPairPromise
   1660  ) {
   1661    this.#changeStateTo("init-failure", /* retainEntries */ true, {
   1662      event,
   1663      screenX,
   1664      screenY,
   1665      sourceText,
   1666      isTextSelected,
   1667      langPairPromise,
   1668    });
   1669  }
   1670 
   1671  /**
   1672   * Changes the phase from "translating" to "translation-failure".
   1673   */
   1674  #changeStateToTranslationFailure() {
   1675    const phase = this.phase();
   1676    if (phase !== "translating") {
   1677      this.console?.error(
   1678        `Invalid state change (${phase} => translation-failure)`
   1679      );
   1680    }
   1681    this.#changeStateTo("translation-failure", /* retainEntries */ true);
   1682  }
   1683 
   1684  /**
   1685   * Transitions the phase to "translatable" if the proper conditions are met,
   1686   * otherwise retains the same phase as before.
   1687   *
   1688   * @param {string} sourceLanguage - The BCP-47 from-language tag.
   1689   * @param {string} targetLanguage - The BCP-47 to-language tag.
   1690   */
   1691  #maybeChangeStateToTranslatable(sourceLanguage, targetLanguage) {
   1692    const previous = this.#translationState;
   1693 
   1694    const langSelectionChanged = () =>
   1695      !TranslationsUtils.langTagsMatch(
   1696        previous.sourceLanguage,
   1697        sourceLanguage
   1698      ) ||
   1699      !TranslationsUtils.langTagsMatch(previous.targetLanguage, targetLanguage);
   1700 
   1701    const shouldTranslateEvenIfLangSelectionHasNotChanged = () => {
   1702      const phase = this.phase();
   1703      return (
   1704        // The panel has just opened, and this is the initial translation.
   1705        phase === "idle" ||
   1706        // The previous translation failed and we are about to try again.
   1707        phase === "translation-failure"
   1708      );
   1709    };
   1710 
   1711    if (
   1712      // A valid source language is actively selected.
   1713      sourceLanguage &&
   1714      // A valid target language is actively selected.
   1715      targetLanguage &&
   1716      // The language selection has changed, requiring a new translation.
   1717      (langSelectionChanged() ||
   1718        // We should try to translate even if the language selection has not changed.
   1719        shouldTranslateEvenIfLangSelectionHasNotChanged())
   1720    ) {
   1721      this.#changeStateTo("translatable", /* retainEntries */ true, {
   1722        sourceLanguage,
   1723        targetLanguage,
   1724      });
   1725    }
   1726  }
   1727 
   1728  /**
   1729   * Handles changes to the copy button based on the current translation state.
   1730   *
   1731   * @param {string} phase - The current phase of the translation state.
   1732   */
   1733  #handleCopyButtonChanges(phase) {
   1734    switch (phase) {
   1735      case "closed":
   1736      case "translation-failure":
   1737      case "translated": {
   1738        this.#uncheckCopyButton();
   1739        break;
   1740      }
   1741      case "idle":
   1742      case "init-failure":
   1743      case "translatable":
   1744      case "translating":
   1745      case "unsupported": {
   1746        // Do nothing.
   1747        break;
   1748      }
   1749      default: {
   1750        throw new Error(`Invalid state change to '${phase}'`);
   1751      }
   1752    }
   1753  }
   1754 
   1755  /**
   1756   * Handles changes to the text area's background image based on the current translation state.
   1757   *
   1758   * @param {string} phase - The current phase of the translation state.
   1759   */
   1760  #handleTextAreaBackgroundChanges(phase) {
   1761    const { textArea } = this.elements;
   1762    switch (phase) {
   1763      case "translating": {
   1764        textArea.classList.add("translating");
   1765        break;
   1766      }
   1767      case "closed":
   1768      case "idle":
   1769      case "init-failure":
   1770      case "translation-failure":
   1771      case "translatable":
   1772      case "translated":
   1773      case "unsupported": {
   1774        textArea.classList.remove("translating");
   1775        break;
   1776      }
   1777      default: {
   1778        throw new Error(`Invalid state change to '${phase}'`);
   1779      }
   1780    }
   1781  }
   1782 
   1783  /**
   1784   * Handles changes to the primary UI components based on the current translation state.
   1785   *
   1786   * @param {string} phase  - The current phase of the translation state.
   1787   */
   1788  #handlePrimaryUIChanges(phase) {
   1789    switch (phase) {
   1790      case "closed":
   1791      case "idle": {
   1792        this.#displayIdlePlaceholder();
   1793        break;
   1794      }
   1795      case "init-failure": {
   1796        this.#displayInitFailureMessage();
   1797        break;
   1798      }
   1799      case "translation-failure": {
   1800        this.#displayTranslationFailureMessage();
   1801        break;
   1802      }
   1803      case "translatable": {
   1804        // Do nothing.
   1805        break;
   1806      }
   1807      case "translating": {
   1808        this.#displayTranslatingPlaceholder();
   1809        break;
   1810      }
   1811      case "translated": {
   1812        this.#displayTranslatedText();
   1813        break;
   1814      }
   1815      case "unsupported": {
   1816        this.#displayUnsupportedLanguageMessage();
   1817        break;
   1818      }
   1819      default: {
   1820        throw new Error(`Invalid state change to '${phase}'`);
   1821      }
   1822    }
   1823  }
   1824 
   1825  /**
   1826   * Returns true if the translate-full-page button should be hidden in the current panel view.
   1827   *
   1828   * @returns {boolean}
   1829   */
   1830  #shouldHideTranslateFullPageButton() {
   1831    return (
   1832      // Do not offer to translate the full page if it is restricted on this page.
   1833      this.#isFullPageTranslationsRestrictedForPage ||
   1834      // Do not offer to translate the full page if Full-Page Translations is already active.
   1835      this.#activeFullPageTranslationsTargetLanguage
   1836    );
   1837  }
   1838 
   1839  /**
   1840   * Determines whether translation should continue based on panel state and language pair.
   1841   *
   1842   * @param {number} translationId - The id of the translation request to match.
   1843   * @param {string} sourceLanguage - The source language to analyze.
   1844   * @param {string} targetLanguage - The target language to analyze.
   1845   *
   1846   * @returns {boolean} True if translation should continue with the given pair, otherwise false.
   1847   */
   1848  #shouldContinueTranslation(translationId, sourceLanguage, targetLanguage) {
   1849    return (
   1850      // Continue only if the panel is still open.
   1851      this.#isOpen() &&
   1852      // Continue only if the current translationId matches.
   1853      translationId === this.#translationId &&
   1854      // Continue only if the given language pair is still the actively selected pair.
   1855      this.#isSelectedLangPair(sourceLanguage, targetLanguage)
   1856    );
   1857  }
   1858 
   1859  /**
   1860   * Displays the placeholder text for the translation state's "idle" phase.
   1861   */
   1862  #displayIdlePlaceholder() {
   1863    this.#showMainContent();
   1864 
   1865    const { textArea } = SelectTranslationsPanel.elements;
   1866    textArea.value = this.#idlePlaceholderText;
   1867    this.#updateTextDirection();
   1868    this.#updateConditionalUIEnabledState();
   1869    this.#maybeFocusMenuList();
   1870  }
   1871 
   1872  /**
   1873   * Displays the placeholder text for the translation state's "translating" phase.
   1874   */
   1875  #displayTranslatingPlaceholder() {
   1876    this.#showMainContent();
   1877 
   1878    const { textArea } = SelectTranslationsPanel.elements;
   1879    textArea.value = this.#translatingPlaceholderText;
   1880    this.#updateTextDirection();
   1881    this.#updateConditionalUIEnabledState();
   1882    this.#indicateTranslatedTextArea({ overflow: "hidden" });
   1883  }
   1884 
   1885  /**
   1886   * Displays the translated text for the translation state's "translated" phase.
   1887   */
   1888  #displayTranslatedText() {
   1889    this.#showMainContent();
   1890 
   1891    const { targetLanguage } = this.#getSelectedLanguagePair();
   1892    const { textArea } = SelectTranslationsPanel.elements;
   1893    textArea.value = this.getTranslatedText();
   1894    this.#updateTextDirection(targetLanguage);
   1895    this.#updateConditionalUIEnabledState();
   1896    this.#indicateTranslatedTextArea({ overflow: "auto" });
   1897    this.#maybeEnableTextAreaResizer();
   1898 
   1899    const window =
   1900      gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
   1901    window.A11yUtils.announce({
   1902      id: "select-translations-panel-translation-complete-announcement",
   1903    });
   1904  }
   1905 
   1906  /**
   1907   * Sets attributes on panel elements that are specifically relevant
   1908   * to the SelectTranslationsPanel's state.
   1909   *
   1910   * @param {object} options - Options of which attributes to set.
   1911   * @param {Record<string, Element[]>} options.makeHidden - Make these elements hidden.
   1912   * @param {Record<string, Element[]>} options.makeVisible - Make these elements visible.
   1913   */
   1914  #setPanelElementAttributes({ makeHidden = [], makeVisible = [] }) {
   1915    for (const element of makeHidden) {
   1916      element.hidden = true;
   1917    }
   1918    for (const element of makeVisible) {
   1919      element.hidden = false;
   1920    }
   1921  }
   1922 
   1923  /**
   1924   * Enables or disables UI components that are conditional on a valid language pair being selected.
   1925   */
   1926  #updateConditionalUIEnabledState() {
   1927    const { sourceLanguage, targetLanguage } = this.#getSelectedLanguagePair();
   1928    const {
   1929      copyButton,
   1930      textArea,
   1931      translateButton,
   1932      translateFullPageButton,
   1933      tryAnotherSourceMenuList,
   1934    } = this.elements;
   1935 
   1936    const invalidLangPairSelected = !sourceLanguage || !targetLanguage;
   1937    const isTranslating = this.phase() === "translating";
   1938 
   1939    textArea.disabled = invalidLangPairSelected;
   1940    copyButton.disabled = invalidLangPairSelected || isTranslating;
   1941    translateButton.disabled = !tryAnotherSourceMenuList.value;
   1942    translateFullPageButton.disabled =
   1943      invalidLangPairSelected ||
   1944      TranslationsUtils.langTagsMatch(sourceLanguage, targetLanguage) ||
   1945      this.#shouldHideTranslateFullPageButton();
   1946  }
   1947 
   1948  /**
   1949   * Updates the panel UI based on the current phase of the translation state.
   1950   */
   1951  #updatePanelUIFromState() {
   1952    const phase = this.phase();
   1953 
   1954    this.#handlePrimaryUIChanges(phase);
   1955    this.#handleCopyButtonChanges(phase);
   1956    this.#handleTextAreaBackgroundChanges(phase);
   1957 
   1958    this.#mostRecentUIPhase = phase;
   1959  }
   1960 
   1961  /**
   1962   * Shows the panel's main-content group of elements.
   1963   */
   1964  #showMainContent() {
   1965    const {
   1966      cancelButton,
   1967      copyButton,
   1968      doneButtonPrimary,
   1969      doneButtonSecondary,
   1970      initFailureContent,
   1971      mainContent,
   1972      unsupportedLanguageContent,
   1973      textArea,
   1974      translateButton,
   1975      translateFullPageButton,
   1976      translationFailureMessageBar,
   1977      tryAgainButton,
   1978    } = this.elements;
   1979    this.#setPanelElementAttributes({
   1980      makeHidden: [
   1981        cancelButton,
   1982        doneButtonSecondary,
   1983        initFailureContent,
   1984        translateButton,
   1985        translationFailureMessageBar,
   1986        tryAgainButton,
   1987        unsupportedLanguageContent,
   1988        ...(this.#shouldHideTranslateFullPageButton()
   1989          ? [translateFullPageButton]
   1990          : []),
   1991      ],
   1992      makeVisible: [
   1993        mainContent,
   1994        copyButton,
   1995        doneButtonPrimary,
   1996        textArea,
   1997        ...(this.#shouldHideTranslateFullPageButton()
   1998          ? []
   1999          : [translateFullPageButton]),
   2000      ],
   2001    });
   2002  }
   2003 
   2004  /**
   2005   * Shows the panel's unsupported-language group of elements.
   2006   */
   2007  #showUnsupportedLanguageContent() {
   2008    const {
   2009      cancelButton,
   2010      copyButton,
   2011      doneButtonPrimary,
   2012      doneButtonSecondary,
   2013      initFailureContent,
   2014      mainContent,
   2015      unsupportedLanguageContent,
   2016      translateButton,
   2017      translateFullPageButton,
   2018      tryAgainButton,
   2019    } = this.elements;
   2020    this.#setPanelElementAttributes({
   2021      makeHidden: [
   2022        cancelButton,
   2023        doneButtonPrimary,
   2024        copyButton,
   2025        initFailureContent,
   2026        mainContent,
   2027        translateFullPageButton,
   2028        tryAgainButton,
   2029      ],
   2030      makeVisible: [
   2031        doneButtonSecondary,
   2032        translateButton,
   2033        unsupportedLanguageContent,
   2034      ],
   2035    });
   2036  }
   2037 
   2038  /**
   2039   * Displays the panel content for when the language dropdowns fail to populate.
   2040   */
   2041  #displayInitFailureMessage() {
   2042    if (this.#mostRecentUIPhase !== "init-failure") {
   2043      TranslationsParent.telemetry()
   2044        .selectTranslationsPanel()
   2045        .onInitializationFailureMessage();
   2046    }
   2047 
   2048    const {
   2049      cancelButton,
   2050      copyButton,
   2051      doneButtonPrimary,
   2052      doneButtonSecondary,
   2053      initFailureContent,
   2054      mainContent,
   2055      unsupportedLanguageContent,
   2056      translateButton,
   2057      translateFullPageButton,
   2058      tryAgainButton,
   2059    } = this.elements;
   2060    this.#setPanelElementAttributes({
   2061      makeHidden: [
   2062        doneButtonPrimary,
   2063        doneButtonSecondary,
   2064        copyButton,
   2065        mainContent,
   2066        translateButton,
   2067        translateFullPageButton,
   2068        unsupportedLanguageContent,
   2069      ],
   2070      makeVisible: [initFailureContent, cancelButton, tryAgainButton],
   2071    });
   2072    tryAgainButton.setAttribute(
   2073      "aria-describedby",
   2074      "select-translations-panel-init-failure-message-bar"
   2075    );
   2076    tryAgainButton.focus({ focusVisible: false });
   2077  }
   2078 
   2079  /**
   2080   * Displays the panel content for when a translation fails to complete.
   2081   */
   2082  #displayTranslationFailureMessage() {
   2083    if (this.#mostRecentUIPhase !== "translation-failure") {
   2084      const { sourceLanguage, targetLanguage } =
   2085        this.#getSelectedLanguagePair();
   2086      TranslationsParent.telemetry()
   2087        .selectTranslationsPanel()
   2088        .onTranslationFailureMessage({ sourceLanguage, targetLanguage });
   2089    }
   2090 
   2091    const {
   2092      cancelButton,
   2093      copyButton,
   2094      doneButtonPrimary,
   2095      doneButtonSecondary,
   2096      initFailureContent,
   2097      mainContent,
   2098      textArea,
   2099      translateButton,
   2100      translateFullPageButton,
   2101      translationFailureMessageBar,
   2102      tryAgainButton,
   2103      unsupportedLanguageContent,
   2104    } = this.elements;
   2105    this.#setPanelElementAttributes({
   2106      makeHidden: [
   2107        doneButtonPrimary,
   2108        doneButtonSecondary,
   2109        copyButton,
   2110        initFailureContent,
   2111        translateButton,
   2112        translateFullPageButton,
   2113        textArea,
   2114        unsupportedLanguageContent,
   2115      ],
   2116      makeVisible: [
   2117        cancelButton,
   2118        mainContent,
   2119        translationFailureMessageBar,
   2120        tryAgainButton,
   2121      ],
   2122    });
   2123    tryAgainButton.setAttribute(
   2124      "aria-describedby",
   2125      "select-translations-panel-translation-failure-message-bar"
   2126    );
   2127    tryAgainButton.focus({ focusVisible: false });
   2128  }
   2129 
   2130  /**
   2131   * Displays the panel's unsupported language message bar, showing
   2132   * the panel's unsupported-language elements.
   2133   */
   2134  #displayUnsupportedLanguageMessage() {
   2135    const { detectedLanguage } = this.#translationState;
   2136 
   2137    if (this.#mostRecentUIPhase !== "unsupported") {
   2138      const { docLangTag } = this.#getLanguageInfo();
   2139      TranslationsParent.telemetry()
   2140        .selectTranslationsPanel()
   2141        .onUnsupportedLanguageMessage({ docLangTag, detectedLanguage });
   2142    }
   2143 
   2144    const { unsupportedLanguageMessageBar, tryAnotherSourceMenuList } =
   2145      this.elements;
   2146    const languageDisplayNames =
   2147      TranslationsParent.createLanguageDisplayNames();
   2148    try {
   2149      const language = languageDisplayNames.of(detectedLanguage);
   2150      if (language) {
   2151        document.l10n.setAttributes(
   2152          unsupportedLanguageMessageBar,
   2153          "select-translations-panel-unsupported-language-message-known",
   2154          { language }
   2155        );
   2156      } else {
   2157        // Will be immediately caught.
   2158        throw new Error();
   2159      }
   2160    } catch {
   2161      // Either displayNames.of() threw, or we threw due to no display name found.
   2162      // In either case, localize the message for an unknown language.
   2163      document.l10n.setAttributes(
   2164        unsupportedLanguageMessageBar,
   2165        "select-translations-panel-unsupported-language-message-unknown"
   2166      );
   2167    }
   2168    this.#updateConditionalUIEnabledState();
   2169    this.#showUnsupportedLanguageContent();
   2170    this.#maybeFocusMenuList(tryAnotherSourceMenuList);
   2171  }
   2172 
   2173  /**
   2174   * Sets the text direction attribute in the text areas based on the specified language.
   2175   * Uses the given language tag if provided, otherwise uses the current app locale.
   2176   *
   2177   * @param {string} [langTag] - The language tag to determine text direction.
   2178   */
   2179  #updateTextDirection(langTag) {
   2180    const { textArea } = this.elements;
   2181    if (langTag) {
   2182      const scriptDirection = Services.intl.getScriptDirection(langTag);
   2183      textArea.setAttribute("dir", scriptDirection);
   2184    } else {
   2185      textArea.removeAttribute("dir");
   2186    }
   2187  }
   2188 
   2189  /**
   2190   * Requests a translations port for a given language pair.
   2191   *
   2192   * @param {LanguagePair} languagePair
   2193   * @returns {Promise<MessagePort | undefined>} The message port promise.
   2194   */
   2195  async #requestTranslationsPort(languagePair) {
   2196    return TranslationsParent.requestTranslationsPort(languagePair);
   2197  }
   2198 
   2199  /**
   2200   * Retrieves the existing translator for the specified language pair if it matches,
   2201   * otherwise creates a new translator.
   2202   *
   2203   * @param {LanguagePair} languagePair
   2204   *
   2205   * @returns {Promise<Translator>} A promise that resolves to a `Translator` instance for the given language pair.
   2206   */
   2207  async #createTranslator(languagePair) {
   2208    this.console?.log(
   2209      `Creating new Translator (${TranslationsUtils.serializeLanguagePair(languagePair)})`
   2210    );
   2211 
   2212    const translator = await Translator.create({
   2213      languagePair,
   2214      requestTranslationsPort: this.#requestTranslationsPort,
   2215      allowSameLanguage: true,
   2216      activeRequestCapacity: 1,
   2217    });
   2218 
   2219    return translator;
   2220  }
   2221 
   2222  /**
   2223   * Initiates the translation process if the panel state and selected languages
   2224   * meet the conditions for translation.
   2225   */
   2226  #maybeRequestTranslation() {
   2227    if (this.#isClosed()) {
   2228      return;
   2229    }
   2230 
   2231    const languagePair = this.#getSelectedLanguagePair();
   2232    const { sourceLanguage, targetLanguage } = languagePair;
   2233    this.#maybeChangeStateToTranslatable(sourceLanguage, targetLanguage);
   2234 
   2235    if (this.phase() !== "translatable") {
   2236      return;
   2237    }
   2238 
   2239    const { docLangTag, topPreferredLanguage } = this.#getLanguageInfo();
   2240    const sourceText = this.getSourceText();
   2241    const translationId = ++this.#translationId;
   2242 
   2243    TranslationsParent.storeMostRecentTargetLanguage(targetLanguage);
   2244 
   2245    this.#createTranslator(languagePair)
   2246      .then(translator => {
   2247        if (
   2248          this.#shouldContinueTranslation(
   2249            translationId,
   2250            sourceLanguage,
   2251            targetLanguage
   2252          )
   2253        ) {
   2254          this.#changeStateToTranslating();
   2255          return translator.translate(this.getSourceText());
   2256        }
   2257        return null;
   2258      })
   2259      .then(translatedText => {
   2260        if (
   2261          translatedText &&
   2262          this.#shouldContinueTranslation(
   2263            translationId,
   2264            sourceLanguage,
   2265            targetLanguage
   2266          )
   2267        ) {
   2268          this.#changeStateToTranslated(translatedText);
   2269        }
   2270      })
   2271      .catch(error => {
   2272        this.console?.error(error);
   2273        this.#changeStateToTranslationFailure();
   2274      });
   2275 
   2276    try {
   2277      if (!this.#sourceTextWordCount) {
   2278        this.#sourceTextWordCount = TranslationsParent.countWords(
   2279          sourceLanguage,
   2280          sourceText
   2281        );
   2282      }
   2283    } catch (error) {
   2284      // Failed to create an Intl.Segmenter for the sourceLanguage.
   2285      // Continue on to report undefined to telemetry.
   2286      this.console?.warn(error);
   2287    }
   2288 
   2289    TranslationsParent.telemetry().onTranslate({
   2290      docLangTag,
   2291      sourceLanguage,
   2292      targetLanguage,
   2293      topPreferredLanguage,
   2294      autoTranslate: false,
   2295      requestTarget: "select",
   2296      sourceTextCodeUnits: sourceText.length,
   2297      sourceTextWordCount: this.#sourceTextWordCount,
   2298    });
   2299  }
   2300 
   2301  /**
   2302   * Reports to telemetry whether the source language or the target language has
   2303   * changed based on whether the currently selected language is different
   2304   * than the corresponding language that is stored in the panel's state.
   2305   */
   2306  #maybeReportLanguageChangeToTelemetry() {
   2307    const previous = this.#translationState;
   2308    const selected = this.#getSelectedLanguagePair();
   2309 
   2310    if (
   2311      !TranslationsUtils.langTagsMatch(
   2312        selected.sourceLanguage,
   2313        previous.sourceLanguage
   2314      )
   2315    ) {
   2316      const { docLangTag } = this.#getLanguageInfo();
   2317      TranslationsParent.telemetry()
   2318        .selectTranslationsPanel()
   2319        .onChangeFromLanguage({
   2320          previousLangTag: previous.sourceLanguage,
   2321          currentLangTag: selected.sourceLanguage,
   2322          docLangTag,
   2323        });
   2324    }
   2325    if (
   2326      !TranslationsUtils.langTagsMatch(
   2327        selected.targetLanguage,
   2328        previous.targetLanguage
   2329      )
   2330    ) {
   2331      TranslationsParent.telemetry()
   2332        .selectTranslationsPanel()
   2333        .onChangeToLanguage(selected.targetLanguage);
   2334    }
   2335  }
   2336 
   2337  /**
   2338   * Attaches event listeners to the target element for initiating translation on specified event types.
   2339   *
   2340   * @param {string[]} eventTypes - An array of event types to listen for.
   2341   * @param {object} target - The target element to attach event listeners to.
   2342   * @throws {Error} If an unrecognized event type is provided.
   2343   */
   2344  #maybeTranslateOnEvents(eventTypes, target) {
   2345    if (!target.translationListenerCallbacks) {
   2346      target.translationListenerCallbacks = [];
   2347    }
   2348    if (target.translationListenerCallbacks.length === 0) {
   2349      for (const eventType of eventTypes) {
   2350        let callback;
   2351        switch (eventType) {
   2352          case "focus":
   2353          case "popuphidden": {
   2354            callback = () => {
   2355              this.#maybeReportLanguageChangeToTelemetry();
   2356              this.#maybeRequestTranslation();
   2357            };
   2358            break;
   2359          }
   2360          case "keypress": {
   2361            callback = event => {
   2362              if (event.key === "Enter") {
   2363                this.#maybeReportLanguageChangeToTelemetry();
   2364                this.#maybeRequestTranslation();
   2365              }
   2366            };
   2367            break;
   2368          }
   2369          default: {
   2370            throw new Error(
   2371              `Invalid translation event type given: '${eventType}`
   2372            );
   2373          }
   2374        }
   2375        target.addEventListener(eventType, callback);
   2376        target.translationListenerCallbacks.push({ eventType, callback });
   2377      }
   2378    }
   2379  }
   2380 
   2381  /**
   2382   * Removes all translation event listeners from any panel elements that would have one.
   2383   */
   2384  #removeActiveTranslationListeners() {
   2385    const { fromMenuList, fromMenuPopup, textArea, toMenuList, toMenuPopup } =
   2386      SelectTranslationsPanel.elements;
   2387    this.#removeTranslationListenersFrom(fromMenuList);
   2388    this.#removeTranslationListenersFrom(fromMenuPopup);
   2389    this.#removeTranslationListenersFrom(textArea);
   2390    this.#removeTranslationListenersFrom(toMenuList);
   2391    this.#removeTranslationListenersFrom(toMenuPopup);
   2392  }
   2393 
   2394  /**
   2395   * Removes all translation event listeners from the target element.
   2396   *
   2397   * @param {Element} target - The element from which event listeners are to be removed.
   2398   */
   2399  #removeTranslationListenersFrom(target) {
   2400    if (!target.translationListenerCallbacks) {
   2401      return;
   2402    }
   2403 
   2404    for (const { eventType, callback } of target.translationListenerCallbacks) {
   2405      target.removeEventListener(eventType, callback);
   2406    }
   2407 
   2408    target.translationListenerCallbacks = [];
   2409  }
   2410 })();