Source code
Revision control
Copy as Markdown
Other Tools
/* 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
/* eslint-env mozilla/browser-window */
/* eslint-disable jsdoc/valid-types */
/**
* @typedef {import("../../../../toolkit/components/translations/translations").LangTags} LangTags
*/
/* eslint-enable jsdoc/valid-types */
ChromeUtils.defineESModuleGetters(this, {
PageActions: "resource:///modules/PageActions.sys.mjs",
TranslationsTelemetry:
TranslationsPanelShared:
"chrome://browser/content/translations/TranslationsPanelShared.sys.mjs",
});
/**
* The set of actions that can occur from interaction with the
* translations panel.
*/
const PageAction = Object.freeze({
NO_CHANGE: "NO_CHANGE",
RESTORE_PAGE: "RESTORE_PAGE",
TRANSLATE_PAGE: "TRANSLATE_PAGE",
CLOSE_PANEL: "CLOSE_PANEL",
});
/**
* A mechanism for determining the next relevant page action
* based on the current translated state of the page and the state
* of the persistent options in the translations panel settings.
*/
class CheckboxPageAction {
/**
* Whether or not translations is active on the page.
*
* @type {boolean}
*/
#translationsActive = false;
/**
* Whether the always-translate-language menuitem is checked
* in the translations panel settings menu.
*
* @type {boolean}
*/
#alwaysTranslateLanguage = false;
/**
* Whether the never-translate-language menuitem is checked
* in the translations panel settings menu.
*
* @type {boolean}
*/
#neverTranslateLanguage = false;
/**
* Whether the never-translate-site menuitem is checked
* in the translations panel settings menu.
*
* @type {boolean}
*/
#neverTranslateSite = false;
/**
* @param {boolean} translationsActive
* @param {boolean} alwaysTranslateLanguage
* @param {boolean} neverTranslateLanguage
* @param {boolean} neverTranslateSite
*/
constructor(
translationsActive,
alwaysTranslateLanguage,
neverTranslateLanguage,
neverTranslateSite
) {
this.#translationsActive = translationsActive;
this.#alwaysTranslateLanguage = alwaysTranslateLanguage;
this.#neverTranslateLanguage = neverTranslateLanguage;
this.#neverTranslateSite = neverTranslateSite;
}
/**
* Accepts four integers that are either 0 or 1 and returns
* a single, unique number for each possible combination of
* values.
*
* @param {number} translationsActive
* @param {number} alwaysTranslateLanguage
* @param {number} neverTranslateLanguage
* @param {number} neverTranslateSite
*
* @returns {number} - An integer representation of the state
*/
static #computeState(
translationsActive,
alwaysTranslateLanguage,
neverTranslateLanguage,
neverTranslateSite
) {
return (
(translationsActive << 3) |
(alwaysTranslateLanguage << 2) |
(neverTranslateLanguage << 1) |
neverTranslateSite
);
}
/**
* Returns the current state of the data members as a single number.
*
* @returns {number} - An integer representation of the state
*/
#state() {
return CheckboxPageAction.#computeState(
Number(this.#translationsActive),
Number(this.#alwaysTranslateLanguage),
Number(this.#neverTranslateLanguage),
Number(this.#neverTranslateSite)
);
}
/**
* Returns the next page action to take when the always-translate-language
* menuitem is toggled in the translations panel settings menu.
*
* @returns {PageAction}
*/
alwaysTranslateLanguage() {
switch (this.#state()) {
case CheckboxPageAction.#computeState(1, 1, 0, 1):
case CheckboxPageAction.#computeState(1, 1, 0, 0):
return PageAction.RESTORE_PAGE;
case CheckboxPageAction.#computeState(0, 0, 1, 0):
case CheckboxPageAction.#computeState(0, 0, 0, 0):
return PageAction.TRANSLATE_PAGE;
}
return PageAction.NO_CHANGE;
}
/**
* Returns the next page action to take when the never-translate-language
* menuitem is toggled in the translations panel settings menu.
*
* @returns {PageAction}
*/
neverTranslateLanguage() {
switch (this.#state()) {
case CheckboxPageAction.#computeState(1, 1, 0, 1):
case CheckboxPageAction.#computeState(1, 1, 0, 0):
case CheckboxPageAction.#computeState(1, 0, 0, 1):
case CheckboxPageAction.#computeState(1, 0, 0, 0):
return PageAction.RESTORE_PAGE;
case CheckboxPageAction.#computeState(0, 1, 0, 0):
case CheckboxPageAction.#computeState(0, 0, 0, 1):
case CheckboxPageAction.#computeState(0, 1, 0, 1):
case CheckboxPageAction.#computeState(0, 0, 0, 0):
return PageAction.CLOSE_PANEL;
}
return PageAction.NO_CHANGE;
}
/**
* Returns the next page action to take when the never-translate-site
* menuitem is toggled in the translations panel settings menu.
*
* @returns {PageAction}
*/
neverTranslateSite() {
switch (this.#state()) {
case CheckboxPageAction.#computeState(1, 1, 0, 0):
case CheckboxPageAction.#computeState(1, 0, 1, 0):
case CheckboxPageAction.#computeState(1, 0, 0, 0):
return PageAction.RESTORE_PAGE;
case CheckboxPageAction.#computeState(0, 1, 0, 1):
return PageAction.TRANSLATE_PAGE;
case CheckboxPageAction.#computeState(0, 0, 1, 0):
case CheckboxPageAction.#computeState(0, 1, 0, 0):
case CheckboxPageAction.#computeState(0, 0, 0, 0):
return PageAction.CLOSE_PANEL;
}
return PageAction.NO_CHANGE;
}
}
/**
* 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?} */
#console;
/**
* The cached detected languages for both the document and the user.
*
* @type {null | LangTags}
*/
detectedLanguages = null;
/**
* 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 avialable. 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;
}
/**
* Tracks if the popup is open, or scheduled to be open.
*
* @type {boolean}
*/
#isPopupOpen = false;
/**
* Where the lazy elements are stored.
*
* @type {Record<string, Element>?}
*/
#lazyElements;
/**
* 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-translations-panel");
const panel = wrapper.content.firstElementChild;
wrapper.replaceWith(wrapper.content);
panel.addEventListener("command", this);
panel.addEventListener("click", this);
panel.addEventListener("popupshown", this);
panel.addEventListener("popuphidden", this);
const settingsButton = document.getElementById(
"translations-panel-settings"
);
// Clone the settings toolbarbutton across all the views.
for (const header of panel.querySelectorAll(".panel-header")) {
if (header.contains(settingsButton)) {
continue;
}
const settingsButtonClone = settingsButton.cloneNode(true);
settingsButtonClone.removeAttribute("id");
header.appendChild(settingsButtonClone);
}
// Lazily select the elements.
this.#lazyElements = {
panel,
settingsButton,
// The rest of the elements are set by the getter below.
};
TranslationsPanelShared.defineLazyElements(document, this.#lazyElements, {
alwaysTranslateLanguageMenuItem: ".always-translate-language-menuitem",
appMenuButton: "PanelUI-menu-button",
cancelButton: "full-page-translations-panel-cancel",
changeSourceLanguageButton:
"full-page-translations-panel-change-source-language",
dismissErrorButton: "full-page-translations-panel-dismiss-error",
error: "full-page-translations-panel-error",
errorMessage: "full-page-translations-panel-error-message",
errorMessageHint: "full-page-translations-panel-error-message-hint",
errorHintAction: "full-page-translations-panel-translate-hint-action",
fromLabel: "full-page-translations-panel-from-label",
fromMenuList: "full-page-translations-panel-from",
fromMenuPopup: "full-page-translations-panel-from-menupopup",
header: "full-page-translations-panel-header",
intro: "full-page-translations-panel-intro",
introLearnMoreLink:
"full-page-translations-panel-intro-learn-more-link",
langSelection: "full-page-translations-panel-lang-selection",
manageLanguagesMenuItem: ".manage-languages-menuitem",
multiview: "full-page-translations-panel-multiview",
neverTranslateLanguageMenuItem: ".never-translate-language-menuitem",
neverTranslateSiteMenuItem: ".never-translate-site-menuitem",
restoreButton: "full-page-translations-panel-restore-button",
toLabel: "full-page-translations-panel-to-label",
toMenuList: "full-page-translations-panel-to",
toMenuPopup: "full-page-translations-panel-to-menupopup",
translateButton: "full-page-translations-panel-translate",
unsupportedHeader:
"full-page-translations-panel-unsupported-language-header",
unsupportedHint: "full-page-translations-panel-error-unsupported-hint",
unsupportedLearnMoreLink:
"full-page-translations-panel-unsupported-learn-more-link",
});
}
return this.#lazyElements;
}
#lazyButtonElements = null;
/**
* When accessing `this.elements` the first time, it de-lazifies the custom components
* that are needed for the popup. Avoid that by having a second element lookup
* just for modifying the button.
*/
get buttonElements() {
if (!this.#lazyButtonElements) {
this.#lazyButtonElements = {
button: document.getElementById("translations-button"),
buttonLocale: document.getElementById("translations-button-locale"),
buttonCircleArrows: document.getElementById(
"translations-button-circle-arrows"
),
};
}
return this.#lazyButtonElements;
}
/**
* Cache the last command used for error hints so that it can be later removed.
*/
#lastHintCommand = null;
/**
* @param {object} options
* @param {string} options.message - l10n id
* @param {string} options.hint - l10n id
* @param {string} options.actionText - l10n id
* @param {Function} options.actionCommand - The action to perform.
*/
#showError({
message,
hint,
actionText: hintCommandText,
actionCommand: hintCommand,
}) {
const { error, errorMessage, errorMessageHint, errorHintAction, intro } =
this.elements;
error.hidden = false;
intro.hidden = true;
document.l10n.setAttributes(errorMessage, message);
if (hint) {
errorMessageHint.hidden = false;
document.l10n.setAttributes(errorMessageHint, hint);
} else {
errorMessageHint.hidden = true;
}
if (hintCommand && hintCommandText) {
errorHintAction.removeEventListener("command", this.#lastHintCommand);
this.#lastHintCommand = hintCommand;
errorHintAction.addEventListener("command", hintCommand);
errorHintAction.hidden = false;
document.l10n.setAttributes(errorHintAction, hintCommandText);
} else {
errorHintAction.hidden = true;
}
}
/**
* 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.
*
* @returns {Promise<LangTags>}
*/
async #fetchDetectedLanguages() {
this.detectedLanguages = await TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).getDetectedLanguages();
return this.detectedLanguages;
}
/**
* If the detected language tags have been retrieved previously, return the cached
* version. Otherwise do a fresh lookup of the document's language tag.
*
* @returns {Promise<LangTags>}
*/
async #getCachedDetectedLanguages() {
if (!this.detectedLanguages) {
return this.#fetchDetectedLanguages();
}
return this.detectedLanguages;
}
/**
* 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.
*/
async #ensureLangListsBuilt() {
try {
await TranslationsPanelShared.ensureLangListsBuilt(document, this);
} catch (error) {
this.console?.error(error);
}
}
/**
* Reactively sets the views based on the async state changes of the engine, and
* other component state changes.
*
* @param {TranslationsLanguageState} languageState
*/
#updateViewFromTranslationStatus(
languageState = TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).languageState
) {
const { translateButton, toMenuList, fromMenuList, header, cancelButton } =
this.elements;
const { requestedTranslationPair, isEngineReady } = languageState;
if (
requestedTranslationPair &&
!isEngineReady &&
toMenuList.value === requestedTranslationPair.toLanguage &&
fromMenuList.value === requestedTranslationPair.fromLanguage
) {
// A translation has been requested, but is not ready yet.
document.l10n.setAttributes(
translateButton,
"translations-panel-translate-button-loading"
);
translateButton.disabled = true;
cancelButton.hidden = false;
this.updateUIForReTranslation(false /* isReTranslation */);
} else {
document.l10n.setAttributes(
translateButton,
"translations-panel-translate-button"
);
translateButton.disabled =
// The translation languages are the same, don't allow this translation.
toMenuList.value === fromMenuList.value ||
// No "to" language was provided.
!toMenuList.value ||
// No "from" language was provided.
!fromMenuList.value ||
// This is the requested translation pair.
(requestedTranslationPair &&
requestedTranslationPair.fromLanguage === fromMenuList.value &&
requestedTranslationPair.toLanguage === toMenuList.value);
}
if (requestedTranslationPair && isEngineReady) {
const { fromLanguage, toLanguage } = requestedTranslationPair;
const displayNames = new Services.intl.DisplayNames(undefined, {
type: "language",
});
cancelButton.hidden = true;
this.updateUIForReTranslation(true /* isReTranslation */);
document.l10n.setAttributes(header, "translations-panel-revisit-header", {
fromLanguage: displayNames.of(fromLanguage),
toLanguage: displayNames.of(toLanguage),
});
} else {
document.l10n.setAttributes(header, "translations-panel-header");
}
}
/**
* @param {boolean} isReTranslation
*/
updateUIForReTranslation(isReTranslation) {
const { restoreButton, fromLabel, fromMenuList, toLabel } = this.elements;
restoreButton.hidden = !isReTranslation;
// When offering to re-translate a page, hide the "from" language so users don't
// get confused.
fromLabel.hidden = isReTranslation;
fromMenuList.hidden = isReTranslation;
if (isReTranslation) {
fromLabel.style.marginBlockStart = "";
toLabel.style.marginBlockStart = 0;
} else {
fromLabel.style.marginBlockStart = 0;
toLabel.style.marginBlockStart = "";
}
}
/**
* Returns true if the panel is currently showing the default view, otherwise false.
*
* @returns {boolean}
*/
#isShowingDefaultView() {
if (!this.#lazyElements) {
// Nothing has been initialized.
return false;
}
const { multiview } = this.elements;
return (
multiview.getAttribute("mainViewId") ===
"full-page-translations-panel-view-default"
);
}
/**
* Show the default view of choosing a source and target language.
*
* @param {TranslationsParent} actor
* @param {boolean} force - Force the page to show translation options.
*/
async #showDefaultView(actor, force = false) {
const {
fromMenuList,
multiview,
panel,
error,
toMenuList,
translateButton,
langSelection,
intro,
header,
} = this.elements;
this.#updateViewFromTranslationStatus();
// Unconditionally hide the intro text in case the panel is re-shown.
intro.hidden = true;
if (TranslationsPanelShared.getLangListsInitState(this) === "error") {
// There was an error, display it in the view rather than the language
// dropdowns.
const { cancelButton, errorHintAction } = this.elements;
this.#showError({
message: "translations-panel-error-load-languages",
hint: "translations-panel-error-load-languages-hint",
actionText: "translations-panel-error-load-languages-hint-button",
actionCommand: () => this.#reloadLangList(actor),
});
translateButton.disabled = true;
this.updateUIForReTranslation(false /* isReTranslation */);
cancelButton.hidden = false;
langSelection.hidden = true;
errorHintAction.disabled = false;
return;
}
// Remove any old selected values synchronously before asking for new ones.
fromMenuList.value = "";
error.hidden = true;
langSelection.hidden = false;
const { userLangTag, docLangTag, isDocLangTagSupported } =
await this.#fetchDetectedLanguages().then(langTags => langTags ?? {});
if (isDocLangTagSupported || force) {
// Show the default view with the language selection
const { cancelButton } = this.elements;
if (isDocLangTagSupported) {
fromMenuList.value = docLangTag ?? "";
} else {
fromMenuList.value = "";
}
if (
this.#manuallySelectedToLanguage &&
this.#manuallySelectedToLanguage !== docLangTag
) {
// Use the manually selected language if available
toMenuList.value = this.#manuallySelectedToLanguage;
} else if (userLangTag && userLangTag !== docLangTag) {
// The userLangTag is specified and does not match the doc lang tag, so we should use it.
toMenuList.value = userLangTag;
} else {
// No userLangTag is specified in the cache and no #manuallySelectedToLanguage is available,
// so we will attempt to find a suitable one.
toMenuList.value =
await TranslationsParent.getTopPreferredSupportedToLang({
excludeLangTags: [
// Avoid offering to translate into the original source language.
docLangTag,
// Avoid same-language to same-language translations if possible.
fromMenuList.value,
],
});
}
if (fromMenuList.value === toMenuList.value) {
// The best possible user-preferred language tag that we were able to find for the
// toMenuList is the same as the fromMenuList, but same-language to same-language
// translations are not allowed in Full Page Translations, so we will just show the
// "Choose a language" option in this case.
toMenuList.value = "";
}
this.onChangeLanguages();
this.updateUIForReTranslation(false /* isReTranslation */);
cancelButton.hidden = false;
multiview.setAttribute(
"mainViewId",
"full-page-translations-panel-view-default"
);
if (!this._hasShownPanel) {
actor.firstShowUriSpec = gBrowser.currentURI.spec;
}
if (
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
document.l10n.setAttributes(header, "translations-panel-header");
actor.firstShowUriSpec = null;
intro.hidden = true;
} else {
Services.prefs.setBoolPref("browser.translations.panelShown", true);
intro.hidden = false;
document.l10n.setAttributes(header, "translations-panel-intro-header");
}
} else {
// Show the "unsupported language" view.
const { unsupportedHint } = this.elements;
multiview.setAttribute(
"mainViewId",
"full-page-translations-panel-view-unsupported-language"
);
let language;
if (docLangTag) {
const displayNames = new Intl.DisplayNames(undefined, {
type: "language",
fallback: "none",
});
language = displayNames.of(docLangTag);
}
if (language) {
document.l10n.setAttributes(
unsupportedHint,
"translations-panel-error-unsupported-hint-known",
{ language }
);
} else {
document.l10n.setAttributes(
unsupportedHint,
"translations-panel-error-unsupported-hint-unknown"
);
}
}
// Focus the "from" language, as it is the only field not set.
panel.addEventListener(
"ViewShown",
() => {
if (!fromMenuList.value) {
fromMenuList.focus();
}
if (!toMenuList.value) {
toMenuList.focus();
}
},
{ once: true }
);
}
/**
* Updates the checked states of the settings menu checkboxes that
* pertain to languages.
*/
async #updateSettingsMenuLanguageCheckboxStates() {
const langTags = await this.#getCachedDetectedLanguages();
const { docLangTag, isDocLangTagSupported } = langTags;
const { panel } = this.elements;
const alwaysTranslateMenuItems = panel.ownerDocument.querySelectorAll(
".always-translate-language-menuitem"
);
const neverTranslateMenuItems = panel.ownerDocument.querySelectorAll(
".never-translate-language-menuitem"
);
const alwaysOfferTranslationsMenuItems =
panel.ownerDocument.querySelectorAll(
".always-offer-translations-menuitem"
);
const alwaysOfferTranslations =
TranslationsParent.shouldAlwaysOfferTranslations();
const alwaysTranslateLanguage =
TranslationsParent.shouldAlwaysTranslateLanguage(langTags);
const neverTranslateLanguage =
TranslationsParent.shouldNeverTranslateLanguage(docLangTag);
const shouldDisable =
!docLangTag ||
!isDocLangTagSupported ||
docLangTag ===
(await TranslationsParent.getTopPreferredSupportedToLang());
for (const menuitem of alwaysOfferTranslationsMenuItems) {
menuitem.setAttribute(
"checked",
alwaysOfferTranslations ? "true" : "false"
);
}
for (const menuitem of alwaysTranslateMenuItems) {
menuitem.setAttribute(
"checked",
alwaysTranslateLanguage ? "true" : "false"
);
menuitem.disabled = shouldDisable;
}
for (const menuitem of neverTranslateMenuItems) {
menuitem.setAttribute(
"checked",
neverTranslateLanguage ? "true" : "false"
);
menuitem.disabled = shouldDisable;
}
}
/**
* Updates the checked states of the settings menu checkboxes that
* pertain to site permissions.
*/
async #updateSettingsMenuSiteCheckboxStates() {
const { panel } = this.elements;
const neverTranslateSiteMenuItems = panel.ownerDocument.querySelectorAll(
".never-translate-site-menuitem"
);
const neverTranslateSite = await TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).shouldNeverTranslateSite();
for (const menuitem of neverTranslateSiteMenuItems) {
menuitem.setAttribute("checked", neverTranslateSite ? "true" : "false");
}
}
/**
* Populates the language-related settings menuitems by adding the
* localized display name of the document's detected language tag.
*/
async #populateSettingsMenuItems() {
const { docLangTag } = await this.#getCachedDetectedLanguages();
const { panel } = this.elements;
const alwaysTranslateMenuItems = panel.ownerDocument.querySelectorAll(
".always-translate-language-menuitem"
);
const neverTranslateMenuItems = panel.ownerDocument.querySelectorAll(
".never-translate-language-menuitem"
);
/** @type {string | undefined} */
let docLangDisplayName;
if (docLangTag) {
const displayNames = new Services.intl.DisplayNames(undefined, {
type: "language",
fallback: "none",
});
// The display name will still be empty if the docLangTag is not known.
docLangDisplayName = displayNames.of(docLangTag);
}
for (const menuitem of alwaysTranslateMenuItems) {
if (docLangDisplayName) {
document.l10n.setAttributes(
menuitem,
"translations-panel-settings-always-translate-language",
{ language: docLangDisplayName }
);
} else {
document.l10n.setAttributes(
menuitem,
"translations-panel-settings-always-translate-unknown-language"
);
}
}
for (const menuitem of neverTranslateMenuItems) {
if (docLangDisplayName) {
document.l10n.setAttributes(
menuitem,
"translations-panel-settings-never-translate-language",
{ language: docLangDisplayName }
);
} else {
document.l10n.setAttributes(
menuitem,
"translations-panel-settings-never-translate-unknown-language"
);
}
}
await Promise.all([
this.#updateSettingsMenuLanguageCheckboxStates(),
this.#updateSettingsMenuSiteCheckboxStates(),
]);
}
/**
* Configures the panel for the user to reset the page after it has been translated.
*
* @param {TranslationPair} translationPair
*/
async #showRevisitView({ fromLanguage, toLanguage }) {
const { fromMenuList, toMenuList, intro } = this.elements;
if (!this.#isShowingDefaultView()) {
await this.#showDefaultView(
TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser)
);
}
intro.hidden = true;
fromMenuList.value = fromLanguage;
toMenuList.value = await TranslationsParent.getTopPreferredSupportedToLang({
excludeLangTags: [
// Avoid offering to translate into the original source language.
fromLanguage,
// Avoid offering to translate into current active target language.
toLanguage,
],
});
this.onChangeLanguages();
}
/**
* Handle the disable logic for when the menulist is changed for the "Translate to"
* on the "revisit" subview.
*/
onChangeRevisitTo() {
const { revisitTranslate, revisitMenuList } = this.elements;
revisitTranslate.disabled = !revisitMenuList.value;
}
/**
* Handle logic and telemetry for changing the selected from-language option.
*
* @param {Event} event
*/
onChangeFromLanguage(event) {
const { target } = event;
if (target?.value) {
TranslationsParent.telemetry()
.fullPagePanel()
.onChangeFromLanguage(target.value);
}
this.onChangeLanguages();
}
/**
* Handle logic and telemetry for changing the selected to-language option.
*
* @param {Event} event
*/
onChangeToLanguage(event) {
const { target } = event;
if (target?.value) {
TranslationsParent.telemetry()
.fullPagePanel()
.onChangeToLanguage(target.value);
}
this.onChangeLanguages();
// Update the manually selected language when the user changes the target language.
this.#manuallySelectedToLanguage = target.value;
}
/**
* When changing the language selection, the translate button will need updating.
*/
onChangeLanguages() {
this.#updateViewFromTranslationStatus();
}
/**
* Hide the pop up (for event handlers).
*/
close() {
PanelMultiView.hidePopup(this.elements.panel);
}
/*
* Handler for clicking the learn more link from linked text
* within the translations panel.
*/
onLearnMoreLink() {
TranslationsParent.telemetry().fullPagePanel().onLearnMoreLink();
FullPageTranslationsPanel.close();
}
/*
* Handler for clicking the learn more link from the gear menu.
*/
onAboutTranslations() {
TranslationsParent.telemetry().fullPagePanel().onAboutTranslations();
PanelMultiView.hidePopup(this.elements.panel);
const window =
gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
window.openTrustedLinkIn(
"tab",
{
forceForeground: true,
triggeringPrincipal:
Services.scriptSecurityManager.getSystemPrincipal(),
}
);
}
/**
* When a language is not supported and the menu is manually invoked, an error message
* is shown. This method switches the panel back to the language selection view.
* Note that this bypasses the showSubView method since the main view doesn't support
* a subview.
*/
async onChangeSourceLanguage(event) {
const { panel } = this.elements;
PanelMultiView.hidePopup(panel);
await this.#showDefaultView(
TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser),
true /* force this view to be shown */
);
await this.#openPanelPopup(this.elements.appMenuButton, {
event,
viewName: "defaultView",
maintainFlow: true,
});
}
/**
* @param {TranslationsActor} actor
*/
async #reloadLangList(actor) {
try {
await this.#ensureLangListsBuilt();
await this.#showDefaultView(actor);
} catch (error) {
this.elements.errorHintAction.disabled = false;
}
}
/**
* Handle telemetry events when buttons are invoked in the panel.
*
* @param {Event} event
*/
handlePanelButtonEvent(event) {
const {
cancelButton,
changeSourceLanguageButton,
dismissErrorButton,
restoreButton,
translateButton,
} = this.elements;
switch (event.target.id) {
case cancelButton.id: {
TranslationsParent.telemetry().fullPagePanel().onCancelButton();
break;
}
case changeSourceLanguageButton.id: {
TranslationsParent.telemetry()
.fullPagePanel()
.onChangeSourceLanguageButton();
break;
}
case dismissErrorButton.id: {
TranslationsParent.telemetry().fullPagePanel().onDismissErrorButton();
break;
}
case restoreButton.id: {
TranslationsParent.telemetry().fullPagePanel().onRestorePageButton();
break;
}
case translateButton.id: {
TranslationsParent.telemetry().fullPagePanel().onTranslateButton();
break;
}
}
}
/**
* Handle telemetry events when popups are shown in the panel.
*
* @param {Event} event
*/
handlePanelPopupShownEvent(event) {
const { panel, fromMenuList, toMenuList } = this.elements;
switch (event.target.id) {
case panel.id: {
// This telemetry event is invoked externally because it requires
// extra logic about from where the panel was opened and whether
// or not the flow should be maintained or started anew.
break;
}
case fromMenuList.firstChild.id: {
TranslationsParent.telemetry().fullPagePanel().onOpenFromLanguageMenu();
break;
}
case toMenuList.firstChild.id: {
TranslationsParent.telemetry().fullPagePanel().onOpenToLanguageMenu();
break;
}
}
}
/**
* Handle telemetry events when popups are hidden in the panel.
*
* @param {Event} event
*/
handlePanelPopupHiddenEvent(event) {
const { panel, fromMenuList, toMenuList } = this.elements;
switch (event.target.id) {
case panel.id: {
TranslationsParent.telemetry().fullPagePanel().onClose();
this.#isPopupOpen = false;
this.elements.error.hidden = true;
break;
}
case fromMenuList.firstChild.id: {
TranslationsParent.telemetry()
.fullPagePanel()
.onCloseFromLanguageMenu();
break;
}
case toMenuList.firstChild.id: {
TranslationsParent.telemetry().fullPagePanel().onCloseToLanguageMenu();
break;
}
}
}
/**
* Handle telemetry events when the settings menu is shown.
*/
handleSettingsPopupShownEvent() {
TranslationsParent.telemetry().fullPagePanel().onOpenSettingsMenu();
}
/**
* Handle telemetry events when the settings menu is hidden.
*/
handleSettingsPopupHiddenEvent() {
TranslationsParent.telemetry().fullPagePanel().onCloseSettingsMenu();
}
/**
* Opens the Translations panel popup at the given target.
*
* @param {object} target - The target element at which to open the popup.
* @param {object} telemetryData
* @param {string} telemetryData.event
* The trigger event for opening the popup.
* @param {string} telemetryData.viewName
* The name of the view shown by the panel.
* @param {boolean} telemetryData.autoShow
* True if the panel was automatically opened, otherwise false.
* @param {boolean} telemetryData.maintainFlow
* Whether or not to maintain the flow of telemetry.
*/
async #openPanelPopup(
target,
{ event = null, viewName = null, autoShow = false, maintainFlow = false }
) {
const { panel, appMenuButton } = this.elements;
const openedFromAppMenu = target.id === appMenuButton.id;
const { docLangTag } = await this.#getCachedDetectedLanguages();
TranslationsParent.telemetry().fullPagePanel().onOpen({
viewName,
autoShow,
docLangTag,
maintainFlow,
openedFromAppMenu,
});
this.#isPopupOpen = true;
PanelMultiView.openPopup(panel, target, {
position: "bottomright topright",
triggerEvent: event,
}).catch(error => this.console?.error(error));
}
/**
* Keeps track of open requests to guard against race conditions.
*
* @type {Promise<void> | null}
*/
#openPromise = null;
/**
* Opens the FullPageTranslationsPanel.
*
* @param {Event} event
* @param {boolean} reportAsAutoShow
* True to report to telemetry that the panel was opened automatically, otherwise false.
*/
async open(event, reportAsAutoShow = false) {
if (this.#openPromise) {
// There is already an open event happening, do not open.
return;
}
this.#openPromise = this.#openImpl(event, reportAsAutoShow);
this.#openPromise.finally(() => {
this.#openPromise = null;
});
}
/**
* The language tag that was manually selected by the user.
* This is used to remember the selection if the user happens to close and then reopen the panel prior to translating.
*
* @type {string | null}
*/
#manuallySelectedToLanguage = null;
/**
* Implementation function for opening the panel. Prefer FullPageTranslationsPanel.open.
*
* @param {Event} event
*/
async #openImpl(event, reportAsAutoShow) {
event.stopPropagation();
if (
(event.type == "click" && event.button != 0) ||
(event.type == "keypress" &&
event.charCode != KeyEvent.DOM_VK_SPACE &&
event.keyCode != KeyEvent.DOM_VK_RETURN)
) {
// Allow only left click, space, or enter.
return;
}
const { button } = this.buttonElements;
const { requestedTranslationPair } =
TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).languageState;
await this.#ensureLangListsBuilt();
if (requestedTranslationPair) {
await this.#showRevisitView(requestedTranslationPair).catch(error => {
this.console?.error(error);
});
} else {
await this.#showDefaultView(
TranslationsParent.getTranslationsActor(gBrowser.selectedBrowser)
).catch(error => {
this.console?.error(error);
});
}
this.#populateSettingsMenuItems();
const targetButton =
button.contains(event.target) ||
event.type === "TranslationsParent:OfferTranslation"
? button
: this.elements.appMenuButton;
this.console?.log(`Showing a translation panel`, gBrowser.currentURI.spec);
await this.#openPanelPopup(targetButton, {
event,
autoShow: reportAsAutoShow,
viewName: requestedTranslationPair ? "revisitView" : "defaultView",
maintainFlow: false,
});
}
/**
* Returns true if translations is currently active, otherwise false.
*
* @returns {boolean}
*/
#isTranslationsActive() {
const { requestedTranslationPair } =
TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).languageState;
return requestedTranslationPair !== null;
}
/**
* Handle the translation button being clicked when there are two language options.
*/
async onTranslate() {
this.#manuallySelectedToLanguage = null;
PanelMultiView.hidePopup(this.elements.panel);
const actor = TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
);
actor.translate(
this.elements.fromMenuList.value,
this.elements.toMenuList.value,
false // reportAsAutoTranslate
);
}
/**
* Handle the cancel button being clicked.
*/
onCancel() {
PanelMultiView.hidePopup(this.elements.panel);
}
/**
* A handler for opening the settings context menu.
*/
openSettingsPopup(button) {
this.#updateSettingsMenuLanguageCheckboxStates();
this.#updateSettingsMenuSiteCheckboxStates();
const popup = button.ownerDocument.getElementById(
"full-page-translations-panel-settings-menupopup"
);
popup.openPopup(button, "after_end");
}
/**
* Creates a new CheckboxPageAction based on the current translated
* state of the page and the state of the persistent options in the
* translations panel settings.
*
* @returns {CheckboxPageAction}
*/
getCheckboxPageActionFor() {
const {
alwaysTranslateLanguageMenuItem,
neverTranslateLanguageMenuItem,
neverTranslateSiteMenuItem,
} = this.elements;
const alwaysTranslateLanguage =
alwaysTranslateLanguageMenuItem.getAttribute("checked") === "true";
const neverTranslateLanguage =
neverTranslateLanguageMenuItem.getAttribute("checked") === "true";
const neverTranslateSite =
neverTranslateSiteMenuItem.getAttribute("checked") === "true";
return new CheckboxPageAction(
this.#isTranslationsActive(),
alwaysTranslateLanguage,
neverTranslateLanguage,
neverTranslateSite
);
}
/**
* Redirect the user to about:preferences
*/
openManageLanguages() {
TranslationsParent.telemetry().fullPagePanel().onManageLanguages();
const window =
gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
window.openTrustedLinkIn("about:preferences#general-translations", "tab");
}
/**
* Performs the given page action.
*
* @param {PageAction} pageAction
*/
async #doPageAction(pageAction) {
switch (pageAction) {
case PageAction.NO_CHANGE: {
break;
}
case PageAction.RESTORE_PAGE: {
await this.onRestore();
break;
}
case PageAction.TRANSLATE_PAGE: {
await this.onTranslate();
break;
}
case PageAction.CLOSE_PANEL: {
PanelMultiView.hidePopup(this.elements.panel);
break;
}
}
}
/**
* Updates the always-translate-language menuitem prefs and checked state.
* If auto-translate is currently active for the doc language, deactivates it.
* If auto-translate is currently inactive for the doc language, activates it.
*/
async onAlwaysTranslateLanguage() {
const langTags = await this.#getCachedDetectedLanguages();
const { docLangTag } = langTags;
if (!docLangTag) {
throw new Error("Expected to have a document language tag.");
}
const pageAction =
this.getCheckboxPageActionFor().alwaysTranslateLanguage();
const toggledOn =
TranslationsParent.toggleAlwaysTranslateLanguagePref(langTags);
TranslationsParent.telemetry()
.fullPagePanel()
.onAlwaysTranslateLanguage(docLangTag, toggledOn);
this.#updateSettingsMenuLanguageCheckboxStates();
await this.#doPageAction(pageAction);
}
/**
* Toggle offering translations.
*/
async onAlwaysOfferTranslations() {
const toggledOn = TranslationsParent.toggleAutomaticallyPopupPref();
TranslationsParent.telemetry()
.fullPagePanel()
.onAlwaysOfferTranslations(toggledOn);
}
/**
* Updates the never-translate-language menuitem prefs and checked state.
* If never-translate is currently active for the doc language, deactivates it.
* If never-translate is currently inactive for the doc language, activates it.
*/
async onNeverTranslateLanguage() {
const { docLangTag } = await this.#getCachedDetectedLanguages();
if (!docLangTag) {
throw new Error("Expected to have a document language tag.");
}
const pageAction = this.getCheckboxPageActionFor().neverTranslateLanguage();
const toggledOn =
TranslationsParent.toggleNeverTranslateLanguagePref(docLangTag);
TranslationsParent.telemetry()
.fullPagePanel()
.onNeverTranslateLanguage(docLangTag, toggledOn);
this.#updateSettingsMenuLanguageCheckboxStates();
await this.#doPageAction(pageAction);
}
/**
* Updates the never-translate-site menuitem permissions and checked state.
* If never-translate is currently active for the site, deactivates it.
* If never-translate is currently inactive for the site, activates it.
*/
async onNeverTranslateSite() {
const pageAction = this.getCheckboxPageActionFor().neverTranslateSite();
const toggledOn = await TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).toggleNeverTranslateSitePermissions();
TranslationsParent.telemetry()
.fullPagePanel()
.onNeverTranslateSite(toggledOn);
this.#updateSettingsMenuSiteCheckboxStates();
await this.#doPageAction(pageAction);
}
/**
* Handle the restore button being clicked.
*/
async onRestore() {
const { panel } = this.elements;
PanelMultiView.hidePopup(panel);
const { docLangTag } = await this.#getCachedDetectedLanguages();
if (!docLangTag) {
throw new Error("Expected to have a document language tag.");
}
TranslationsParent.getTranslationsActor(
gBrowser.selectedBrowser
).restorePage(docLangTag);
}
/**
* An event handler that allows the FullPageTranslationsPanel object
* to be compatible with the addTabsProgressListener function.
*
* @param {tabbrowser} browser
*/
onLocationChange(browser) {
if (browser.currentURI.spec.startsWith("about:reader")) {
// Hide the translations button when entering reader mode.
this.buttonElements.button.hidden = true;
}
}
/**
* Update the view to show an error.
*
* @param {TranslationParent} actor
*/
async #showEngineError(actor) {
const { button } = this.buttonElements;
await this.#ensureLangListsBuilt();
if (!this.#isShowingDefaultView()) {
await this.#showDefaultView(actor).catch(e => {
this.console?.error(e);
});
}
this.elements.error.hidden = false;
this.#showError({
message: "translations-panel-error-translating",
});
const targetButton = button.hidden ? this.elements.appMenuButton : button;
// Re-open the menu on an error.
await this.#openPanelPopup(targetButton, {
autoShow: true,
viewName: "errorView",
maintainFlow: true,
});
}
/**
* Set the state of the translations button in the URL bar.
*
* @param {CustomEvent} event
*/
handleEvent = event => {
const target = event.target;
let { id } = target;
// If a menuitem within a menulist is the target, it will not have an id,
// so we want to grab the closest relevant id.
if (!id) {
id = target.closest("[id]")?.id;
}
switch (event.type) {
case "command": {
switch (id) {
case "translations-panel-settings":
this.openSettingsPopup(target);
break;
case "full-page-translations-panel-from-menupopup":
this.onChangeFromLanguage(event);
break;
case "full-page-translations-panel-to-menupopup":
this.onChangeToLanguage(event);
break;
case "full-page-translations-panel-restore-button":
this.onRestore(event);
break;
case "full-page-translations-panel-cancel":
case "full-page-translations-panel-dismiss-error":
this.onCancel(event);
break;
case "full-page-translations-panel-translate":
this.onTranslate(event);
break;
case "full-page-translations-panel-change-source-language":
this.onChangeSourceLanguage(event);
break;
}
break;
}
case "click": {
switch (id) {
case "full-page-translations-panel-intro-learn-more-link":
case "full-page-translations-panel-unsupported-learn-more-link":
this.onLearnMoreLink();
break;
default:
this.handlePanelButtonEvent(event);
}
break;
}
case "popupshown":
this.handlePanelPopupShownEvent(event);
break;
case "popuphidden":
this.handlePanelPopupHiddenEvent(event);
break;
case "TranslationsParent:OfferTranslation": {
if (Services.wm.getMostRecentBrowserWindow()?.gBrowser === gBrowser) {
this.open(event, /* reportAsAutoShow */ true);
}
break;
}
case "TranslationsParent:LanguageState": {
const { actor, reason } = event.detail;
const innerWindowId =
gBrowser.selectedBrowser.browsingContext.top.embedderElement
.innerWindowID;
this.console?.debug("TranslationsParent:LanguageState", {
reason,
currentId: innerWindowId,
originatorId: actor.innerWindowId,
});
if (innerWindowId !== actor.innerWindowId) {
// The id of the currently active tab does not match the id of the tab that was active when the event was dispatched.
// This likely means that the tab was changed after the event was dispatched, but before it was received by this class.
//
// Keep in mind that there is only one instance of this class (FullPageTranslationsPanel) for each open Firefox window,
// but there is one instance of the TranslationsParent actor for each open tab within a Firefox window. As such, it is
// possible for a tab-specific actor to fire an event that is received by the window-global panel after switching tabs.
//
// Since the dispatched event did not originate in the currently active tab, we should not react to it any further.
return;
}
const {
detectedLanguages,
requestedTranslationPair,
error,
isEngineReady,
} = actor.languageState;
const { button, buttonLocale, buttonCircleArrows } =
this.buttonElements;
const hasSupportedLanguage =
detectedLanguages?.docLangTag &&
detectedLanguages?.userLangTag &&
detectedLanguages?.isDocLangTagSupported;
if (detectedLanguages) {
// Ensure the cached detected languages are up to date, for instance whenever
// the user switches tabs.
FullPageTranslationsPanel.detectedLanguages = detectedLanguages;
// Reset the manually selected language when the user switches tabs.
this.#manuallySelectedToLanguage = null;
}
if (this.#isPopupOpen) {
// Make sure to use the language state that is passed by the event.detail, and
// don't read it from the actor here, as it's possible the actor isn't available
// via the gBrowser.selectedBrowser.
this.#updateViewFromTranslationStatus(actor.languageState);
}
if (
// We've already requested to translate this page, so always show the icon.
requestedTranslationPair ||
// There was an error translating, so always show the icon. This can happen
// when a user manually invokes the translation and we wouldn't normally show
// the icon.
error ||
// Finally check that we can translate this language.
(hasSupportedLanguage &&
TranslationsParent.getIsTranslationsEngineSupported())
) {
// Keep track if the button was originally hidden, because it will be shown now.
const wasButtonHidden = button.hidden;
button.hidden = false;
if (requestedTranslationPair) {
// The translation is active, update the urlbar button.
button.setAttribute("translationsactive", true);
if (isEngineReady) {
const displayNames = new Services.intl.DisplayNames(undefined, {
type: "language",
});
document.l10n.setAttributes(
button,
"urlbar-translations-button-translated",
{
fromLanguage: displayNames.of(
requestedTranslationPair.fromLanguage
),
toLanguage: displayNames.of(
requestedTranslationPair.toLanguage
),
}
);
// Show the locale of the page in the button.
buttonLocale.hidden = false;
buttonCircleArrows.hidden = true;
buttonLocale.innerText = requestedTranslationPair.toLanguage;
} else {
document.l10n.setAttributes(
button,
"urlbar-translations-button-loading"
);
// Show the spinning circle arrows to indicate that the engine is
// still loading.
buttonCircleArrows.hidden = false;
buttonLocale.hidden = true;
}
} else {
// The translation is not active, update the urlbar button.
button.removeAttribute("translationsactive");
buttonLocale.hidden = true;
buttonCircleArrows.hidden = true;
// Follow the same rules for displaying the first-run intro text for the
// button's accessible tooltip label.
if (
this._hasShownPanel &&
gBrowser.currentURI.spec !== actor.firstShowUriSpec
) {
document.l10n.setAttributes(
button,
"urlbar-translations-button2"
);
} else {
document.l10n.setAttributes(
button,
"urlbar-translations-button-intro"
);
}
}
// The button was hidden, but now it is shown.
if (wasButtonHidden) {
PageActions.sendPlacedInUrlbarTrigger(button);
}
} else if (!button.hidden) {
// There are no translations visible, hide the button.
button.hidden = true;
}
switch (error) {
case null:
break;
case "engine-load-failure":
this.#showEngineError(actor).catch(viewError =>
this.console?.error(viewError)
);
break;
default:
console.error("Unknown translation error", error);
}
break;
}
}
};
})();
XPCOMUtils.defineLazyPreferenceGetter(
FullPageTranslationsPanel,
"_hasShownPanel",
"browser.translations.panelShown",
false
);