- : 96 %
- : 94 %
- : 96 %
- : 94 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 94 %
- : 94 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 94 %
- : 96 %
- : 96 %
- : 94 %
- : 94 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 96 %
- : 93 %
- : 93 %
- : 93 %
- : 94 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 94 %
- : 94 %
- : 94 %
- : 94 %
- : 94 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
- : 95 %
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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// The following globals are injected via the AboutTranslationsChild actor.
// about-translations.mjs is running in an unprivileged context, and these injected functions
// allow for the page to get access to additional privileged features.
/* global AT_getAppLocaleAsBCP47, AT_getSupportedLanguages, AT_log, AT_getScriptDirection,
AT_getDisplayName, AT_logError, AT_createTranslationsPort, AT_isHtmlTranslation,
AT_isTranslationEngineSupported, AT_isInAutomation, AT_identifyLanguage, AT_clearSourceText,
AT_telemetry, AT_isEnabledStateManagedByPolicy, AT_enableTranslationsFeature */
import { Translator } from "chrome://global/content/translations/Translator.mjs";
// eslint-disable-next-line import/no-unassigned-import
import "chrome://global/content/elements/moz-support-link.mjs";
/**
* This is the delay for the set of throttled reactions to input in the source text area.
* These actions will trigger at most once every 200 milliseconds while the source text area is receiving input.
*
* Applying this on the window allows tests to override the delay milliseconds for throttled input handling.
*/
window.THROTTLE_DELAY = 200;
/**
* This is the delay for the set of debounced reactions to input in the source text area.
* These actions will trigger 400 milliseconds after the final text-area input.
* Further input to the text area within the 400ms window will reset this timer.
*
* Applying this on the window allows tests to override the delay milliseconds for debounced input handling.
*/
window.DEBOUNCE_DELAY = 400;
/**
* This is the delay for throttling translation-request telemetry in about:translations.
*
* These events will trigger at most once per interval, unless the throttle
* is manually reset by context changes (e.g. source clear or language change).
*
* Applying this on the window allows tests to override the delay milliseconds.
*/
window.TRANSLATION_REQUEST_TELEMETRY_THROTTLE_DELAY = 5_000;
/**
* This is the default duration, in milliseconds, that the copy button remains
* in the "copied" state before reverting back to its default state.
*
* Based on WCAG standards, a minimum of 5 seconds should be maintained to
* avoid blinking, and to provide enough time for users to notice the change
* of the copy button's state.
*/
const COPY_BUTTON_RESET_DELAY_DEFAULT = 5_000;
/**
* This is the duration, in milliseconds, that the copy button remains in the
* "copied" state before reverting back to its default state for users who
* prefer reduced motion.
*
*/
const COPY_BUTTON_RESET_DELAY_REDUCED_MOTION = 20_000;
window.COPY_BUTTON_RESET_DELAY = COPY_BUTTON_RESET_DELAY_DEFAULT;
/**
* Limits how long the "text" parameter can be in the URL.
*/
const URL_MAX_TEXT_LENGTH = 5000;
/**
* @typedef {import("../translations").LanguagePair} LanguagePair
* @typedef {import("../translations").SupportedLanguages} SupportedLanguages
*/
/**
* Dispatches a custom event used only by automated tests.
*
* @param {string} type
* @param {object} [detail]
*/
function dispatchTestEvent(type, detail) {
if (!AT_isInAutomation()) {
return;
}
document.dispatchEvent(new CustomEvent(type, { detail }));
}
class AboutTranslations {
/**
* The set of languages that are supported by the Translations feature.
*
* @type {SupportedLanguages}
*/
#supportedLanguages;
/**
* A monotonically increasing number that represents a unique ID
* for each translation request.
*
* @type {number}
*/
#translationId = 0;
/**
* Whether the Translations feature is enabled for this page.
*
* @type {boolean}
*/
#isFeatureEnabled = true;
/**
* Whether the current system supports the translations engine.
*
* @type {boolean}
*/
#isTranslationsEngineSupported;
/**
* The previous source text that was translated.
*
* This is used to calculate the difference of lengths in the texts,
* which informs whether we should display a translating placeholder.
*
* @type {string}
*/
#previousSourceText = "";
/**
* The BCP-47 language tag of the currently detected language.
*
* Defaults to an empty string if the detect-language option is not selected,
* or if the detector did not determine a language tag.
*
* @type {string}
*/
#detectedLanguage = "";
/**
* The display name of the currently detected language.
*
* @type {string}
*/
#detectedLanguageDisplayName = "";
/**
* The translator for the current language pair.
*
* @type {null | Translator}
*/
#translator = null;
/**
* The elements that comprise the about:translations UI.
*
* Use the {@link AboutTranslations#elements} getter to instantiate and retrieve them.
*
* @type {Record<string, Element>}
*/
#lazyElements;
/**
* The localized placeholder text to display when translating.
*
* @type {string}
*/
#translatingPlaceholderText;
/**
* A promise that resolves when the about:translations UI has successfully initialized,
* or rejects if the UI failed to initialize.
*
* @type {PromiseWithResolvers}
*/
#readyPromiseWithResolvers = Promise.withResolvers();
/**
* A timeout id for resetting the copy button's "copied" state.
*
* @type {number | null}
*/
#copyButtonResetTimeoutId = null;
/**
* A timeout id for throttling translation-request telemetry.
*
* @type {number | null}
*/
#translationRequestTelemetryThrottleTimeoutId = null;
/**
* The word-count segmenter for the current source language.
*
* @type {Intl.Segmenter | null}
*/
#wordCountSegmenter = null;
/**
* The language tag of the current word-count segmenter.
*
* @type {string}
*/
#wordCountSegmenterLanguageTag = "";
/**
* An optional delay override for resetting the copy button.
*
* This is intended for automated tests that need deterministic timing.
*
* @type {number | null}
*/
#copyButtonResetDelayOverride = null;
/**
* Whether manual copy button resets are enabled for automated tests.
*
* @type {boolean}
*/
#isManualCopyButtonResetEnabled = false;
/**
* The orientation of the page's content.
*
* When the page orientation is horizontal the source and target sections
* are displayed side by side with the source section at the inline start
* and the target section at the inline end.
*
* When the page orientation is vertical the source section is displayed
* above the target section, independent of locale bidirectionality.
*
* When the page orientation is vertical, each section spans the full width
* of the content, and resizing the window width or changing the zoom level
* must trigger section resizing, whereas those updates are not necessary
* when the page orientation is horizontal.
*
* @type {("vertical"|"horizontal")}
*/
#pageOrientation = "horizontal";
/**
* A timeout id that gets set when a pending callback is scheduled
* to update the section heights.
*
* This helps ensure that we do not make repeated calls to this function
* that would cause unnecessary and excessive reflow.
*
* @type {number | null}
*/
#updateSectionHeightsTimeoutId = null;
/**
* This set contains hash values that to be assigned to the URL from user
* interaction with the UI. When this occurs, we want to ignore the "hashchange"
* event, since the URL did not change externally.
*
* When "hashchange" fires and the active URL hash is not a member of this set,
* then it means the user may have modified the URL outside of the page UI, and
* we will need to update the UI from the URL.
*
* @type {Set<string>}
*/
#urlHashesFromUIState = new Set();
/**
* Returns the maximum of the given numbers, rounded up.
*
* @param {...number} numbers
*
* @returns {number}
*/
static #maxInteger(...numbers) {
return Math.ceil(Math.max(...numbers));
}
/**
* Constructs a new {@link AboutTranslations} instance.
*
* @param {boolean} isTranslationsEngineSupported
* Whether the translations engine is supported by the current system.
*/
constructor(isTranslationsEngineSupported) {
this.#isTranslationsEngineSupported = isTranslationsEngineSupported;
AT_telemetry("onOpen", {
maintainFlow: false,
});
if (!isTranslationsEngineSupported) {
this.#readyPromiseWithResolvers.reject();
this.#showUnsupportedInfoMessage();
return;
}
this.#setup()
.then(() => {
this.#readyPromiseWithResolvers.resolve();
})
.catch(error => {
AT_logError(error);
this.#readyPromiseWithResolvers.reject(error);
this.#showLanguageLoadErrorMessage();
});
}
/**
* Resolves when the about:translations page has successfully initialized.
*
* @returns {Promise<void>}
*/
async ready() {
await this.#readyPromiseWithResolvers.promise;
}
/**
* Enables the feature and updates the UI to match the enabled state.
*
* @returns {Promise<void>}
*/
async onFeatureEnabled() {
this.#isFeatureEnabled = true;
if (!this.#isTranslationsEngineSupported) {
this.#showUnsupportedInfoMessage();
document.body.style.visibility = "visible";
return;
}
if (!this.#supportedLanguages) {
this.#showLanguageLoadErrorMessage();
document.body.style.visibility = "visible";
return;
}
this.#showMainUserInterface();
this.#setMainUserInterfaceEnabled(true);
this.#updateSourceScriptDirection();
this.#updateTargetScriptDirection();
this.#updateSourceSectionClearButtonVisibility();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
this.#updateURLFromUI();
this.#maybeRequestTranslation();
document.body.style.visibility = "visible";
}
/**
* Disables the feature and clears any active translations.
*
* @returns {Promise<void>}
*/
async onFeatureDisabled() {
// Ensure any active translation request becomes stale.
this.#translationId += 1;
this.#isFeatureEnabled = false;
this.#destroyTranslator();
const isManagedByPolicy = await AT_isEnabledStateManagedByPolicy();
if (isManagedByPolicy) {
this.#showPolicyDisabledInfoMessage();
return;
}
this.#showFeatureBlockedInfoMessage();
}
/**
* Instantiates and returns the elements that comprise the UI.
*
* @returns {{
* copyButton: HTMLElement,
* detectLanguageOption: HTMLElement,
* detectedLanguageUnsupportedHeading: HTMLElement,
* detectedLanguageUnsupportedLearnMoreLink: HTMLAnchorElement,
* detectedLanguageUnsupportedMessage: HTMLElement,
* featureBlockedInfoMessage: HTMLElement,
* unblockFeatureButton: HTMLElement,
* languageLoadErrorButton: HTMLElement,
* languageLoadErrorMessage: HTMLElement,
* learnMoreLink: HTMLAnchorElement,
* mainUserInterface: HTMLElement,
* policyDisabledInfoMessage: HTMLElement,
* sourceLanguageSelector: HTMLElement,
* sourceSection: HTMLElement,
* sourceSectionClearButton: HTMLElement,
* sourceSectionTextArea: HTMLTextAreaElement,
* swapLanguagesButton: HTMLElement,
* targetLanguageSelector: HTMLElement,
* targetSection: HTMLElement,
* targetSectionTextArea: HTMLTextAreaElement,
* translationErrorButton: HTMLElement,
* translationErrorMessage: HTMLElement,
* unsupportedInfoMessage: HTMLElement,
* }}
*/
get elements() {
if (this.#lazyElements) {
return this.#lazyElements;
}
this.#lazyElements = {
copyButton: /** @type {HTMLElement} */ (
document.getElementById("about-translations-copy-button")
),
detectLanguageOption: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-detect-language-label-option"
)
),
detectedLanguageUnsupportedHeading: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-detected-language-unsupported-heading"
)
),
detectedLanguageUnsupportedLearnMoreLink:
/** @type {HTMLAnchorElement} */ (
document.getElementById(
"about-translations-detected-language-unsupported-learn-more-link"
)
),
detectedLanguageUnsupportedMessage: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-detected-language-unsupported-message"
)
),
featureBlockedInfoMessage: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-feature-blocked-info-message"
)
),
unblockFeatureButton: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-feature-blocked-unblock-button"
)
),
languageLoadErrorButton: /** @type {HTMLElement} */ (
document.getElementById("about-translations-language-load-error-button")
),
languageLoadErrorMessage: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-language-load-error-message"
)
),
learnMoreLink: /** @type {HTMLAnchorElement} */ (
document.getElementById("about-translations-learn-more-link")
),
mainUserInterface: /** @type {HTMLElement} */ (
document.getElementById("about-translations-main-user-interface")
),
policyDisabledInfoMessage: /** @type {HTMLElement} */ (
document.getElementById(
"about-translations-policy-disabled-info-message"
)
),
sourceLanguageSelector: /** @type {HTMLElement} */ (
document.getElementById("about-translations-source-select")
),
sourceSection: /** @type {HTMLElement} */ (
document.getElementById("about-translations-source-section")
),
sourceSectionClearButton: /** @type {HTMLElement} */ (
document.getElementById("about-translations-clear-button")
),
sourceSectionTextArea: /** @type {HTMLTextAreaElement} */ (
document.getElementById("about-translations-source-textarea")
),
swapLanguagesButton: /** @type {HTMLElement} */ (
document.getElementById("about-translations-swap-languages-button")
),
targetLanguageSelector: /** @type {HTMLElement} */ (
document.getElementById("about-translations-target-select")
),
targetSection: /** @type {HTMLElement} */ (
document.getElementById("about-translations-target-section")
),
targetSectionTextArea: /** @type {HTMLTextAreaElement} */ (
document.getElementById("about-translations-target-textarea")
),
translationErrorButton: /** @type {HTMLElement} */ (
document.getElementById("about-translations-translation-error-button")
),
translationErrorMessage: /** @type {HTMLElement} */ (
document.getElementById("about-translations-translation-error-message")
),
unsupportedInfoMessage: /** @type {HTMLElement} */ (
document.getElementById("about-translations-unsupported-info-message")
),
};
return this.#lazyElements;
}
/**
* Sets the internal data member for which orientation the page is in.
*
* This information is important for performance regarding in which situations
* we need to dynamically resize the <textarea> elements on the page.
*
* @returns {boolean} True if the page orientation changed, otherwise false.
*/
#updatePageOrientation() {
const orientationAtStart = this.#pageOrientation;
// Keep this value up to date with the media query rule in about-translations.css
this.#pageOrientation =
window.innerWidth > 1200 ? "horizontal" : "vertical";
const orientationChanged = orientationAtStart !== this.#pageOrientation;
if (orientationChanged) {
dispatchTestEvent("AboutTranslationsTest:PageOrientationChanged", {
orientation: this.#pageOrientation,
});
}
return orientationChanged;
}
/**
* An async setup function to initialize the about:translations page.
*
* Performs all initialization steps and finally reveals the main UI.
*/
async #setup() {
this.#initializeEventListeners();
this.#updatePageOrientation();
this.#translatingPlaceholderText = await document.l10n.formatValue(
"about-translations-translating-message"
);
await this.#setupLanguageSelectors();
this.#updateUIFromURL();
this.#updateSourceScriptDirection();
this.#updateTargetScriptDirection();
this.#showMainUserInterface();
this.#updateSourceSectionClearButtonVisibility();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
}
/**
* Populates the language selector elements with supported languages.
*
* This is a one-time operation performed during initialization.
*/
async #populateLanguageSelectors() {
this.#supportedLanguages = await AT_getSupportedLanguages();
const { sourceLanguageSelector, targetLanguageSelector } = this.elements;
for (const { langTagKey, displayName } of this.#supportedLanguages
.sourceLanguages) {
const option = document.createElement("moz-option");
option.value = langTagKey;
option.setAttribute("label", displayName);
sourceLanguageSelector.append(option);
}
for (const { langTagKey, displayName } of this.#supportedLanguages
.targetLanguages) {
const option = document.createElement("moz-option");
option.value = langTagKey;
option.setAttribute("label", displayName);
targetLanguageSelector.append(option);
}
}
/**
* Sets up the language selectors during initialization.
*/
async #setupLanguageSelectors() {
await this.#populateLanguageSelectors();
const { sourceLanguageSelector, targetLanguageSelector } = this.elements;
this.#resetDetectLanguageOptionText();
sourceLanguageSelector.value = "detect";
targetLanguageSelector.value = "";
this.#setElementEnabled(sourceLanguageSelector, true);
this.#setElementEnabled(targetLanguageSelector, true);
}
/**
* Initializes all relevant event listeners for the UI elements.
*/
#initializeEventListeners() {
const {
copyButton,
detectedLanguageUnsupportedMessage,
unblockFeatureButton,
languageLoadErrorButton,
sourceLanguageSelector,
sourceSectionClearButton,
sourceSectionTextArea,
swapLanguagesButton,
targetLanguageSelector,
translationErrorButton,
translationErrorMessage,
} = this.elements;
copyButton.addEventListener("click", this.#onCopyButton);
unblockFeatureButton.addEventListener(
"click",
this.#onUnblockFeatureButton
);
languageLoadErrorButton.addEventListener(
"click",
this.#onLanguageLoadErrorRetry
);
sourceLanguageSelector.addEventListener(
"change",
this.#onSourceLanguageChange
);
detectedLanguageUnsupportedMessage.addEventListener(
"pointerdown",
this.#onSourceMessageBarPointerDown
);
sourceSectionClearButton.addEventListener(
"click",
this.#onSourceSectionClearButton
);
sourceSectionClearButton.addEventListener(
"mousedown",
this.#onSourceSectionClearButtonMouseDown
);
sourceSectionTextArea.addEventListener("input", this.#onSourceTextInput);
swapLanguagesButton.addEventListener("click", this.#onSwapLanguagesButton);
targetLanguageSelector.addEventListener(
"change",
this.#onTargetLanguageChange
);
translationErrorButton.addEventListener(
"click",
this.#onTranslationErrorRetry
);
translationErrorMessage.addEventListener(
"pointerdown",
this.#onTargetMessageBarPointerDown
);
window.addEventListener("hashchange", this.#onHashChange);
window.addEventListener("resize", this.#onResize);
window.visualViewport.addEventListener("resize", this.#onResize);
}
/**
* Handles retry clicks from the language-load error message.
*/
#onLanguageLoadErrorRetry = event => {
event.preventDefault();
void this.#retryLanguageLoad();
};
/**
* Handles clicks on the unblock button in the feature-blocked message.
*/
#onUnblockFeatureButton = async () => {
const { unblockFeatureButton } = this.elements;
unblockFeatureButton.setAttribute("disabled", "true");
AT_telemetry("onUnblockFeature");
try {
await AT_enableTranslationsFeature();
} catch (error) {
AT_logError(error);
} finally {
unblockFeatureButton.removeAttribute("disabled");
}
};
/**
* Handles mousedown on the source section clear button.
*
* Prevents the button from taking focus when clicked.
*/
#onSourceSectionClearButtonMouseDown = event => {
event.preventDefault();
};
/**
* Handles clicks on the source section clear button.
*/
#onSourceSectionClearButton = event => {
if (!this.#sourceTextAreaHasValue()) {
return;
}
event.preventDefault();
this.#clearSourceText();
AT_telemetry("onClearSourceTextButton");
};
/**
* Handles clicks on the swap-languages button.
*/
#onSwapLanguagesButton = () => {
AT_telemetry("onSwapButton");
this.#clearTranslationRequestTelemetryThrottle();
this.#disableSwapLanguagesButton();
this.#maybeSwapLanguages();
this.#updateURLFromUI();
this.#maybeRequestTranslation({ allowFromErrorState: true });
};
/**
* Handles change events on the source-language selector.
*/
#onSourceLanguageChange = () => {
const { sourceLanguageSelector } = this.elements;
const previouslyDetectedLanguageWasExplicitlySelected =
sourceLanguageSelector.value === this.#detectedLanguage;
this.#updateURLFromUI();
this.#resetDetectLanguageOptionText();
this.#clearTranslationRequestTelemetryThrottle();
if (previouslyDetectedLanguageWasExplicitlySelected) {
// This represents the case where we were previously on the "Detect language"
// dropdown menu item and, for example, Spanish was detected, but the user has
// now updated the source-language select to the explicit "Spanish" menu item.
// In this case, the effectively selected language tag remains the same.
return;
}
this.#disableSwapLanguagesButton();
this.#updateSourceScriptDirection();
this.#maybeRequestTranslation({ allowFromErrorState: true });
this.#updateDetectedLanguageUnsupportedMessage();
};
/**
* Handles change events on the target-language selector.
*/
#onTargetLanguageChange = () => {
this.#clearTranslationRequestTelemetryThrottle();
this.#disableSwapLanguagesButton();
this.#updateURLFromUI();
this.#maybeRequestTranslation({ allowFromErrorState: true });
};
/**
* Handles input events on the source textarea.
*/
#onSourceTextInput = () => {
this.#updateSourceSectionClearButtonVisibility();
this.#handleSourceTextInput();
};
/**
* Handles pointerdown events within the source message bar.
*
* Focuses the source section when the event occurs on non-interactive content.
*/
#onSourceMessageBarPointerDown = event => {
if (event.target?.closest?.("a")) {
return;
}
this.elements.sourceSection.focus();
};
/**
* Handles pointerdown events within the target message bar.
*
* Focuses the target section when the event occurs on non-interactive content.
*/
#onTargetMessageBarPointerDown = event => {
if (event.target?.closest?.("moz-button")) {
return;
}
this.elements.targetSection.focus();
};
/**
* Handles retry clicks from the translation error message.
*/
#onTranslationErrorRetry = () => {
AT_telemetry("onTryAgainButton");
this.#maybeRequestTranslation({ allowFromErrorState: true });
};
/**
* Handles copying the translated text to the clipboard when the copy button is invoked.
*/
#onCopyButton = async () => {
const { copyButton, targetSectionTextArea } = this.elements;
if (copyButton.disabled) {
return;
}
const targetText = targetSectionTextArea.value;
if (!targetText) {
return;
}
try {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard API is unavailable.");
}
await navigator.clipboard.writeText(targetText);
} catch (error) {
AT_logError(error);
return;
}
AT_telemetry("onCopyButton");
this.#showCopyButtonCopiedState();
};
/**
* Handles resize events, including resizing the window and zooming.
*/
#onResize = () => {
const orientationChanged = this.#updatePageOrientation();
if (orientationChanged) {
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
} else if (this.#pageOrientation === "vertical") {
this.#requestSectionHeightsUpdate({ scheduleCallback: true });
}
};
/**
* Handles hash changes that come from URL bar navigation.
*/
#onHashChange = event => {
const urlHash = new URL(event.newURL).hash.slice(1);
if (this.#urlHashesFromUIState.has(urlHash)) {
// This hash change came from the UI updating the URL.
// The UI doesn't need to react to this hash change.
this.#urlHashesFromUIState.delete(urlHash);
return;
}
this.#updateUIFromURL();
};
/**
* Shows the main UI and hides any stand-alone message bars.
*/
#showMainUserInterface() {
const { mainUserInterface } = this.elements;
for (const messageBar of this.#getTopLevelMessageBars()) {
this.#setTopLevelMessageBarVisible(messageBar, false);
}
mainUserInterface.hidden = false;
}
/**
* Enables or disables the controls in the main UI.
*
* @param {boolean} enabled
*/
#setMainUserInterfaceEnabled(enabled) {
const {
copyButton,
sourceLanguageSelector,
sourceSectionClearButton,
sourceSectionTextArea,
swapLanguagesButton,
targetLanguageSelector,
targetSectionTextArea,
translationErrorButton,
} = this.elements;
for (const element of [
sourceLanguageSelector,
targetLanguageSelector,
sourceSectionTextArea,
targetSectionTextArea,
sourceSectionClearButton,
translationErrorButton,
]) {
this.#setElementEnabled(element, enabled);
}
// These buttons depend on contextual UI state, so they are handled separately.
// They will always be disabled when we are disabling the UI, but they are only
// conditionally re-enabled based on the context within the UI itself.
if (!enabled) {
swapLanguagesButton.disabled = true;
copyButton.disabled = true;
} else {
this.#updateSwapLanguagesButtonEnabledState();
}
}
/**
* Sets an element's enabled state.
*
* We need to do it this way until the moz-select web component properly
* reflects the disabled attribute on the top-level custom element.
*
* @param {HTMLElement} element
* @param {boolean} enabled
*/
#setElementEnabled(element, enabled) {
if ("disabled" in element) {
element.disabled = !enabled;
}
if (enabled) {
element.removeAttribute("disabled");
} else {
element.setAttribute("disabled", "true");
}
}
/**
* Shows the message that translations are not supported in the current environment.
*/
#showUnsupportedInfoMessage() {
const { unsupportedInfoMessage } = this.elements;
this.#showTopLevelMessageBar(unsupportedInfoMessage);
}
/**
* Shows the message that translations are unavailable due to enterprise policy.
*/
#showPolicyDisabledInfoMessage() {
const { policyDisabledInfoMessage } = this.elements;
this.#showTopLevelMessageBar(policyDisabledInfoMessage);
}
/**
* Returns all top-level message bars.
*
* @returns {HTMLElement[]}
*/
#getTopLevelMessageBars() {
const {
featureBlockedInfoMessage,
unsupportedInfoMessage,
policyDisabledInfoMessage,
languageLoadErrorMessage,
} = this.elements;
return [
unsupportedInfoMessage,
policyDisabledInfoMessage,
languageLoadErrorMessage,
featureBlockedInfoMessage,
];
}
/**
* Shows a top-level message bar and updates the main UI visibility.
*
* @param {HTMLElement} messageBar
*/
#showTopLevelMessageBar(messageBar) {
const { featureBlockedInfoMessage, mainUserInterface } = this.elements;
for (const topLevelMessageBar of this.#getTopLevelMessageBars()) {
this.#setTopLevelMessageBarVisible(
topLevelMessageBar,
topLevelMessageBar === messageBar
);
}
if (messageBar === featureBlockedInfoMessage) {
mainUserInterface.hidden = false;
this.#setMainUserInterfaceEnabled(false);
} else {
mainUserInterface.hidden = true;
}
document.body.style.visibility = "visible";
}
/**
* Shows the message that the list of languages could not be loaded.
*/
#showLanguageLoadErrorMessage() {
const { languageLoadErrorMessage } = this.elements;
this.#showTopLevelMessageBar(languageLoadErrorMessage);
}
/**
* Shows the message that the feature is blocked but can be unblocked.
*/
#showFeatureBlockedInfoMessage() {
const { featureBlockedInfoMessage } = this.elements;
this.#showTopLevelMessageBar(featureBlockedInfoMessage);
}
/**
* Shows or hides a top-level message bar and notifies tests when the
* visibility changes.
*
* @param {HTMLElement} messageBar
* @param {boolean} visible
*/
#setTopLevelMessageBarVisible(messageBar, visible) {
const isVisible = !messageBar.hidden;
if (isVisible === visible) {
return;
}
messageBar.hidden = !visible;
if (visible) {
this.#recordTopLevelMessageBarTelemetry(messageBar);
}
const eventNames =
this.#getTopLevelMessageBarVisibilityEventNames(messageBar);
if (!eventNames) {
return;
}
dispatchTestEvent(visible ? eventNames.shown : eventNames.hidden);
}
/**
* Records telemetry when a top-level message bar is shown.
*
* @param {HTMLElement} messageBar
*/
#recordTopLevelMessageBarTelemetry(messageBar) {
const {
unsupportedInfoMessage,
policyDisabledInfoMessage,
languageLoadErrorMessage,
featureBlockedInfoMessage,
} = this.elements;
switch (messageBar) {
case unsupportedInfoMessage: {
AT_telemetry("onUnsupportedInfoMessage");
return;
}
case policyDisabledInfoMessage: {
AT_telemetry("onPolicyDisabledInfoMessage");
return;
}
case languageLoadErrorMessage: {
AT_telemetry("onLanguageLoadErrorMessage");
return;
}
case featureBlockedInfoMessage: {
AT_telemetry("onFeatureBlockedInfoMessage");
return;
}
default: {
AT_logError(
`Unexpected top-level message bar for telemetry: ${messageBar.id || "<no-id>"}`
);
}
}
}
/**
* Returns test events for top-level message bar visibility changes.
*
* @param {HTMLElement} messageBar
* @returns {{ shown: string, hidden: string } | null}
*/
#getTopLevelMessageBarVisibilityEventNames(messageBar) {
const {
unsupportedInfoMessage,
policyDisabledInfoMessage,
languageLoadErrorMessage,
} = this.elements;
if (messageBar === unsupportedInfoMessage) {
return {
shown: "AboutTranslationsTest:UnsupportedInfoMessageShown",
hidden: "AboutTranslationsTest:UnsupportedInfoMessageHidden",
};
}
if (messageBar === policyDisabledInfoMessage) {
return {
shown: "AboutTranslationsTest:PolicyDisabledInfoMessageShown",
hidden: "AboutTranslationsTest:PolicyDisabledInfoMessageHidden",
};
}
if (messageBar === languageLoadErrorMessage) {
return {
shown: "AboutTranslationsTest:LanguageLoadErrorMessageShown",
hidden: "AboutTranslationsTest:LanguageLoadErrorMessageHidden",
};
}
return null;
}
/**
* Resets the source and target language selectors before reloading languages.
*/
#resetLanguageSelectorsForReload() {
const {
detectLanguageOption,
sourceLanguageSelector,
targetLanguageSelector,
} = this.elements;
for (const option of sourceLanguageSelector.querySelectorAll(
"moz-option"
)) {
if (option !== detectLanguageOption) {
option.remove();
}
}
for (const option of targetLanguageSelector.querySelectorAll(
"moz-option"
)) {
if (option.getAttribute("value") !== "") {
option.remove();
}
}
}
/**
* Retries loading languages and restores the UI when the data is available.
*/
async #retryLanguageLoad() {
const { languageLoadErrorButton } = this.elements;
languageLoadErrorButton.setAttribute("disabled", "true");
dispatchTestEvent("AboutTranslationsTest:LanguageLoadRetryStarted");
try {
this.#resetLanguageSelectorsForReload();
await this.#setupLanguageSelectors();
this.#updateUIFromURL();
this.#updateSourceScriptDirection();
this.#updateTargetScriptDirection();
this.#showMainUserInterface();
this.#updateSourceSectionClearButtonVisibility();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
dispatchTestEvent("AboutTranslationsTest:LanguageLoadRetrySucceeded");
} catch (error) {
AT_logError(error);
this.#showLanguageLoadErrorMessage();
dispatchTestEvent("AboutTranslationsTest:LanguageLoadRetryFailed");
} finally {
languageLoadErrorButton.removeAttribute("disabled");
}
}
/**
* Shows the message that the detected language is not yet supported.
*/
#showDetectedLanguageUnsupportedMessage() {
const {
detectedLanguageUnsupportedHeading,
detectedLanguageUnsupportedMessage,
} = this.elements;
const wasHidden = detectedLanguageUnsupportedMessage.hidden;
const languageLabel =
this.#detectedLanguageDisplayName || this.#detectedLanguage;
if (languageLabel) {
document.l10n.setAttributes(
detectedLanguageUnsupportedHeading,
"about-translations-detected-language-unsupported-heading-2",
{ language: languageLabel }
);
} else {
document.l10n.setAttributes(
detectedLanguageUnsupportedHeading,
"about-translations-detected-language-unsupported-heading-unknown-2"
);
}
detectedLanguageUnsupportedMessage.hidden = false;
if (wasHidden) {
this.#recordUnsupportedLanguageMessageTelemetry();
}
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
document.l10n
.translateFragment(detectedLanguageUnsupportedMessage)
.then(() => {
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
});
}
/**
* Hides the message that the detected language is not yet supported.
*/
#hideDetectedLanguageUnsupportedMessage() {
const { detectedLanguageUnsupportedMessage } = this.elements;
if (detectedLanguageUnsupportedMessage.hidden) {
return;
}
detectedLanguageUnsupportedMessage.hidden = true;
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
}
/**
* Shows the translation error message in the target section.
*/
#showTranslationErrorMessage() {
const { targetSection, translationErrorMessage } = this.elements;
if (!translationErrorMessage.hidden) {
return;
}
this.#clearTranslationRequestTelemetryThrottle();
targetSection.classList.add("has-translation-error");
translationErrorMessage.hidden = false;
this.#disableSwapLanguagesButton();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
}
/**
* Hides the translation error message in the target section.
*/
#hideTranslationErrorMessage() {
const { targetSection, translationErrorMessage } = this.elements;
if (translationErrorMessage.hidden) {
return;
}
targetSection.classList.remove("has-translation-error");
translationErrorMessage.hidden = true;
this.#updateSwapLanguagesButtonEnabledState();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
}
/**
* Updates the detected-language unsupported message based on the current state.
*/
#updateDetectedLanguageUnsupportedMessage() {
const sourceText = this.#getSourceText();
if (!sourceText || !this.#isDetectLanguageSelected()) {
this.#hideDetectedLanguageUnsupportedMessage();
return;
}
if (!this.#detectedLanguage || this.#isDetectedLanguageUnsupported()) {
this.#showDetectedLanguageUnsupportedMessage();
return;
}
this.#hideDetectedLanguageUnsupportedMessage();
}
/**
* Splits a language-tag key into its language tag and optional variant.
*
* @param {string} languageTagKey
* @returns {{ languageTag: string, variant: string | undefined }}
*/
#splitLanguageTagKey(languageTagKey) {
const [languageTag = "", variant] = languageTagKey.split(",");
return { languageTag, variant };
}
/**
* Returns the currently selected or detected source language tag.
* Returns an empty string if no language is selected or detected.
*
* @returns {string}
*/
#getSelectedSourceLanguageTag() {
const selectedSourceLanguage = this.#isDetectLanguageSelected()
? this.#detectedLanguage
: this.elements.sourceLanguageSelector.value;
return this.#splitLanguageTagKey(selectedSourceLanguage).languageTag;
}
/**
* Returns the currently selected target language tag.
* Returns an empty string if no language is selected.
*
* @returns {string}
*/
#getSelectedTargetLanguageTag() {
return this.#splitLanguageTagKey(this.elements.targetLanguageSelector.value)
.languageTag;
}
/**
* Returns the language pair formed by the values of the language selectors.
* Returns null if one or more of the selectors has no selected value.
*
* @returns {null | LanguagePair}
*/
#getSelectedLanguagePair() {
const { sourceLanguageSelector, targetLanguageSelector } = this.elements;
let selectedSourceLanguage;
if (this.#isDetectLanguageSelected()) {
selectedSourceLanguage = this.#detectedLanguage;
} else {
selectedSourceLanguage = sourceLanguageSelector.value;
}
const { languageTag: sourceLanguage, variant: sourceVariant } =
this.#splitLanguageTagKey(selectedSourceLanguage);
const { languageTag: targetLanguage, variant: targetVariant } =
this.#splitLanguageTagKey(targetLanguageSelector.value);
if (sourceLanguage === "" || targetLanguage === "") {
// At least one selector is empty, so we cannot create a language pair.
return null;
}
return {
sourceLanguage,
targetLanguage,
sourceVariant,
targetVariant,
};
}
/**
* Returns true if we do not yet support the detected language for Translations,
* otherwise false.
*
* @returns {boolean}
*/
#isDetectedLanguageUnsupported() {
if (!this.#isDetectLanguageSelected()) {
// The detect-language option is not selected so this is not relevant.
return false;
}
return !this.elements.sourceLanguageSelector.querySelector(
`[value^=${this.#detectedLanguage}]`
);
}
/**
* Returns true if the source-language selector is set to the detect-language
* option, otherwise false.
*
* @returns {boolean}
*/
#isDetectLanguageSelected() {
return this.elements.sourceLanguageSelector.value === "detect";
}
/**
* Updates the display value of the source-language selector with the identified
* language of the source text, only if the source-language selector is set to
* the detect-language option.
*/
async #maybeUpdateDetectedSourceLanguage() {
if (!this.#isDetectLanguageSelected()) {
this.#resetDetectLanguageOptionText();
return;
}
const detectedLanguage = await this.#identifySourceLanguage();
if (!this.#isDetectLanguageSelected()) {
this.#resetDetectLanguageOptionText();
return;
}
const previousDetectedLanguage = this.#detectedLanguage;
this.#detectedLanguage = detectedLanguage;
if (detectedLanguage) {
const displayName = await AT_getDisplayName(detectedLanguage);
this.#detectedLanguageDisplayName = displayName;
this.#populateDetectLanguageOption({ detectedLanguage, displayName });
} else {
this.#resetDetectLanguageOptionText();
}
this.#updateSourceScriptDirection();
if (previousDetectedLanguage !== detectedLanguage) {
this.#clearTranslationRequestTelemetryThrottle();
dispatchTestEvent("AboutTranslationsTest:DetectedLanguageUpdated", {
language: detectedLanguage,
});
}
}
/**
* Sets the label of the about-translations-detect option in the
* about-translations-source-select dropdown.
*
* Passing an empty display name will reset to the default string.
* Passing a display name will set the text to the given language.
*
* @param {object} params
* @param {string} params.detectedLanguage - The BCP-47 language tag of the detected language.
* @param {string} params.displayName - The display name of the detected language.
*/
#populateDetectLanguageOption({ detectedLanguage, displayName }) {
const { detectLanguageOption } = this.elements;
detectLanguageOption.setAttribute("language", detectedLanguage);
document.l10n.setAttributes(
detectLanguageOption,
"about-translations-detect-language-label",
{ language: displayName }
);
}
/**
* Restores the detect-language option to its default value.
*/
#resetDetectLanguageOptionText() {
const { detectLanguageOption } = this.elements;
this.#detectedLanguage = "";
this.#detectedLanguageDisplayName = "";
detectLanguageOption.removeAttribute("language");
document.l10n.setAttributes(
detectLanguageOption,
"about-translations-detect-default-label"
);
}
/**
* Returns true if the source-language selector contains the given language,
* otherwise false.
*
* @param {string} language - A BCP-47 language tag or prefix.
* @returns {boolean}
*/
#sourceSelectorHasLanguage(language) {
return this.elements.sourceLanguageSelector.querySelector(
`[value^=${language}]`
);
}
/**
* Returns true if the target-language selector contains the given language,
* otherwise false.
*
* @param {string} language - A BCP-47 language tag or prefix.
* @returns {boolean}
*/
#targetSelectorHasLanguage(language) {
return this.elements.targetLanguageSelector.querySelector(
`[value^=${language}]`
);
}
/**
* Determines whether the currently selected source/target languages can be swapped.
*
* @returns {boolean}
*/
#selectedLanguagePairIsSwappable() {
const selectedLanguagePair = this.#getSelectedLanguagePair();
return (
// There is an actively selected language pair.
selectedLanguagePair &&
// The source and target languages are different languages.
selectedLanguagePair.sourceLanguage !==
selectedLanguagePair.targetLanguage &&
// The source language list contains the currently selected target language.
this.#sourceSelectorHasLanguage(selectedLanguagePair.targetLanguage) &&
// The target language list contains the currently selected source language.
this.#targetSelectorHasLanguage(selectedLanguagePair.sourceLanguage)
);
}
/**
* Disables the swap-languages button.
*/
#disableSwapLanguagesButton() {
const { swapLanguagesButton } = this.elements;
if (swapLanguagesButton.disabled === true) {
return;
}
swapLanguagesButton.disabled = true;
dispatchTestEvent("AboutTranslationsTest:SwapLanguagesButtonDisabled");
}
/**
* Enables the swap-languages button.
*/
#enableSwapLanguagesButton() {
const { swapLanguagesButton } = this.elements;
if (swapLanguagesButton.disabled === false) {
return;
}
swapLanguagesButton.disabled = false;
dispatchTestEvent("AboutTranslationsTest:SwapLanguagesButtonEnabled");
}
/**
* Updates the enabled state of the swap-languages button based on the current
* values of the language selectors.
*/
#updateSwapLanguagesButtonEnabledState() {
if (!this.elements.translationErrorMessage.hidden) {
this.#disableSwapLanguagesButton();
return;
}
if (this.#selectedLanguagePairIsSwappable()) {
this.#enableSwapLanguagesButton();
} else {
this.#disableSwapLanguagesButton();
}
}
/**
* Updates the enabled state of the copy button.
*
* @param {boolean} shouldEnable
*/
#setCopyButtonEnabled(shouldEnable) {
const { copyButton } = this.elements;
if (copyButton.disabled !== shouldEnable) {
// The state isn't going to change: nothing to do.
return;
}
if (
this.#copyButtonResetTimeoutId !== null ||
copyButton.classList.contains("copied")
) {
// When the copy button's enabled state changes while it is in the "copied" state,
// then we want to reset it immediately, instead of waiting for the timeout.
this.#resetCopyButton();
}
copyButton.disabled = !shouldEnable;
const eventName = shouldEnable
? "AboutTranslationsTest:CopyButtonEnabled"
: "AboutTranslationsTest:CopyButtonDisabled";
dispatchTestEvent(eventName);
}
/**
* Applies the "copied" state visuals to the copy button.
*/
#showCopyButtonCopiedState() {
const { copyButton } = this.elements;
copyButton.classList.add("copied");
copyButton.iconSrc = "chrome://global/skin/icons/check.svg";
document.l10n.setAttributes(
copyButton,
"about-translations-copy-button-copied"
);
dispatchTestEvent("AboutTranslationsTest:CopyButtonShowCopied");
this.#maybeScheduleCopyButtonReset();
}
/**
* Restores the copy button to its default visual state.
*/
#resetCopyButton() {
if (this.#copyButtonResetTimeoutId !== null) {
window.clearTimeout(this.#copyButtonResetTimeoutId);
this.#copyButtonResetTimeoutId = null;
}
const { copyButton } = this.elements;
if (!copyButton.classList.contains("copied")) {
return;
}
copyButton.classList.remove("copied");
copyButton.iconSrc = "chrome://global/skin/icons/edit-copy.svg";
document.l10n.setAttributes(
copyButton,
"about-translations-copy-button-default"
);
dispatchTestEvent("AboutTranslationsTest:CopyButtonReset");
}
/**
* Returns the copy button reset delay based on user preferences and test overrides.
*
* @returns {number}
*/
#getCopyButtonResetDelay() {
if (this.#copyButtonResetDelayOverride !== null) {
window.COPY_BUTTON_RESET_DELAY = this.#copyButtonResetDelayOverride;
return this.#copyButtonResetDelayOverride;
}
const defaultCopyButtonResetDelay = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches
? COPY_BUTTON_RESET_DELAY_REDUCED_MOTION
: COPY_BUTTON_RESET_DELAY_DEFAULT;
window.COPY_BUTTON_RESET_DELAY = defaultCopyButtonResetDelay;
return defaultCopyButtonResetDelay;
}
/**
* Schedules resetting the copy button back to its default state.
*/
#maybeScheduleCopyButtonReset() {
if (this.#isManualCopyButtonResetEnabled) {
return;
}
if (this.#copyButtonResetTimeoutId !== null) {
window.clearTimeout(this.#copyButtonResetTimeoutId);
this.#copyButtonResetTimeoutId = null;
}
this.#copyButtonResetTimeoutId = window.setTimeout(() => {
this.#resetCopyButton();
}, this.#getCopyButtonResetDelay());
}
/**
* Manually resets the state of the copy button.
* This function is only expected to be called by automated tests.
*/
testResetCopyButton() {
if (!AT_isInAutomation()) {
throw new Error("Test-only function called outside of automation.");
}
if (!this.#isManualCopyButtonResetEnabled) {
throw new Error("Unexpected call to testResetCopyButton.");
}
this.#resetCopyButton();
}
/**
* Enables or disables manual copy button resets for automated tests.
*
* @param {boolean} enabled
*/
testSetManualCopyButtonResetEnabled(enabled) {
if (!AT_isInAutomation()) {
throw new Error("Test-only function called outside of automation.");
}
this.#isManualCopyButtonResetEnabled = enabled;
}
/**
* Sets or clears the copy button reset delay override for automated tests.
*
* @param {number | null} delayMs
*/
testSetCopyButtonResetDelayOverride(delayMs) {
if (!AT_isInAutomation()) {
throw new Error("Test-only function called outside of automation.");
}
this.#copyButtonResetDelayOverride = delayMs;
this.#getCopyButtonResetDelay();
}
/**
* Clears translation-request telemetry throttling for automated tests.
*/
testClearTranslationRequestTelemetryThrottle() {
if (!AT_isInAutomation()) {
throw new Error("Test-only function called outside of automation.");
}
this.#clearTranslationRequestTelemetryThrottle();
}
/**
* If the currently selected language pair is determined to be swappable,
* swaps the active source language with the active target language,
* and moves the translated output to be the new source text.
*/
#maybeSwapLanguages() {
if (!this.#selectedLanguagePairIsSwappable()) {
return;
}
const {
sourceLanguageSelector,
targetLanguageSelector,
sourceSectionTextArea,
targetSectionTextArea,
} = this.elements;
const selectedLanguagePair = this.#getSelectedLanguagePair();
sourceLanguageSelector.value = selectedLanguagePair.targetLanguage;
targetLanguageSelector.value = selectedLanguagePair.sourceLanguage;
sourceSectionTextArea.value = targetSectionTextArea.value;
this.#updateSourceScriptDirection();
this.#updateTargetScriptDirection();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
this.#updateSourceSectionClearButtonVisibility();
if (sourceSectionTextArea.value) {
this.#displayTranslatingPlaceholder();
}
// When swapping languages, the target language will become the source language,
// therefore the "detect" option is guaranteed to not be selected, and we need
// to ensure that we reset the option text back to the default in case the user
// opens the source-text dropdown menu.
this.#resetDetectLanguageOptionText();
}
/**
* Attempts to identify the BCP-47 language tag of the source text.
* Returns an empty string if no language could be identified.
*
* @returns {string} The BCP-47 language tag of the identified language.
*/
async #identifySourceLanguage() {
const sourceText = this.#getSourceText();
if (!sourceText) {
return "";
}
const { language } = await AT_identifyLanguage(sourceText);
if (!language) {
return "";
}
if (language === "zh") {
// CLD2 detects simplified Chinese as "zh" and traditional Chinese as "zh-Hant".
// But we need to be explicit about the script tag in both cases.
return "zh-Hans";
}
return language;
}
/**
* Returns the trimmed value of the source <textarea>.
*
* @returns {string}
*/
#getSourceText() {
return this.elements.sourceSectionTextArea.value.trim();
}
/**
* Returns true if the source textarea contains any text, otherwise false.
*
* @returns {boolean}
*/
#sourceTextAreaHasValue() {
return Boolean(this.elements.sourceSectionTextArea.value);
}
/**
* Returns the configured throttle delay for translation-request telemetry.
*
* @returns {number}
*/
#getTranslationRequestTelemetryThrottleDelay() {
const delay = Number(window.TRANSLATION_REQUEST_TELEMETRY_THROTTLE_DELAY);
return Number.isFinite(delay) && delay >= 0 ? delay : 5_000;
}
/**
* Clears the active translation-request telemetry throttle timer.
*/
#clearTranslationRequestTelemetryThrottle() {
if (this.#translationRequestTelemetryThrottleTimeoutId !== null) {
clearTimeout(this.#translationRequestTelemetryThrottleTimeoutId);
this.#translationRequestTelemetryThrottleTimeoutId = null;
}
}
/**
* Determines whether translation-request telemetry should be recorded now.
*
* @returns {boolean}
*/
#shouldRecordTranslationRequestTelemetry() {
if (this.#translationRequestTelemetryThrottleTimeoutId !== null) {
return false;
}
this.#translationRequestTelemetryThrottleTimeoutId = setTimeout(() => {
this.#translationRequestTelemetryThrottleTimeoutId = null;
}, this.#getTranslationRequestTelemetryThrottleDelay());
return true;
}
/**
* Counts the words in the text for the given language tag.
*
* @param {string} text - The text for which to count words.
* @param {string | null} [languageTag=null] - An optional BCP-47 language tag.
*
* @returns {number | null} The count of words in the text, or null when no language tag is available.
*/
#countWords(text, languageTag = null) {
if (!languageTag) {
return null;
}
let segmenter = this.#wordCountSegmenter;
if (
// We have not yet cached a word segmenter.
!segmenter ||
// Our cached segmenter no longer matches the source language.
this.#wordCountSegmenterLanguageTag !== languageTag
) {
segmenter = new Intl.Segmenter(languageTag, { granularity: "word" });
this.#wordCountSegmenter = segmenter;
this.#wordCountSegmenterLanguageTag = languageTag;
}
const segments = Array.from(segmenter.segment(text));
return segments.filter(segment => segment.isWordLike).length;
}
/**
* Records a translation-request telemetry event when the throttle allows it.
*
* @param {object} data
* @param {string} data.sourceLanguage
* @param {string} data.targetLanguage
* @param {string} data.sourceText
*/
async #maybeRecordTranslationRequestTelemetry({
sourceLanguage,
targetLanguage,
sourceText,
}) {
if (!this.#shouldRecordTranslationRequestTelemetry()) {
return;
}
let sourceTextWordCount;
try {
sourceTextWordCount = this.#countWords(sourceText, sourceLanguage);
} catch (error) {
AT_logError(error);
}
try {
AT_telemetry("onTranslate", {
autoTranslate: false,
sourceLanguage,
targetLanguage,
sourceTextCodeUnits: sourceText.length,
sourceTextWordCount,
});
} catch (error) {
AT_logError(error);
}
}
/**
* Records an unsupported-language-message telemetry event.
*/
#recordUnsupportedLanguageMessageTelemetry() {
const sourceText = this.#getSourceText();
const detectedLanguage = this.#detectedLanguage;
let sourceTextWordCount;
try {
sourceTextWordCount = this.#countWords(sourceText, detectedLanguage);
} catch (error) {
AT_logError(error);
}
try {
AT_telemetry("onUnsupportedLanguageMessage", {
detectedLanguage,
sourceTextCodeUnits: sourceText.length,
sourceTextWordCount,
});
} catch (error) {
AT_logError(error);
}
}
/**
* Shows or hides the source clear button based on whether the textarea has text.
*/
#updateSourceSectionClearButtonVisibility() {
const { sourceSectionClearButton } = this.elements;
const shouldShow = this.#sourceTextAreaHasValue();
const isHidden = sourceSectionClearButton.hidden;
const shouldHide = !shouldShow;
const isShown = !isHidden;
if (shouldShow && isHidden) {
sourceSectionClearButton.hidden = false;
dispatchTestEvent("AboutTranslationsTest:SourceTextClearButtonShown");
} else if (shouldHide && isShown) {
sourceSectionClearButton.hidden = true;
dispatchTestEvent("AboutTranslationsTest:SourceTextClearButtonHidden");
}
}
/**
* Clears the content in the source-section text area.
*/
#clearSourceText() {
AT_clearSourceText();
this.elements.sourceSectionTextArea.focus();
dispatchTestEvent("AboutTranslationsTest:ClearSourceText");
}
/**
* Sets the value of the source <textarea> and dispatches an input event.
*
* @param {string} value
*/
#setSourceText(value) {
const { sourceSectionTextArea } = this.elements;
const hadValueBefore = Boolean(sourceSectionTextArea.value);
sourceSectionTextArea.value = value;
this.#updateSourceScriptDirection();
this.#maybeUpdateDetectedSourceLanguage();
this.#updateSourceSectionClearButtonVisibility();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
if (!value && hadValueBefore) {
this.#clearTranslationRequestTelemetryThrottle();
dispatchTestEvent("AboutTranslationsTest:ClearSourceText");
}
sourceSectionTextArea.dispatchEvent(new Event("input"));
}
/**
* Sets the value of the target <textarea>.
*
* @param {string} value
* @param {object} [options]
* @param {boolean} [options.isTranslationResult=false]
* True if the value is the result of a translation request, otherwise false.
*/
#setTargetText(value, { isTranslationResult = false } = {}) {
this.elements.targetSectionTextArea.value = value;
if (!value) {
dispatchTestEvent("AboutTranslationsTest:ClearTargetText");
}
this.#updateTargetScriptDirection();
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
this.#setCopyButtonEnabled(Boolean(value) && isTranslationResult);
}
/**
* Displays the translating placeholder text if the conditions are correct to do so.
* We do this when switching to a new language pair, when rebuilding the translator,
* or if the text is long enough that translating may incur a significant wait time.
*/
#maybeDisplayTranslatingPlaceholder() {
const sourceText = this.#getSourceText();
if (
// We will need to request a new translator, which will take time.
!this.#translatorMatchesSelectedLanguagePair() ||
// The current translator matches, but we need to request a new port, which takes time.
this.#translator?.portClosed ||
// The source text is long enough to take noticeable time to translate,
// and has changed enough from the previous source text to warrant a placeholder.
(sourceText.length > 5000 &&
Math.abs(sourceText.length - this.#previousSourceText.length) > 500)
) {
this.#displayTranslatingPlaceholder();
}
this.#previousSourceText = sourceText;
}
/**
* Shows the translating placeholder in the target textarea and disables
* the swap-languages button while translation is in progress.
*/
#displayTranslatingPlaceholder() {
const { targetSectionTextArea } = this.elements;
this.#setTargetText(this.#translatingPlaceholderText);
this.#disableSwapLanguagesButton();
this.#updateTextAreaAttributes(targetSectionTextArea, {
languageTag: AT_getAppLocaleAsBCP47(),
});
dispatchTestEvent("AboutTranslationsTest:ShowTranslatingPlaceholder");
}
/**
* Returns true if the current {@link Translator} instance is sufficient
* to translate the actively selected language pair, otherwise false.
*
* @returns {boolean}
*/
#translatorMatchesSelectedLanguagePair() {
if (!this.#translator) {
// There is no active translator, therefore the language pair cannot match it.
return false;
}
const selectedLanguagePair = this.#getSelectedLanguagePair();
if (!selectedLanguagePair) {
// No valid language pair is selected, therefore it cannot match the translator.
return false;
}
return this.#translator.matchesLanguagePair(selectedLanguagePair);
}
/**
* Reads the current URL hash and updates the UI to reflect the parameters.
* Invalid parameters are ignored.
*/
#updateUIFromURL() {
const supportedLanguages = this.#supportedLanguages;
const urlParams = new URLSearchParams(window.location.hash.slice(1));
const sourceLanguage = urlParams.get("src");
const targetLanguage = urlParams.get("trg");
const sourceText = urlParams.get("text") ?? "";
const { sourceLanguageSelector, targetLanguageSelector } = this.elements;
const previousSourceLanguage = sourceLanguageSelector.value;
const previousTargetLanguage = targetLanguageSelector.value;
sourceLanguageSelector.value = "detect";
targetLanguageSelector.value = "";
if (
sourceLanguage !== "detect" &&
supportedLanguages.sourceLanguages.some(
({ langTagKey }) => langTagKey === sourceLanguage
)
) {
sourceLanguageSelector.value = sourceLanguage;
}
if (
supportedLanguages.targetLanguages.some(
({ langTagKey }) => langTagKey === targetLanguage
)
) {
targetLanguageSelector.value = targetLanguage;
}
if (
sourceLanguageSelector.value !== previousSourceLanguage ||
targetLanguageSelector.value !== previousTargetLanguage
) {
this.#clearTranslationRequestTelemetryThrottle();
}
this.#setSourceText(sourceText);
}
/**
* Updates the URL hash to reflect the current state of the UI.
*/
#updateURLFromUI() {
const { sourceLanguageSelector, targetLanguageSelector } = this.elements;
const params = new URLSearchParams();
const sourceLanguage = sourceLanguageSelector.value;
if (sourceLanguage) {
params.append("src", sourceLanguage);
}
const targetLanguage = targetLanguageSelector.value;
if (targetLanguage) {
params.append("trg", targetLanguage);
}
const sourceText = this.#getSourceText().substring(0, URL_MAX_TEXT_LENGTH);
if (sourceText) {
params.append("text", sourceText);
}
const hashFromUIState = params.toString();
const currentHash = window.location.hash.slice(1);
if (hashFromUIState !== currentHash) {
const url = new URL(window.location.href);
url.hash = hashFromUIState;
this.#urlHashesFromUIState.add(hashFromUIState);
try {
window.location.replace(url.href);
} catch (error) {
this.#urlHashesFromUIState.delete(hashFromUIState);
throw error;
}
}
dispatchTestEvent("AboutTranslationsTest:URLUpdatedFromUI", {
sourceLanguage,
targetLanguage,
sourceText,
});
}
/**
* Updates a textarea's `dir` and `lang` attributes according to the provided language tag.
*
* The `dir` attribute is always derived and set from the provided language tag,
* however there are cases in which we may want to remove the `lang` attribute
* from the textarea, rather than populate it with the tag's value.
*
* @param {HTMLTextAreaElement} textArea
* @param {object} config
* @param {string} config.languageTag
* @param {boolean} [config.removeLangAttribute=false]
*/
#updateTextAreaAttributes(
textArea,
{ languageTag, removeLangAttribute = false }
) {
textArea.setAttribute("dir", AT_getScriptDirection(languageTag));
if (removeLangAttribute) {
textArea.removeAttribute("lang");
} else {
textArea.setAttribute("lang", languageTag);
}
}
/**
* Sets the source text area's `dir` and `lang` attributes based on its
* content and the currently selected source language.
*/
#updateSourceScriptDirection() {
const selectedSourceLanguage = this.#getSelectedSourceLanguageTag();
const { sourceSectionTextArea } = this.elements;
if (selectedSourceLanguage && sourceSectionTextArea.value) {
this.#updateTextAreaAttributes(sourceSectionTextArea, {
languageTag: selectedSourceLanguage,
});
} else {
this.#updateTextAreaAttributes(sourceSectionTextArea, {
languageTag: AT_getAppLocaleAsBCP47(),
removeLangAttribute: true,
});
}
}
/**
* Sets the target text area's `dir` and `lang` attributes based on its
* content and the currently selected target language.
*/
#updateTargetScriptDirection() {
const selectedTargetLanguage = this.#getSelectedTargetLanguageTag();
const { targetSectionTextArea } = this.elements;
if (selectedTargetLanguage && targetSectionTextArea.value) {
this.#updateTextAreaAttributes(targetSectionTextArea, {
languageTag: selectedTargetLanguage,
});
} else {
this.#updateTextAreaAttributes(targetSectionTextArea, {
languageTag: AT_getAppLocaleAsBCP47(),
removeLangAttribute: true,
});
}
}
/**
* Ensures that the translator matches the selected language pair,
* destroying the previous non-matching translator if needed, and
* creating a new translator.
*
* @returns {Promise<boolean>} true if the translator was reassigned, otherwise false.
*/
async #ensureTranslatorMatchesSelectedLanguagePair() {
if (this.#translatorMatchesSelectedLanguagePair()) {
return false;
}
this.#translator?.destroy();
const selectedLanguagePair = this.#getSelectedLanguagePair();
this.#translator = await Translator.create({
languagePair: selectedLanguagePair,
requestTranslationsPort,
allowSameLanguage: true,
activeRequestCapacity: 1,
});
return true;
}
/**
* Destroys the active {@link Translator} instance, if any.
*/
#destroyTranslator() {
if (this.#translator) {
this.#translator.destroy();
this.#translator = null;
}
}
/**
* Rebuilds the translator unconditionally and immediately re-evaluates whether
* a translation request should be made.
*/
rebuildTranslator() {
this.#destroyTranslator();
this.#maybeRequestTranslation();
}
/**
* Schedules reactions to input in the source text area in three categories
* with different considerations for the timing in which the actions are triggered.
*/
#handleSourceTextInput = scheduleInputHandling({
/**
* These actions happen every time input is received to the source text area.
*
* These actions should be cheap to call, and of high importance: things that we
* likely want to react to immediately, such as clearing the entirety of the text.
*/
doEveryTime: () => {
if (!this.#isFeatureEnabled) {
return;
}
const sourceText = this.#getSourceText();
performance.mark(
`Translations: input changed to ${sourceText.length} code units.`
);
if (!sourceText) {
this.#clearTranslationRequestTelemetryThrottle();
this.#setTargetText("");
if (this.#isDetectLanguageSelected()) {
this.#resetDetectLanguageOptionText();
}
this.#updateURLFromUI();
this.#updateSwapLanguagesButtonEnabledState();
this.#hideDetectedLanguageUnsupportedMessage();
}
this.#updateSourceScriptDirection();
},
/**
* These actions are throttled, such that they occur frequently while the text area is receiving input,
* but they will not occur every time the text area receives input.
*
* These actions may be more expensive to call than do-every-time actions, but are still necessary for
* making the UI appear fluid and reactive to input, such as updating the height of the sections, or
* updating the detected language of the text.
*/
onThrottle: async () => {
if (!this.#isFeatureEnabled) {
return;
}
try {
this.#requestSectionHeightsUpdate({ scheduleCallback: false });
await this.#maybeUpdateDetectedSourceLanguage();
this.#updateDetectedLanguageUnsupportedMessage();
} catch (error) {
AT_logError(error);
}
},
/**
* These actions are debounced, such that they occur only when active input to the text area has stopped
* for a determined amount of time.
*
* These are the most expensive actions to call, such as requesting translations, which we should only invoke
* when we're reasonably certain that the user has completed their source-text input for the moment.
*/
onDebounce: async () => {
const sourceText = this.#getSourceText();
dispatchTestEvent("AboutTranslationsTest:SourceTextInputDebounced", {
sourceText,
});
if (sourceText) {
this.#updateURLFromUI();
}
await this.#maybeRequestTranslation();
},
});
/**
* Requests translation when the UI conditions are met.
*
* @param {object} [options]
* @param {boolean} [options.allowFromErrorState=false]
* Allow a translation request when a translation error message is visible, such as retrying
* after a failure or switching languages while an error is showing.
* @returns {Promise<void>}
*/
async #maybeRequestTranslation({ allowFromErrorState = false } = {}) {
if (!this.#isFeatureEnabled) {
return;
}
let translationId = null;
try {
await this.#maybeUpdateDetectedSourceLanguage();
this.#updateDetectedLanguageUnsupportedMessage();
const sourceText = this.#getSourceText();
const selectedLanguagePair = this.#getSelectedLanguagePair();
if (!sourceText || !selectedLanguagePair) {
// The conditions for translation are not met.
this.#setTargetText("");
this.#destroyTranslator();
this.#updateSwapLanguagesButtonEnabledState();
this.#hideTranslationErrorMessage();
return;
}
if (this.#isDetectedLanguageUnsupported()) {
this.#updateSwapLanguagesButtonEnabledState();
this.#setTargetText("");
this.#hideTranslationErrorMessage();
return;
}
if (
!this.elements.translationErrorMessage.hidden &&
!allowFromErrorState
) {
return;
}
translationId = ++this.#translationId;
this.#hideTranslationErrorMessage();
this.#maybeDisplayTranslatingPlaceholder();
await this.#ensureTranslatorMatchesSelectedLanguagePair();
if (translationId !== this.#translationId) {
// This translation request is no longer relevant.
return;
}
dispatchTestEvent("AboutTranslationsTest:TranslationRequested", {
translationId,
});
void this.#maybeRecordTranslationRequestTelemetry({
sourceLanguage: selectedLanguagePair.sourceLanguage,
targetLanguage: selectedLanguagePair.targetLanguage,
sourceText,
});
const startTime = performance.now();
const translationRequest = this.#translator.translate(
sourceText,
AT_isHtmlTranslation()
);
const translatedText = await translationRequest;
if (translationId !== this.#translationId) {
// This translation request is no longer relevant.
return;
}
performance.measure(
`AboutTranslations: Translate ${selectedLanguagePair.sourceLanguage} → ${selectedLanguagePair.targetLanguage} with ${sourceText.length} characters.`,
{
start: startTime,
end: performance.now(),
}
);
this.#setTargetText(translatedText, { isTranslationResult: true });
this.#hideTranslationErrorMessage();
this.#updateSwapLanguagesButtonEnabledState();
dispatchTestEvent("AboutTranslationsTest:TranslationComplete", {
translationId,
});
const duration = performance.now() - startTime;
AT_log(`Translation done in ${duration / 1000} seconds`);
} catch (error) {
AT_logError(error);
if (translationId === null || translationId !== this.#translationId) {
return;
}
this.#setTargetText("");
this.#showTranslationErrorMessage();
this.#updateSwapLanguagesButtonEnabledState();
}
}
/**
* Requests that the heights of each section is updated to at least match its content.
*
* In horizontal orientation the source and target sections are synchronized to
* the maximum content height between the two. In vertical orientation, each section
* is sized to its own content height.
*
* There are many situations in which this function needs to be called:
* - Every time the source text is updated
* - Every time a translation occurs
* - Every time the window is resized
* - Every time the zoom level is changed
* - Etc.
*
* Some of these events happen infrequently, or are already debounced, such as
* each time a translation occurs. In these situations it is okay to update
* the section heights immediately.
*
* Some of these events can trigger quite rapidly, such as resizing the window
* via click-and-drag semantics. In this case, a single callback should be scheduled
* to update the section heights to prevent unnecessary and excessive reflow.
*
* @param {object} params
* @param {boolean} params.scheduleCallback
*/
#requestSectionHeightsUpdate({ scheduleCallback }) {
if (scheduleCallback) {
if (this.#updateSectionHeightsTimeoutId) {
// There is already a pending callback: no need to schedule another.
return;
}
this.#updateSectionHeightsTimeoutId = setTimeout(
this.#updateSectionHeights,
100
);
return;
}
this.#updateSectionHeights();
}
/**
* Updates the section heights based on the current page orientation.
*
* This function is intentionally written as a lambda so that it can be passed
* as a callback without the need to explicitly bind `this` to the function object.
*/
#updateSectionHeights = () => {
if (this.#updateSectionHeightsTimeoutId) {
clearTimeout(this.#updateSectionHeightsTimeoutId);
this.#updateSectionHeightsTimeoutId = null;
}
if (this.#pageOrientation === "horizontal") {
this.#synchronizeSectionsToMaxContentHeight();
} else {
this.#resizeSectionsToIndividualContentHeights();
}
};
/**
* Retrieves the combined border and padding height of an element.
*
* Returns 0 when the element is missing or does not use border-box sizing.
*
* @param {HTMLElement | undefined} element
*
* @returns {number}
*/
#getBorderAndPaddingHeight(element) {
if (!element) {
return 0;
}
const style = window.getComputedStyle(element);
if (style.boxSizing !== "border-box") {
return 0;
}
const borderTop = Number.parseFloat(style.borderTopWidth) || 0;
const borderBottom = Number.parseFloat(style.borderBottomWidth) || 0;
const paddingTop = Number.parseFloat(style.paddingTop) || 0;
const paddingBottom = Number.parseFloat(style.paddingBottom) || 0;
return Math.ceil(borderTop + borderBottom + paddingTop + paddingBottom);
}
/**
* Retrieves the minimum height of an element.
*
* @param {HTMLElement | undefined} element
*
* @returns {number}
*/
#getMinHeight(element) {
if (!element) {
return 0;
}
const style = window.getComputedStyle(element);
const minHeight = Number.parseFloat(style.minHeight);
if (Number.isNaN(minHeight)) {
return 0;
}
return Math.max(minHeight - this.#getBorderAndPaddingHeight(element), 0);
}
/**
* Returns whether a section height increased or decreased.
*
* @param {number} beforeHeight
* @param {number} afterHeight
* @returns {null | "decreased" | "increased"}
*/
#getSectionHeightChange(beforeHeight, afterHeight) {
const changeThreshold = 1;
if (!Number.isFinite(beforeHeight) || beforeHeight <= changeThreshold) {
return null;
}
const delta = afterHeight - beforeHeight;
if (Math.abs(delta) <= changeThreshold) {
return null;
}
return delta < 0 ? "decreased" : "increased";
}
/**
* Dispatches a test event when section height changes are detected.
*
* @param {object} params
* @param {null | "decreased" | "increased"} params.sourceSectionHeightChange
* @param {null | "decreased" | "increased"} params.targetSectionHeightChange
*/
#dispatchSectionHeightsChangedEvent({
sourceSectionHeightChange,
targetSectionHeightChange,
}) {
if (!sourceSectionHeightChange && !targetSectionHeightChange) {
return;
}
dispatchTestEvent("AboutTranslationsTest:SectionHeightsChanged", {
sourceSectionHeightChange: sourceSectionHeightChange ?? "unchanged",
targetSectionHeightChange: targetSectionHeightChange ?? "unchanged",
});
}
/**
* Calculates the content heights in both the source and target sections,
* then syncs them to the maximum calculated content height among the two.
*
* Prefer calling #requestSectionHeightsUpdate to make it clear whether this function
* needs to run immediately, or is okay to be scheduled as a callback.
*
* @see {AboutTranslations#requestSectionHeightsUpdate}
*/
#synchronizeSectionsToMaxContentHeight() {
const {
detectedLanguageUnsupportedMessage,
sourceSection,
sourceSectionTextArea,
targetSection,
targetSectionTextArea,
translationErrorMessage,
} = this.elements;
const sourceSectionHeightBefore = Number.parseFloat(
sourceSection?.style.height
);
const targetSectionHeightBefore = Number.parseFloat(
targetSection?.style.height
);
sourceSection.style.height = "auto";
targetSection.style.height = "auto";
sourceSectionTextArea.style.height = "auto";
targetSectionTextArea.style.height = "auto";
const detectedLanguageUnsupportedHeight =
detectedLanguageUnsupportedMessage?.getBoundingClientRect().height ?? 0;
const translationErrorHeight =
translationErrorMessage?.getBoundingClientRect().height ?? 0;
const minSectionHeight = AboutTranslations.#maxInteger(
this.#getMinHeight(sourceSection),
this.#getMinHeight(targetSection)
);
const sourceSectionContentHeight =
sourceSectionTextArea.scrollHeight + detectedLanguageUnsupportedHeight;
const targetSectionContentHeight =
targetSectionTextArea.scrollHeight + translationErrorHeight;
const maxContentHeight = AboutTranslations.#maxInteger(
sourceSectionContentHeight,
targetSectionContentHeight,
minSectionHeight
);
const sectionBorderHeight = AboutTranslations.#maxInteger(
this.#getBorderAndPaddingHeight(sourceSection),
this.#getBorderAndPaddingHeight(targetSection)
);
const maxSectionHeight = maxContentHeight + sectionBorderHeight;
const maxSectionHeightPixels = `${maxSectionHeight}px`;
const sourceSectionTextAreaHeightPixels = `${Math.max(
maxContentHeight - detectedLanguageUnsupportedHeight,
0
)}px`;
const targetSectionTextAreaHeightPixels = `${Math.max(
maxContentHeight - translationErrorHeight,
0
)}px`;
const sourceSectionHeightChange = this.#getSectionHeightChange(
sourceSectionHeightBefore,
maxSectionHeight
);
const targetSectionHeightChange = this.#getSectionHeightChange(
targetSectionHeightBefore,
maxSectionHeight
);
sourceSection.style.height = maxSectionHeightPixels;
targetSection.style.height = maxSectionHeightPixels;
sourceSectionTextArea.style.height = sourceSectionTextAreaHeightPixels;
targetSectionTextArea.style.height = targetSectionTextAreaHeightPixels;
this.#dispatchSectionHeightsChangedEvent({
sourceSectionHeightChange,
targetSectionHeightChange,
});
}
/**
* Calculates the content heights in both the source and target sections,
* then sizes each section to its own calculated content height.
*
* Prefer calling #requestSectionHeightsUpdate to make it clear whether this function
* needs to run immediately, or is okay to be scheduled as a callback.
*
* @see {AboutTranslations#requestSectionHeightsUpdate}
*/
#resizeSectionsToIndividualContentHeights() {
const {
detectedLanguageUnsupportedMessage,
sourceSection,
sourceSectionTextArea,
targetSection,
targetSectionTextArea,
translationErrorMessage,
} = this.elements;
const sourceSectionHeightBefore = Number.parseFloat(
sourceSection?.style.height
);
const targetSectionHeightBefore = Number.parseFloat(
targetSection?.style.height
);
sourceSection.style.height = "auto";
targetSection.style.height = "auto";
sourceSectionTextArea.style.height = "auto";
targetSectionTextArea.style.height = "auto";
const detectedLanguageUnsupportedHeight =
detectedLanguageUnsupportedMessage?.getBoundingClientRect().height ?? 0;
const translationErrorHeight =
translationErrorMessage?.getBoundingClientRect().height ?? 0;
const sourceMinHeight = this.#getMinHeight(sourceSection);
const targetMinHeight = this.#getMinHeight(targetSection);
const sourceContentHeight = AboutTranslations.#maxInteger(
sourceSectionTextArea.scrollHeight + detectedLanguageUnsupportedHeight,
sourceMinHeight
);
const targetContentHeight = AboutTranslations.#maxInteger(
targetSectionTextArea.scrollHeight + translationErrorHeight,
targetMinHeight
);
const sourceSectionHeight =
sourceContentHeight + this.#getBorderAndPaddingHeight(sourceSection);
const targetSectionHeight =
targetContentHeight + this.#getBorderAndPaddingHeight(targetSection);
const sourceSectionTextAreaHeightPixels = `${Math.max(
sourceContentHeight - detectedLanguageUnsupportedHeight,
0
)}px`;
const targetSectionTextAreaHeightPixels = `${Math.max(
targetContentHeight - translationErrorHeight,
0
)}px`;
const sourceSectionHeightChange = this.#getSectionHeightChange(
sourceSectionHeightBefore,
sourceSectionHeight
);
const targetSectionHeightChange = this.#getSectionHeightChange(
targetSectionHeightBefore,
targetSectionHeight
);
sourceSection.style.height = `${sourceSectionHeight}px`;
targetSection.style.height = `${targetSectionHeight}px`;
sourceSectionTextArea.style.height = sourceSectionTextAreaHeightPixels;
targetSectionTextArea.style.height = targetSectionTextAreaHeightPixels;
this.#dispatchSectionHeightsChangedEvent({
sourceSectionHeightChange,
targetSectionHeightChange,
});
}
}
/**
* A promise used to ensure the about:translations page initializes once.
*
* @type {Promise<void> | null}
*/
let aboutTranslationsInitPromise = null;
/**
* Initializes the about:translations page if it has not been initialized yet.
*
* @returns {Promise<void>}
*/
async function ensureAboutTranslationsInitialized() {
if (aboutTranslationsInitPromise) {
return aboutTranslationsInitPromise;
}
aboutTranslationsInitPromise = AT_isTranslationEngineSupported()
.then(async isTranslationsEngineSupported => {
window.aboutTranslations = new AboutTranslations(
isTranslationsEngineSupported
);
await window.aboutTranslations.ready();
})
.catch(error => {
AT_logError(error);
});
return aboutTranslationsInitPromise;
}
/**
* Marks that the document is ready for automated testing to being making assertions.
*
* @returns {void}
*/
function markReadyForTesting() {
document.body.setAttribute("ready-for-testing", "");
}
/**
* Whether the initial enabled state has been applied.
*
* @type {boolean}
*/
let initialEnabledStateApplied = false;
/**
* Applies the enabled state to the about:translations UI.
*
* @param {boolean} enabled
* @returns {Promise<void>}
*/
async function setFeatureEnabledState(enabled) {
await ensureAboutTranslationsInitialized();
if (!window.aboutTranslations) {
return;
}
if (enabled) {
await window.aboutTranslations.onFeatureEnabled();
} else {
await window.aboutTranslations.onFeatureDisabled();
}
if (initialEnabledStateApplied) {
dispatchTestEvent("AboutTranslationsTest:EnabledStateChanged", { enabled });
}
initialEnabledStateApplied = true;
markReadyForTesting();
}
/**
* Listen for events coming from the AboutTranslations actor.
*/
window.addEventListener("AboutTranslationsChromeToContent", ({ detail }) => {
switch (detail.type) {
case "enabled-state-changed": {
void setFeatureEnabledState(detail.enabled);
break;
}
case "rebuild-translator": {
window.aboutTranslations?.rebuildTranslator();
break;
}
default: {
throw new Error("Unknown AboutTranslationsChromeToContent event.");
}
}
});
/**
* Creates a new message port to handle translation requests for the given
* language pair.
*
* @param {LanguagePair} languagePair
* @returns {Promise<MessagePort>}
*/
function requestTranslationsPort(languagePair) {
const { promise, resolve } = Promise.withResolvers();
/** @param {{data: {type:string, languagePair: LanguagePair, port: MessagePort}}} */
const getResponse = ({ data }) => {
if (
data.type == "GetTranslationsPort" &&
data.languagePair.sourceLanguage === languagePair.sourceLanguage &&
data.languagePair.targetLanguage === languagePair.targetLanguage &&
data.languagePair.sourceVariant == languagePair.sourceVariant &&
data.languagePair.targetVariant == languagePair.targetVariant
) {
window.removeEventListener("message", getResponse);
resolve(data.port);
}
};
window.addEventListener("message", getResponse);
AT_createTranslationsPort(languagePair);
return promise;
}
/**
* Creates an input handler that runs callbacks immediately, on a throttle interval,
* and after input has been idle for a debounce delay.
*
* @param {object} settings
* @param {Function} settings.doEveryTime
* Runs for every input event.
* @param {Function} settings.onThrottle
* Runs at most once per throttle interval while input continues.
* @param {Function} settings.onDebounce
* Runs once after input has been idle for the debounce delay.
* @returns {Function}
*/
function scheduleInputHandling({ doEveryTime, onThrottle, onDebounce }) {
/** @type {number | null} */
let debounceTimeoutId = null;
/** @type {Promise<void> | null} */
let throttlePromise = null;
/** @type {object[]} */
let latestArgs = [];
return (...args) => {
latestArgs = args;
doEveryTime(...args);
if (!throttlePromise) {
const { promise, resolve } = Promise.withResolvers();
throttlePromise = promise
.then(() => onThrottle(...latestArgs))
.finally(() => {
throttlePromise = null;
});
setTimeout(resolve, window.THROTTLE_DELAY);
}
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => {
debounceTimeoutId = null;
onDebounce(...latestArgs);
}, window.DEBOUNCE_DELAY);
};
}