fune/browser/components/translations/content/selectTranslationsPanel.js
Erik Nordin 98490170e8 Bug 1870314 - Add SelectTranslationsPanel unsupported-language content r=translations-reviewers,fluent-reviewers,gregtatum,bolsson
Adds the elements for showing the unsupported language error
in the SelectTranslationsPanel.

Differential Revision: https://phabricator.services.mozilla.com/D207199
2024-04-15 13:04:39 +00:00

1115 lines
35 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* 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.
*/
var SelectTranslationsPanel = new (class {
/** @type {Console?} */
#console;
/**
* Lazily get a console instance. Note that this script is loaded in very early to
* the browser loading process, and may run before the console is available. In
* this case the console will return as `undefined`.
*
* @returns {Console | void}
*/
get console() {
if (!this.#console) {
try {
this.#console = console.createInstance({
maxLogLevelPref: "browser.translations.logLevel",
prefix: "Translations",
});
} catch {
// The console may not be initialized yet.
}
}
return this.#console;
}
/**
* The textarea height for shorter text.
*/
#shortTextHeight = "8em";
/**
* Retrieves the read-only textarea height for shorter text.
*
* @see #shortTextHeight
*/
get shortTextHeight() {
return this.#shortTextHeight;
}
/**
* The textarea height for shorter text.
*/
#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.
*/
#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>?}
*/
#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>}
*/
get elements() {
if (!this.#lazyElements) {
// Lazily turn the template into a DOM element.
/** @type {HTMLTemplateElement} */
const wrapper = document.getElementById(
"template-select-translations-panel"
);
const panel = wrapper.content.firstElementChild;
const settingsButton = document.getElementById(
"translations-panel-settings"
);
wrapper.replaceWith(wrapper.content);
// Lazily select the elements.
this.#lazyElements = {
panel,
settingsButton,
};
TranslationsPanelShared.defineLazyElements(document, this.#lazyElements, {
betaIcon: "select-translations-panel-beta-icon",
copyButton: "select-translations-panel-copy-button",
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",
mainContent: "select-translations-panel-main-content",
textArea: "select-translations-panel-text-area",
toLabel: "select-translations-panel-to-label",
toMenuList: "select-translations-panel-to",
toMenuPopup: "select-translations-panel-to-menupopup",
translateButton: "select-translations-panel-translate-button",
translateFullPageButton:
"select-translations-panel-translate-full-page-button",
tryAnotherSourceMenuList:
"select-translations-panel-try-another-language",
unsupportedLanguageContent:
"select-translations-panel-unsupported-language-content",
unsupportedLanguageMessageBar:
"select-translations-panel-unsupported-language-message-bar",
});
}
return this.#lazyElements;
}
/**
* Attempts to determine the best language tag to use as the source language for translation.
* If the detected language is not supported, attempts to fallback to the document's language tag.
*
* @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed.
*
* @returns {Promise<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.
*
* @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed.
* @returns {Promise<{fromLang?: string, toLang?: string}>} - An object containing the language pair for the translation.
* The `fromLang` property is omitted if it is a language that is not currently supported by Firefox Translations.
*/
async getLangPairPromise(textToTranslate) {
const [fromLang, toLang] = await Promise.all([
SelectTranslationsPanel.getTopSupportedDetectedLanguage(textToTranslate),
TranslationsParent.getTopPreferredSupportedToLang(),
]);
return { fromLang, toLang };
}
/**
* Close the Select Translations Panel.
*/
close() {
PanelMultiView.hidePopup(this.elements.panel);
}
/**
* 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,
gBrowser.selectedBrowser.innerWindowID
);
} catch (error) {
this.console?.error(error);
}
}
/**
* 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 menu list element to update.
*
* @returns {Promise<void>}
*/
async #initializeLanguageMenuList(langTag, menuList) {
const isLangTagSupported =
menuList.id === this.elements.fromMenuList.id
? await TranslationsParent.isSupportedAsFromLang(langTag)
: await TranslationsParent.isSupportedAsToLang(langTag);
if (isLangTagSupported) {
// Remove the data-l10n-id because the menulist label will
// be populated from the supported language's display name.
menuList.removeAttribute("data-l10n-id");
menuList.value = langTag;
} else {
await this.#deselectLanguage(menuList);
}
}
/**
* 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 #initializeLanguageMenuLists(langPairPromise) {
const { fromLang, toLang } = await langPairPromise;
const { fromMenuList, toMenuList, tryAnotherSourceMenuList } =
this.elements;
await Promise.all([
this.#initializeLanguageMenuList(fromLang, fromMenuList),
this.#initializeLanguageMenuList(toLang, toMenuList),
this.#initializeLanguageMenuList(null, tryAnotherSourceMenuList),
]);
}
/**
* 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, screenX, screenY, sourceText, langPairPromise) {
if (this.#isOpen()) {
return;
}
this.#registerSourceText(sourceText, langPairPromise);
await this.#ensureLangListsBuilt();
await Promise.all([
this.#cachePlaceholderText(),
this.#initializeLanguageMenuLists(langPairPromise),
]);
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");
await window.ensureCustomElements("moz-message-bar");
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.
* @param {Promise<{fromLang?: string, toLang?: string}>} langPairPromise
*
* @returns {Promise<void>}
*/
async #registerSourceText(sourceText, langPairPromise) {
const { textArea } = this.elements;
const { fromLang, toLang } = await langPairPromise;
const isFromLangSupported = await TranslationsParent.isSupportedAsFromLang(
fromLang
);
if (isFromLangSupported) {
this.#changeStateTo("idle", /* retainEntries */ false, {
sourceText,
fromLanguage: fromLang,
toLanguage: toLang,
});
} else {
this.#changeStateTo("unsupported", /* retainEntries */ false, {
sourceText,
detectedLanguage: fromLang,
toLanguage: toLang,
});
}
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 } = this.elements;
this.#maybeTranslateOnEvents(["blur", "keypress"], fromMenuList);
}
/**
* Handles events when the panels select to-language is changed.
*/
onChangeToLanguage() {
const { toMenuList } = this.elements;
this.#maybeTranslateOnEvents(["blur", "keypress"], toMenuList);
}
/**
* 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]);
}
/**
* 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 {string}
*/
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":
case "unsupported": {
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, detectedLanguage } =
this.#translationState;
const sourceLanguage = fromLanguage ? fromLanguage : detectedLanguage;
this.console?.debug(
`SelectTranslationsPanel (${sourceLanguage ? sourceLanguage : "??"}-${
toLanguage ? toLanguage : "??"
}) state change (${previousPhase} => ${phase})`
);
this.#updatePanelUIFromState();
document.dispatchEvent(
new CustomEvent("SelectTranslationsPanelStateChanged", {
detail: { phase },
})
);
}
/**
* 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 languages have not changed, so there is nothing to do.
(this.phase() !== "idle" &&
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() {
this.#showMainContent();
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() {
this.#showMainContent();
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() {
this.#showMainContent();
const { toLanguage } = this.#getSelectedLanguagePair();
const { textArea } = SelectTranslationsPanel.elements;
textArea.value = this.getTranslatedText();
this.#updateTextDirection(toLanguage);
this.#updateConditionalUIEnabledState();
this.#indicateTranslatedTextArea({ overflow: "auto" });
}
/**
* Sets attributes on panel elements that are specifically relevant
* to the SelectTranslationsPanel's state.
*
* @param {object} options - Options of which attributes to set.
* @param {Record<string, Element[]>} options.makeHidden - Make these elements hidden.
* @param {Record<string, Element[]>} options.makeVisible - Make these elements visible.
* @param {Record<string, Element[]>} options.addDefault - Give these elements the default attribute.
* @param {Record<string, Element[]>} options.removeDefault - Remove the default attribute from these elements.
*/
#setPanelElementAttributes({
makeHidden = [],
makeVisible = [],
addDefault = [],
removeDefault = [],
}) {
for (const element of makeHidden) {
element.hidden = true;
}
for (const element of makeVisible) {
element.hidden = false;
}
for (const element of addDefault) {
element.setAttribute("default", "true");
}
for (const element of removeDefault) {
element.removeAttribute("default");
}
}
/**
* Enables or disables UI components that are conditional on a valid language pair being selected.
*/
#updateConditionalUIEnabledState() {
const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair();
const {
copyButton,
translateFullPageButton,
translateButton,
textArea,
tryAnotherSourceMenuList,
} = this.elements;
const invalidLangPairSelected = !fromLanguage || !toLanguage;
const isTranslating = this.phase() === "translating";
textArea.disabled = invalidLangPairSelected;
translateFullPageButton.disabled = invalidLangPairSelected;
copyButton.disabled = invalidLangPairSelected || isTranslating;
translateButton.disabled = !tryAnotherSourceMenuList.value;
}
/**
* 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;
}
case "unsupported": {
this.#displayUnsupportedLanguageMessage();
}
}
}
/**
* Shows the panel's main-content group of elements.
*/
#showMainContent() {
const {
mainContent,
unsupportedLanguageContent,
doneButton,
translateButton,
translateFullPageButton,
} = this.elements;
this.#setPanelElementAttributes({
makeHidden: [unsupportedLanguageContent, translateButton],
makeVisible: [mainContent, doneButton, translateFullPageButton],
addDefault: [doneButton],
removeDefault: [translateButton],
});
}
/**
* Shows the panel's unsupported-language group of elements.
*/
#showUnsupportedLanguageContent() {
const {
mainContent,
unsupportedLanguageContent,
doneButton,
translateButton,
translateFullPageButton,
} = this.elements;
this.#setPanelElementAttributes({
makeHidden: [mainContent, translateFullPageButton],
makeVisible: [unsupportedLanguageContent, doneButton, translateButton],
addDefault: [translateButton],
removeDefault: [doneButton],
});
}
/**
* Displays the panel's unsupported language message bar, showing
* the panel's unsupported-language elements.
*/
#displayUnsupportedLanguageMessage() {
const { detectedLanguage } = this.#translationState;
const { unsupportedLanguageMessageBar } = this.elements;
const displayNames = new Services.intl.DisplayNames(undefined, {
type: "language",
});
try {
const language = displayNames.of(detectedLanguage);
if (language) {
document.l10n.setAttributes(
unsupportedLanguageMessageBar,
"select-translations-panel-unsupported-language-message-known",
{ language }
);
} else {
// Will be immediately caught.
throw new Error();
}
} catch {
// Either displayNames.of() threw, or we threw due to no display name found.
// In either case, localize the message for an unknown language.
document.l10n.setAttributes(
unsupportedLanguageMessageBar,
"select-translations-panel-unsupported-language-message-unknown"
);
}
this.#updateConditionalUIEnabledState();
this.#showUnsupportedLanguageContent();
}
/**
* 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, {
allowSameLanguage: true,
requestTranslationsPort: 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 = [];
}
})();