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