/* 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. * * https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html */ 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} */ #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} */ #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} */ async ready() { await this.#readyPromiseWithResolvers.promise; } /** * Enables the feature and updates the UI to match the enabled state. * * @returns {Promise} */ 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} */ 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