selectTranslationsPanel.js (78085B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 /** 6 * @typedef {import("../../../../toolkit/components/translations/translations").SelectTranslationsPanelState} SelectTranslationsPanelState 7 * @typedef {import("../../../../toolkit/components/translations/translations").LanguagePair} LanguagePair 8 */ 9 10 ChromeUtils.defineESModuleGetters(this, { 11 LanguageDetector: 12 "resource://gre/modules/translations/LanguageDetector.sys.mjs", 13 TranslationsPanelShared: 14 "chrome://browser/content/translations/TranslationsPanelShared.sys.mjs", 15 TranslationsUtils: 16 "chrome://global/content/translations/TranslationsUtils.mjs", 17 // NOTE: Translator.mjs is missing but should be unused. tor-browser#44045. 18 Translator: "chrome://global/content/translations/Translator.mjs", 19 }); 20 21 XPCOMUtils.defineLazyServiceGetter( 22 this, 23 "ClipboardHelper", 24 "@mozilla.org/widget/clipboardhelper;1", 25 Ci.nsIClipboardHelper 26 ); 27 28 XPCOMUtils.defineLazyServiceGetter( 29 this, 30 "GfxInfo", 31 "@mozilla.org/gfx/info;1", 32 Ci.nsIGfxInfo 33 ); 34 35 /** 36 * This singleton class controls the SelectTranslations panel. 37 * 38 * A global instance of this class is created once per top ChromeWindow and is initialized 39 * when the context menu is opened in that window. 40 * 41 * See the comment above TranslationsParent for more details. 42 * 43 * @see TranslationsParent 44 */ 45 var SelectTranslationsPanel = new (class { 46 /** @type {Console?} */ 47 #console; 48 49 /** 50 * Lazily get a console instance. Note that this script is loaded in very early to 51 * the browser loading process, and may run before the console is available. In 52 * this case the console will return as `undefined`. 53 * 54 * @returns {Console | void} 55 */ 56 get console() { 57 if (!this.#console) { 58 try { 59 this.#console = console.createInstance({ 60 maxLogLevelPref: "browser.translations.logLevel", 61 prefix: "Translations", 62 }); 63 } catch { 64 // The console may not be initialized yet. 65 } 66 } 67 return this.#console; 68 } 69 70 /** 71 * The textarea height for shorter text. 72 * 73 * @type {string} 74 */ 75 #shortTextHeight = "8em"; 76 77 /** 78 * Retrieves the read-only textarea height for shorter text. 79 * 80 * @see #shortTextHeight 81 */ 82 get shortTextHeight() { 83 return this.#shortTextHeight; 84 } 85 86 /** 87 * The textarea height for shorter text. 88 * 89 * @type {string} 90 */ 91 #longTextHeight = "16em"; 92 93 /** 94 * The alignment position value of the panel when it opened. 95 * 96 * We want to cache this value because some alignments, such as "before_start" 97 * and "before_end" will cause the panel to expand upward from the top edge 98 * when the user is trying to resize the text-area by dragging the resizer downward. 99 * 100 * Knowing this value helps us determine if we should disable the textarea resizer 101 * based on how and where the panel was opened. 102 * 103 * @see #maybeEnableTextAreaResizer 104 */ 105 #alignmentPosition = ""; 106 107 /** 108 * A value to cache the most recent state that caused the panel's UI to update. 109 * 110 * The event-driven nature of this code can sometimes make redundant calls to 111 * idempotent UI updates, however the telemetry data is not idempotent and will 112 * be double counted. 113 * 114 * This value allows us to avoid double-counting telemetry if we're making a 115 * redundant call to a UI update. 116 * 117 * @type {string} 118 */ 119 #mostRecentUIPhase = "closed"; 120 121 /** 122 * A cached value for the count of words in the source text as determined by the Intl.Segmenter 123 * for the currently selected from-language, which is reported to telemetry. This prevents us 124 * from having to allocate resource for the segmenter multiple times if the user changes the target 125 * language. 126 * 127 * This value should be invalidated when the panel opens and when the from-language is changed. 128 * 129 * @type {number} 130 */ 131 #sourceTextWordCount = undefined; 132 133 /** 134 * Cached information about the document's detected language and the user's 135 * current language settings, useful for populating telemetry events. 136 * 137 * @type {object} 138 */ 139 #languageInfo = { 140 docLangTag: undefined, 141 isDocLangTagSupported: undefined, 142 topPreferredLanguage: undefined, 143 }; 144 145 /** 146 * Retrieves the read-only textarea height for longer text. 147 * 148 * @see #longTextHeight 149 */ 150 get longTextHeight() { 151 return this.#longTextHeight; 152 } 153 154 /** 155 * The threshold used to determine when the panel should 156 * use the short text-height vs. the long-text height. 157 * 158 * @type {number} 159 */ 160 #textLengthThreshold = 800; 161 162 /** 163 * Retrieves the read-only text-length threshold. 164 * 165 * @see #textLengthThreshold 166 */ 167 get textLengthThreshold() { 168 return this.#textLengthThreshold; 169 } 170 171 /** 172 * The localized placeholder text to display when idle. 173 * 174 * @type {string} 175 */ 176 #idlePlaceholderText; 177 178 /** 179 * The localized placeholder text to display when translating. 180 * 181 * @type {string} 182 */ 183 #translatingPlaceholderText; 184 185 /** 186 * Where the lazy elements are stored. 187 * 188 * @type {Record<string, Element>?} 189 */ 190 #lazyElements; 191 192 /** 193 * Set to true the first time event listeners are initialized. 194 * 195 * @type {boolean} 196 */ 197 #eventListenersInitialized = false; 198 199 /** 200 * This value is true if this page does not allow Full Page Translations, 201 * e.g. PDFs, reader mode, internal Firefox pages. 202 * 203 * Many of these are cases where the SelectTranslationsPanel is available 204 * even though the FullPageTranslationsPanel is not, so this helps inform 205 * whether the translate-full-page button should be allowed in this context. 206 * 207 * @type {boolean} 208 */ 209 #isFullPageTranslationsRestrictedForPage = true; 210 211 /** 212 * The BCP-47 language tag of the active target language for Full-Page Translations, 213 * if available. This may not be available if Full-Page Translations is not currently 214 * active in the current tab of the current window, or if Full-Page Translations is 215 * restricted on the current page. 216 * 217 * @type { string | undefined } 218 */ 219 #activeFullPageTranslationsTargetLanguage = undefined; 220 221 /** 222 * The internal state of the SelectTranslationsPanel. 223 * 224 * @type {SelectTranslationsPanelState} 225 */ 226 #translationState = { phase: "closed" }; 227 228 /** 229 * An Id that increments with each translation, used to help keep track 230 * of whether an active translation request continue its progression or 231 * stop due to the existence of a newer translation request. 232 * 233 * @type {number} 234 */ 235 #translationId = 0; 236 237 /** 238 * Lazily creates the dom elements, and lazily selects them. 239 * 240 * @returns {Record<string, Element>} 241 */ 242 get elements() { 243 if (!this.#lazyElements) { 244 // Lazily turn the template into a DOM element. 245 /** @type {HTMLTemplateElement} */ 246 const wrapper = document.getElementById( 247 "template-select-translations-panel" 248 ); 249 250 const panel = wrapper.content.firstElementChild; 251 wrapper.replaceWith(wrapper.content); 252 253 // Lazily select the elements. 254 this.#lazyElements = { 255 panel, 256 }; 257 258 TranslationsPanelShared.defineLazyElements(document, this.#lazyElements, { 259 cancelButton: "select-translations-panel-cancel-button", 260 copyButton: "select-translations-panel-copy-button", 261 doneButtonPrimary: "select-translations-panel-done-button-primary", 262 doneButtonSecondary: "select-translations-panel-done-button-secondary", 263 fromLabel: "select-translations-panel-from-label", 264 fromMenuList: "select-translations-panel-from", 265 fromMenuPopup: "select-translations-panel-from-menupopup", 266 header: "select-translations-panel-header", 267 initFailureContent: "select-translations-panel-init-failure-content", 268 initFailureMessageBar: 269 "select-translations-panel-init-failure-message-bar", 270 mainContent: "select-translations-panel-main-content", 271 settingsButton: "select-translations-panel-settings-button", 272 textArea: "select-translations-panel-text-area", 273 toLabel: "select-translations-panel-to-label", 274 toMenuList: "select-translations-panel-to", 275 toMenuPopup: "select-translations-panel-to-menupopup", 276 translateButton: "select-translations-panel-translate-button", 277 translateFullPageButton: 278 "select-translations-panel-translate-full-page-button", 279 translationFailureMessageBar: 280 "select-translations-panel-translation-failure-message-bar", 281 tryAgainButton: "select-translations-panel-try-again-button", 282 tryAnotherSourceMenuList: 283 "select-translations-panel-try-another-language", 284 tryAnotherSourceMenuPopup: 285 "select-translations-panel-try-another-language-menupopup", 286 unsupportedLanguageContent: 287 "select-translations-panel-unsupported-language-content", 288 unsupportedLanguageMessageBar: 289 "select-translations-panel-unsupported-language-message-bar", 290 }); 291 } 292 293 return this.#lazyElements; 294 } 295 296 /** 297 * Attempts to determine the best language tag to use as the source language for translation. 298 * If the detected language is not supported, attempts to fallback to the document's language tag. 299 * 300 * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed. 301 * 302 * @returns {Promise<string>} - The code of a supported language, a supported document language, or the top detected language. 303 */ 304 async getTopSupportedDetectedLanguage(textToTranslate) { 305 // We want to refresh our cache every time we make a determination about the detected source language, 306 // even if we never make it to the section of the logic below where we consider the document language, 307 // otherwise the incorrect, cached document language may be reported to telemetry. 308 const { docLangTag, isDocLangTagSupported } = this.#getLanguageInfo( 309 /* forceFetch */ true 310 ); 311 312 // First see if any of the detected languages are supported and return it if so. 313 const { language, languages } = 314 await LanguageDetector.detectLanguage(textToTranslate); 315 const languagePairs = await TranslationsParent.getNonPivotLanguagePairs(); 316 for (const { languageCode } of languages) { 317 const compatibleLangTag = 318 TranslationsParent.findCompatibleSourceLangTagSync( 319 languageCode, 320 languagePairs 321 ); 322 if (compatibleLangTag) { 323 return compatibleLangTag; 324 } 325 } 326 327 // Since none of the detected languages were supported, check to see if the 328 // document has a specified language tag that is supported. 329 if (isDocLangTagSupported) { 330 return docLangTag; 331 } 332 333 // No supported language was found, so return the top detected language 334 // to inform the panel's unsupported language state. 335 return language; 336 } 337 338 /** 339 * Attempts to cache the languageInformation for this page and the user's current settings. 340 * This data is helpful for telemetry. Leaves the cache unpopulated if the info failed to be 341 * retrieved. 342 * 343 * @param {boolean} forceFetch - Clears the cache and attempts to refetch data if true. 344 * 345 * @returns {object} - The cached language-info object. 346 */ 347 #getLanguageInfo(forceFetch = false) { 348 if (!forceFetch && this.#languageInfo.docLangTag !== undefined) { 349 return this.#languageInfo; 350 } 351 352 this.#isFullPageTranslationsRestrictedForPage = 353 TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser); 354 this.#activeFullPageTranslationsTargetLanguage = this 355 .#isFullPageTranslationsRestrictedForPage 356 ? undefined 357 : this.#maybeGetActiveFullPageTranslationsTargetLanguage(); 358 359 this.#languageInfo = { 360 docLangTag: undefined, 361 isDocLangTagSupported: undefined, 362 topPreferredLanguage: undefined, 363 }; 364 365 try { 366 const actor = TranslationsParent.getTranslationsActor( 367 gBrowser.selectedBrowser 368 ); 369 const { 370 detectedLanguages: { docLangTag, isDocLangTagSupported }, 371 } = actor.languageState; 372 const preferredLanguages = TranslationsParent.getPreferredLanguages(); 373 const topPreferredLanguage = preferredLanguages?.[0]; 374 this.#languageInfo = { 375 docLangTag: 376 // If Full-Page Translations (FPT) is active, we need to assume that the effective 377 // document language tag matches the language of the FPT target language, otherwise, 378 // if FPT is not active, we can take the real docLangTag value. 379 this.#activeFullPageTranslationsTargetLanguage ?? docLangTag, 380 isDocLangTagSupported, 381 topPreferredLanguage, 382 }; 383 } catch (error) { 384 // Failed to retrieve the Translations actor to detect the document language. 385 // This is most likely due to attempting to retrieve the actor in a page that 386 // is restricted for Full Page Translations, such as a PDF or reader mode, but 387 // Select Translations is often still available, so we can safely continue to 388 // the final return fallback. 389 if ( 390 !TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser) 391 ) { 392 // If we failed to retrieve the TranslationsParent actor on a non-restricted page, 393 // we should warn about this, because it is unexpected. The SelectTranslationsPanel 394 // itself will display an error state if this causes a failure, and this will help 395 // diagnose the issue if this scenario should ever occur. 396 this.console?.warn( 397 "Failed to retrieve the TranslationsParent actor on a page where Full Page Translations is not restricted." 398 ); 399 this.console?.error(error); 400 } 401 } 402 403 return this.#languageInfo; 404 } 405 406 /** 407 * Detects the language of the provided text and retrieves a language pair for translation 408 * based on user settings. 409 * 410 * @param {string} textToTranslate - The text for which the language detection and target language retrieval are performed. 411 * @returns {Promise<{sourceLanguage?: string, targetLanguage?: string}>} - An object containing the language pair for the translation. 412 * The `sourceLanguage` property is omitted if it is a language that is not currently supported by Firefox Translations. 413 */ 414 async getLangPairPromise(textToTranslate) { 415 if ( 416 TranslationsParent.isInAutomation() && 417 !TranslationsParent.isTranslationsEngineMocked() 418 ) { 419 // If we are in automation, and the Translations Engine is NOT mocked, then that means 420 // we are in a test case in which we are not explicitly testing Select Translations, 421 // and the code to get the supported languages below will not be available. However, 422 // we still need to ensure that the translate-selection menuitem in the context menu 423 // is compatible with all code in other tests, so we will return "en" for the purpose 424 // of being able to localize and display the context-menu item in other test cases. 425 return { targetLanguage: "en" }; 426 } 427 428 const sourceLanguage = 429 await SelectTranslationsPanel.getTopSupportedDetectedLanguage( 430 textToTranslate 431 ); 432 const targetLanguage = 433 await TranslationsParent.getTopPreferredSupportedToLang({ 434 // Avoid offering a same-language to same-language translation if we can. 435 excludeLangTags: [sourceLanguage], 436 }); 437 438 return { sourceLanguage, targetLanguage }; 439 } 440 441 /** 442 * Close the Select Translations Panel. 443 */ 444 close() { 445 PanelMultiView.hidePopup(this.elements.panel); 446 this.#mostRecentUIPhase = "closed"; 447 } 448 449 /** 450 * Ensures that the from-language and to-language dropdowns are built. 451 * 452 * This can be called every time the popup is shown, since it will retry 453 * when there is an error (such as a network error) or be a no-op if the 454 * dropdowns have already been initialized. 455 */ 456 async #ensureLangListsBuilt() { 457 await TranslationsPanelShared.ensureLangListsBuilt(document, this); 458 } 459 460 /** 461 * Initializes the selected value of the given language dropdown based on the language tag. 462 * 463 * @param {string} langTag - A BCP-47 language tag. 464 * @param {Element} menuList - The menu list element to update. 465 * 466 * @returns {Promise<void>} 467 */ 468 async #initializeLanguageMenuList(langTag, menuList) { 469 const compatibleLangTag = 470 menuList.id === this.elements.fromMenuList.id 471 ? await TranslationsParent.findCompatibleSourceLangTag(langTag) 472 : await TranslationsParent.findCompatibleTargetLangTag(langTag); 473 474 if (compatibleLangTag) { 475 // Remove the data-l10n-id because the menulist label will 476 // be populated from the supported language's display name. 477 menuList.removeAttribute("data-l10n-id"); 478 menuList.value = compatibleLangTag; 479 } else { 480 await this.#deselectLanguage(menuList); 481 } 482 } 483 484 /** 485 * Initializes the selected values of the from-language and to-language menu 486 * lists based on the result of the given language pair promise. 487 * 488 * @param {Promise<{sourceLanguage?: string, targetLanguage?: string}>} langPairPromise 489 * 490 * @returns {Promise<void>} 491 */ 492 async #initializeLanguageMenuLists(langPairPromise) { 493 const { sourceLanguage, targetLanguage } = await langPairPromise; 494 const { 495 fromMenuList, 496 fromMenuPopup, 497 toMenuList, 498 toMenuPopup, 499 tryAnotherSourceMenuList, 500 } = this.elements; 501 502 await Promise.all([ 503 this.#initializeLanguageMenuList(sourceLanguage, fromMenuList), 504 this.#initializeLanguageMenuList(targetLanguage, toMenuList), 505 this.#initializeLanguageMenuList(null, tryAnotherSourceMenuList), 506 ]); 507 508 this.#maybeTranslateOnEvents(["keypress"], fromMenuList); 509 this.#maybeTranslateOnEvents(["keypress"], toMenuList); 510 511 this.#maybeTranslateOnEvents(["popuphidden"], fromMenuPopup); 512 this.#maybeTranslateOnEvents(["popuphidden"], toMenuPopup); 513 } 514 515 /** 516 * Initializes event listeners on the panel class the first time 517 * this function is called, and is a no-op on subsequent calls. 518 */ 519 #initializeEventListeners() { 520 if (this.#eventListenersInitialized) { 521 // Event listeners have already been initialized, do nothing. 522 return; 523 } 524 525 const { panel, fromMenuList, toMenuList, tryAnotherSourceMenuList } = 526 this.elements; 527 528 // XUL buttons on macOS do not handle the Enter key by default for 529 // the focused element, so we must listen for the Enter key manually: 530 // https://searchfox.org/mozilla-central/rev/4c8627a76e2e0a9b49c2b673424da478e08715ad/dom/xul/XULButtonElement.cpp#563-579 531 if (AppConstants.platform === "macosx") { 532 panel.addEventListener("keypress", this); 533 } 534 panel.addEventListener("popupshown", this); 535 panel.addEventListener("popuphidden", this); 536 537 panel.addEventListener("command", this); 538 fromMenuList.addEventListener("command", this); 539 toMenuList.addEventListener("command", this); 540 tryAnotherSourceMenuList.addEventListener("command", this); 541 542 this.#eventListenersInitialized = true; 543 } 544 545 /** 546 * Opens the panel, ensuring the panel's UI and state are initialized correctly. 547 * 548 * @param {Event} event - The triggering event for opening the panel. 549 * @param {number} screenX - The x-axis location of the screen at which to open the popup. 550 * @param {number} screenY - The y-axis location of the screen at which to open the popup. 551 * @param {string} sourceText - The text to translate. 552 * @param {boolean} isTextSelected - True if the text comes from a selection, false if it comes from a hyperlink. 553 * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. 554 * @param {boolean} maintainFlow - Whether the telemetry flow-id should be persisted or assigned a new id. 555 * 556 * @returns {Promise<void>} 557 */ 558 async open( 559 event, 560 screenX, 561 screenY, 562 sourceText, 563 isTextSelected, 564 langPairPromise, 565 maintainFlow = false 566 ) { 567 if (this.#isOpen()) { 568 await this.#forceReopen( 569 event, 570 screenX, 571 screenY, 572 sourceText, 573 isTextSelected, 574 langPairPromise 575 ); 576 return; 577 } 578 579 const { sourceLanguage, targetLanguage } = await langPairPromise; 580 const { docLangTag, topPreferredLanguage } = this.#getLanguageInfo(); 581 582 TranslationsParent.telemetry() 583 .selectTranslationsPanel() 584 .onOpen({ 585 maintainFlow, 586 docLangTag, 587 sourceLanguage, 588 targetLanguage, 589 topPreferredLanguage, 590 textSource: isTextSelected ? "selection" : "hyperlink", 591 }); 592 593 try { 594 this.#sourceTextWordCount = undefined; 595 this.#initializeEventListeners(); 596 await this.#ensureLangListsBuilt(); 597 await Promise.all([ 598 this.#cachePlaceholderText(), 599 this.#initializeLanguageMenuLists(langPairPromise), 600 this.#registerSourceText(sourceText, langPairPromise), 601 ]); 602 this.#maybeRequestTranslation(); 603 } catch (error) { 604 this.console?.error(error); 605 this.#changeStateToInitFailure( 606 event, 607 screenX, 608 screenY, 609 sourceText, 610 isTextSelected, 611 langPairPromise 612 ); 613 } 614 615 this.#openPopup(event, screenX, screenY); 616 } 617 618 /** 619 * Attempts to retrieve the language tag of the requested target language 620 * for Full Page Translations, if Full Page Translations is active on the page 621 * within the active tab of the active window. 622 * 623 * @returns {string | undefined} - The BCP-47 language tag. 624 */ 625 #maybeGetActiveFullPageTranslationsTargetLanguage() { 626 try { 627 const { requestedLanguagePair } = TranslationsParent.getTranslationsActor( 628 gBrowser.selectedBrowser 629 ).languageState; 630 return requestedLanguagePair?.targetLanguage; 631 } catch { 632 this.console.warn("Failed to retrieve the TranslationsParent actor."); 633 } 634 return undefined; 635 } 636 637 /** 638 * Forces the panel to close and reopen at the same location. 639 * 640 * This should never be called in the regular flow of events, but is good to have in case 641 * the panel somehow gets into an invalid state. 642 * 643 * @param {Event} event - The triggering event for opening the panel. 644 * @param {number} screenX - The x-axis location of the screen at which to open the popup. 645 * @param {number} screenY - The y-axis location of the screen at which to open the popup. 646 * @param {string} sourceText - The text to translate. 647 * @param {boolean} isTextSelected - True if the text comes from a selection, false if it comes from a hyperlink. 648 * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. 649 * 650 * @returns {Promise<void>} 651 */ 652 async #forceReopen( 653 event, 654 screenX, 655 screenY, 656 sourceText, 657 isTextSelected, 658 langPairPromise 659 ) { 660 this.console?.warn("The SelectTranslationsPanel was forced to reopen."); 661 this.close(); 662 this.#changeStateToClosed(); 663 await this.open( 664 event, 665 screenX, 666 screenY, 667 sourceText, 668 isTextSelected, 669 langPairPromise 670 ); 671 } 672 673 /** 674 * Opens the panel popup at a location on the screen. 675 * 676 * @param {Event} event - The event that triggers the popup opening. 677 * @param {number} screenX - The x-axis location of the screen at which to open the popup. 678 * @param {number} screenY - The y-axis location of the screen at which to open the popup. 679 */ 680 #openPopup(event, screenX, screenY) { 681 this.console?.log("Showing SelectTranslationsPanel"); 682 const { panel } = this.elements; 683 this.#cacheAlignmentPositionOnOpen(); 684 panel.openPopupAtScreenRect( 685 "after_start", 686 screenX, 687 screenY, 688 /* width */ 0, 689 /* height */ 0, 690 /* isContextMenu */ false, 691 /* attributesOverride */ false, 692 event 693 ); 694 } 695 696 /** 697 * Resets the cached alignment-position value and adds an event listener 698 * to set the value again when the panel is positioned before opening. 699 * See the comment on the data member for more details. 700 * 701 * @see #alignmentPosition 702 */ 703 #cacheAlignmentPositionOnOpen() { 704 const { panel } = this.elements; 705 this.#alignmentPosition = ""; 706 panel.addEventListener( 707 "popuppositioned", 708 popupPositionedEvent => { 709 // Cache the alignment position when the popup is opened. 710 this.#alignmentPosition = popupPositionedEvent.alignmentPosition; 711 }, 712 { once: true } 713 ); 714 } 715 716 /** 717 * Adds the source text to the translation state and adapts the size of the text area based 718 * on the length of the text. 719 * 720 * @param {string} sourceText - The text to translate. 721 * @param {Promise<{sourceLanguage?: string, targetLanguage?: string}>} langPairPromise 722 * 723 * @returns {Promise<void>} 724 */ 725 async #registerSourceText(sourceText, langPairPromise) { 726 const { textArea } = this.elements; 727 const { sourceLanguage, targetLanguage } = await langPairPromise; 728 const compatibleFromLang = 729 await TranslationsParent.findCompatibleSourceLangTag(sourceLanguage); 730 731 if (compatibleFromLang) { 732 this.#changeStateTo("idle", /* retainEntries */ false, { 733 sourceText, 734 sourceLanguage: compatibleFromLang, 735 targetLanguage, 736 }); 737 } else { 738 this.#changeStateTo("unsupported", /* retainEntries */ false, { 739 sourceText, 740 detectedLanguage: sourceLanguage, 741 targetLanguage, 742 }); 743 } 744 745 textArea.value = ""; 746 textArea.style.resize = "none"; 747 textArea.style.maxHeight = null; 748 if (sourceText.length < SelectTranslationsPanel.textLengthThreshold) { 749 textArea.style.height = SelectTranslationsPanel.shortTextHeight; 750 } else { 751 textArea.style.height = SelectTranslationsPanel.longTextHeight; 752 } 753 754 this.#maybeTranslateOnEvents(["focus"], textArea); 755 } 756 757 /** 758 * Caches the localized text to use as placeholders. 759 */ 760 async #cachePlaceholderText() { 761 const [idleText, translatingText] = await document.l10n.formatValues([ 762 { id: "select-translations-panel-idle-placeholder-text" }, 763 { id: "select-translations-panel-translating-placeholder-text" }, 764 ]); 765 this.#idlePlaceholderText = idleText; 766 this.#translatingPlaceholderText = translatingText; 767 } 768 769 /** 770 * Opens the settings menu popup at the settings button gear-icon. 771 */ 772 #openSettingsPopup() { 773 TranslationsParent.telemetry() 774 .selectTranslationsPanel() 775 .onOpenSettingsMenu(); 776 777 const { settingsButton } = this.elements; 778 const popup = settingsButton.ownerDocument.getElementById( 779 "select-translations-panel-settings-menupopup" 780 ); 781 782 popup.openPopup(settingsButton, "after_start"); 783 } 784 785 /** 786 * Opens the "About translation in Firefox" Mozilla support page in a new tab. 787 */ 788 onAboutTranslations() { 789 TranslationsParent.telemetry() 790 .selectTranslationsPanel() 791 .onAboutTranslations(); 792 793 this.close(); 794 const window = 795 gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; 796 window.openTrustedLinkIn( 797 "https://support.mozilla.org/kb/website-translation", 798 "tab", 799 { 800 forceForeground: true, 801 triggeringPrincipal: 802 Services.scriptSecurityManager.getSystemPrincipal(), 803 } 804 ); 805 } 806 807 /** 808 * Opens the Translations section of about:preferences in a new tab. 809 */ 810 openTranslationsSettingsPage() { 811 TranslationsParent.telemetry() 812 .selectTranslationsPanel() 813 .onTranslationSettings(); 814 815 this.close(); 816 const window = 817 gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; 818 window.openTrustedLinkIn("about:preferences#general-translations", "tab"); 819 } 820 821 /** 822 * Handles events when a command event is triggered within the panel. 823 * 824 * @param {Element} target - The event target 825 */ 826 #handleCommandEvent(target) { 827 const { 828 cancelButton, 829 copyButton, 830 doneButtonPrimary, 831 doneButtonSecondary, 832 fromMenuList, 833 fromMenuPopup, 834 settingsButton, 835 toMenuList, 836 toMenuPopup, 837 translateButton, 838 translateFullPageButton, 839 tryAgainButton, 840 tryAnotherSourceMenuList, 841 tryAnotherSourceMenuPopup, 842 } = this.elements; 843 switch (target.id) { 844 case cancelButton.id: { 845 this.onClickCancelButton(); 846 break; 847 } 848 case copyButton.id: { 849 this.onClickCopyButton(); 850 break; 851 } 852 case doneButtonPrimary.id: 853 case doneButtonSecondary.id: { 854 this.onClickDoneButton(); 855 break; 856 } 857 case fromMenuList.id: 858 case fromMenuPopup.id: { 859 this.onChangeFromLanguage(); 860 break; 861 } 862 case settingsButton.id: { 863 this.#openSettingsPopup(); 864 break; 865 } 866 case toMenuList.id: 867 case toMenuPopup.id: { 868 this.onChangeToLanguage(); 869 break; 870 } 871 case translateButton.id: { 872 this.onClickTranslateButton(); 873 break; 874 } 875 case translateFullPageButton.id: { 876 this.onClickTranslateFullPageButton(); 877 break; 878 } 879 case tryAgainButton.id: { 880 this.onClickTryAgainButton(); 881 break; 882 } 883 case tryAnotherSourceMenuList.id: 884 case tryAnotherSourceMenuPopup.id: { 885 this.onChangeTryAnotherSourceLanguage(); 886 break; 887 } 888 } 889 } 890 891 /** 892 * Handles events when the Enter key is pressed within the panel. 893 * 894 * @param {Element} target - The event target 895 */ 896 #handleEnterKeyPressed(target) { 897 const { 898 cancelButton, 899 copyButton, 900 doneButtonPrimary, 901 doneButtonSecondary, 902 settingsButton, 903 translateButton, 904 translateFullPageButton, 905 tryAgainButton, 906 } = this.elements; 907 908 switch (target.id) { 909 case cancelButton.id: { 910 this.onClickCancelButton(); 911 break; 912 } 913 case copyButton.id: { 914 this.onClickCopyButton(); 915 break; 916 } 917 case doneButtonPrimary.id: 918 case doneButtonSecondary.id: { 919 this.onClickDoneButton(); 920 break; 921 } 922 case settingsButton.id: { 923 this.#openSettingsPopup(); 924 break; 925 } 926 case translateButton.id: { 927 this.onClickTranslateButton(); 928 break; 929 } 930 case translateFullPageButton.id: { 931 this.onClickTranslateFullPageButton(); 932 break; 933 } 934 case tryAgainButton.id: { 935 this.onClickTryAgainButton(); 936 break; 937 } 938 } 939 } 940 941 /** 942 * Conditionally enables the resizer component at the bottom corner of the text area, 943 * and limits the maximum height that the textarea can be resized. 944 * 945 * For systems using Wayland, this function ensures that the panel cannot be resized past 946 * the border of the current Firefox window. 947 * 948 * For all other systems, this function ensures that the panel cannot be resized past the 949 * bottom edge of the available screen space. 950 */ 951 #maybeEnableTextAreaResizer() { 952 // The alignment position of the panel is determined during the "popuppositioned" event 953 // when the panel opens. The alignment positions help us determine in which orientation 954 // the panel is anchored to the screen space. 955 // 956 // * "after_start": The panel is anchored at the top-left corner in LTR locales, top-right in RTL locales. 957 // * "after_end": The panel is anchored at the top-right corner in LTR locales, top-left in RTL locales. 958 // * "before_start": The panel is anchored at the bottom-left corner in LTR locales, bottom-right in RTL locales. 959 // * "before_end": The panel is anchored at the bottom-right corner in LTR locales, bottom-left in RTL locales. 960 // 961 // ┌─Anchor(LTR) ┌─Anchor(RTL) 962 // │ Anchor(RTL)─┐ │ Anchor(LTR)─┐ 963 // │ │ │ │ 964 // x───────────────────x x───────────────────x 965 // │ │ │ │ 966 // │ Panel │ │ Panel │ 967 // │ "after_start" │ │ "after_end" │ 968 // │ │ │ │ 969 // └───────────────────┘ └───────────────────┘ 970 // 971 // ┌───────────────────┐ ┌───────────────────┐ 972 // │ │ │ │ 973 // │ Panel │ │ Panel │ 974 // │ "before_start" │ │ "before_end" │ 975 // │ │ │ │ 976 // x───────────────────x x───────────────────x 977 // │ │ │ │ 978 // │ Anchor(RTL)─┘ │ Anchor(LTR)─┘ 979 // └─Anchor(LTR) └─Anchor(RTL) 980 // 981 // The default choice for the panel is "after_start", to match the content context menu's alignment. However, it is 982 // possible to end up with any of the four combinations. Before the panel is opened, the XUL popup manager needs to 983 // make a determination about the size of the panel and whether or not it will fit within the visible screen area with 984 // the intended alignment. The manager may change the panel's alignment before opening to ensure the panel is fully visible. 985 // 986 // For example, if the panel is opened such that the bottom edge would be rendered off screen, then the XUL popup manager 987 // will change the alignment from "after_start" to "before_start", anchoring the panel's bottom corner to the target screen 988 // location instead of its top corner. This transformation ensures that the whole of the panel is visible on the screen. 989 // 990 // When the panel is anchored by one of its bottom corners (the "before_..." options), then it causes unintentionally odd 991 // behavior where dragging the text-area resizer downward with the mouse actually grows the panel's top edge upward, since 992 // the bottom of the panel is anchored in place. We want to disable the resizer if the panel was positioned to be anchored 993 // from one of its bottom corners. 994 switch (this.#alignmentPosition) { 995 case "after_start": 996 case "after_end": { 997 // The text-area resizer will act normally. 998 break; 999 } 1000 case "before_start": 1001 case "before_end": { 1002 // The text-area resizer increase the size of the panel from the top edge even 1003 // though the user is dragging the resizer downward with the mouse. 1004 this.console?.debug( 1005 `Disabling text-area resizer due to panel alignment position: "${ 1006 this.#alignmentPosition 1007 }"` 1008 ); 1009 return; 1010 } 1011 default: { 1012 this.console?.debug( 1013 `Disabling text-area resizer due to unexpected panel alignment position: "${ 1014 this.#alignmentPosition 1015 }"` 1016 ); 1017 return; 1018 } 1019 } 1020 1021 const { panel, textArea } = this.elements; 1022 1023 if (textArea.style.maxHeight) { 1024 this.console?.debug( 1025 "The text-area resizer has already been enabled at the current panel location." 1026 ); 1027 return; 1028 } 1029 1030 // The visible height of the text area on the screen. 1031 const textAreaClientHeight = textArea.clientHeight; 1032 1033 // The height of the text in the text area, including text that has overflowed beyond the client height. 1034 const textAreaScrollHeight = textArea.scrollHeight; 1035 1036 if (textAreaScrollHeight <= textAreaClientHeight) { 1037 this.console?.debug( 1038 "Disabling text-area resizer because the text content fits within the text area." 1039 ); 1040 return; 1041 } 1042 1043 // Wayland has no concept of "screen coordinates" which causes getOuterScreenRect to always 1044 // return { x: 0, y: 0 } for the location. As such, we cannot tell on Wayland where the panel 1045 // is positioned relative to the screen, so we must restrict the panel's resizing limits to be 1046 // within the Firefox window itself. 1047 let isWayland = false; 1048 try { 1049 isWayland = GfxInfo.windowProtocol === "wayland"; 1050 } catch (error) { 1051 if (AppConstants.platform === "linux") { 1052 this.console?.warn(error); 1053 this.console?.debug( 1054 "Disabling text-area resizer because we were unable to retrieve the window protocol on Linux." 1055 ); 1056 return; 1057 } 1058 // Since we're not on Linux, we can safely continue with isWayland = false. 1059 } 1060 1061 const { 1062 top: panelTop, 1063 left: panelLeft, 1064 bottom: panelBottom, 1065 right: panelRight, 1066 } = isWayland 1067 ? // The panel's location relative to the Firefox window. 1068 panel.getBoundingClientRect() 1069 : // The panel's location relative to the screen. 1070 panel.getOuterScreenRect(); 1071 1072 const window = 1073 gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; 1074 1075 if (isWayland) { 1076 if (panelTop < 0) { 1077 this.console?.debug( 1078 "Disabling text-area resizer because the panel outside the top edge of the window on Wayland." 1079 ); 1080 return; 1081 } 1082 if (panelBottom > window.innerHeight) { 1083 this.console?.debug( 1084 "Disabling text-area resizer because the panel is outside the bottom edge of the window on Wayland." 1085 ); 1086 return; 1087 } 1088 if (panelLeft < 0) { 1089 this.console?.debug( 1090 "Disabling text-area resizer because the panel outside the left edge of the window on Wayland." 1091 ); 1092 return; 1093 } 1094 if (panelRight > window.innerWidth) { 1095 this.console?.debug( 1096 "Disabling text-area resizer because the panel is outside the right edge of the window on Wayland." 1097 ); 1098 return; 1099 } 1100 } else if (!panelBottom) { 1101 // The location of the panel was unable to be retrieved by getOuterScreenRect() so we should not enable 1102 // resizing the text area because we cannot accurately guard against the user resizing the panel off of 1103 // the bottom edge of the screen. The worst case for the user here is that they have to utilize the scroll 1104 // bar instead of resizing. This happens intermittently, but infrequently. 1105 this.console?.debug( 1106 "Disabling text-area resizer because the location of the bottom edge of the panel was unavailable." 1107 ); 1108 return; 1109 } 1110 1111 const availableHeight = isWayland 1112 ? // The available height of the Firefox window. 1113 window.innerHeight 1114 : // The available height of the screen. 1115 screen.availHeight; 1116 1117 // The distance in pixels between the bottom edge of the panel to the bottom 1118 // edge of our available height, which will either be the bottom of the Firefox 1119 // window on Wayland, otherwise the bottom of the available screen space. 1120 const panelBottomToBottomEdge = availableHeight - panelBottom; 1121 1122 // We want to maintain some buffer of pixels between the panel's bottom edge 1123 // and the bottom edge of our available space, because if they touch, it can 1124 // cause visual glitching to occur. 1125 const BOTTOM_EDGE_PIXEL_BUFFER = Math.abs(panelBottom - panelTop) / 5; 1126 1127 if (panelBottomToBottomEdge < BOTTOM_EDGE_PIXEL_BUFFER) { 1128 this.console?.debug( 1129 "Disabling text-area resizer because the bottom of the panel is already close to the bottom edge." 1130 ); 1131 return; 1132 } 1133 1134 // The height that the textarea could grow to before hitting the threshold of the buffer that we 1135 // intend to keep between the bottom edge of the panel and the bottom edge of available space. 1136 const textAreaHeightLimitForEdge = 1137 textAreaClientHeight + panelBottomToBottomEdge - BOTTOM_EDGE_PIXEL_BUFFER; 1138 1139 // This is an arbitrary ratio, but allowing the panel's text area to span 1/2 of the available 1140 // vertical real estate, even if it could expand farther, seems like a reasonable constraint. 1141 const textAreaHeightLimitUpperBound = Math.trunc(availableHeight / 2); 1142 1143 // The final maximum height that the text area will be allowed to resize to at its current location. 1144 const textAreaMaxHeight = Math.min( 1145 textAreaScrollHeight, 1146 textAreaHeightLimitForEdge, 1147 textAreaHeightLimitUpperBound 1148 ); 1149 1150 textArea.style.resize = "vertical"; 1151 textArea.style.maxHeight = `${textAreaMaxHeight}px`; 1152 this.console?.debug( 1153 `Enabling text-area resizer with a maximum height of ${textAreaMaxHeight} pixels` 1154 ); 1155 } 1156 1157 /** 1158 * Handles events when a popup is shown within the panel, including showing 1159 * the panel itself. 1160 * 1161 * @param {Element} target - The event target 1162 */ 1163 #handlePopupShownEvent(target) { 1164 const { panel } = this.elements; 1165 switch (target.id) { 1166 case panel.id: { 1167 this.#updatePanelUIFromState(); 1168 break; 1169 } 1170 } 1171 } 1172 1173 /** 1174 * Handles events when a popup is closed within the panel, including closing 1175 * the panel itself. 1176 * 1177 * @param {Element} target - The event target 1178 */ 1179 #handlePopupHiddenEvent(target) { 1180 const { panel } = this.elements; 1181 switch (target.id) { 1182 case panel.id: { 1183 TranslationsParent.telemetry().selectTranslationsPanel().onClose(); 1184 this.#changeStateToClosed(); 1185 this.#removeActiveTranslationListeners(); 1186 break; 1187 } 1188 } 1189 } 1190 1191 /** 1192 * Handles events in the SelectTranslationsPanel. 1193 * 1194 * @param {Event} event - The event to handle. 1195 */ 1196 handleEvent(event) { 1197 let target = event.target; 1198 1199 // If a menuitem within a menulist is the target, those don't have ids, 1200 // so we want to traverse until we get to a parent element with an id. 1201 while (!target.id && target.parentElement) { 1202 target = target.parentElement; 1203 } 1204 1205 switch (event.type) { 1206 case "command": { 1207 this.#handleCommandEvent(target); 1208 break; 1209 } 1210 case "keypress": { 1211 if (event.key === "Enter") { 1212 this.#handleEnterKeyPressed(target); 1213 } 1214 break; 1215 } 1216 case "popupshown": { 1217 this.#handlePopupShownEvent(target); 1218 break; 1219 } 1220 case "popuphidden": { 1221 this.#handlePopupHiddenEvent(target); 1222 break; 1223 } 1224 } 1225 } 1226 1227 /** 1228 * Handles events when the panels select from-language is changed. 1229 */ 1230 onChangeFromLanguage() { 1231 this.#sourceTextWordCount = undefined; 1232 this.#updateConditionalUIEnabledState(); 1233 } 1234 1235 /** 1236 * Handles events when the panels select to-language is changed. 1237 */ 1238 onChangeToLanguage() { 1239 this.#updateConditionalUIEnabledState(); 1240 } 1241 1242 /** 1243 * Handles events when the panel's try-another-source language is changed. 1244 */ 1245 onChangeTryAnotherSourceLanguage() { 1246 const { tryAnotherSourceMenuList, translateButton } = this.elements; 1247 if (tryAnotherSourceMenuList.value) { 1248 translateButton.disabled = false; 1249 } 1250 } 1251 1252 /** 1253 * Handles events when the panel's cancel button is clicked. 1254 */ 1255 onClickCancelButton() { 1256 TranslationsParent.telemetry().selectTranslationsPanel().onCancelButton(); 1257 this.close(); 1258 } 1259 1260 /** 1261 * Handles events when the panel's copy button is clicked. 1262 */ 1263 onClickCopyButton() { 1264 TranslationsParent.telemetry().selectTranslationsPanel().onCopyButton(); 1265 1266 try { 1267 ClipboardHelper.copyString(this.getTranslatedText()); 1268 } catch (error) { 1269 this.console?.error(error); 1270 return; 1271 } 1272 1273 this.#checkCopyButton(); 1274 } 1275 1276 /** 1277 * Handles events when the panel's done button is clicked. 1278 */ 1279 onClickDoneButton() { 1280 TranslationsParent.telemetry().selectTranslationsPanel().onDoneButton(); 1281 this.close(); 1282 } 1283 1284 /** 1285 * Handles events when the panel's translate button is clicked. 1286 */ 1287 onClickTranslateButton() { 1288 const { fromMenuList, tryAnotherSourceMenuList } = this.elements; 1289 const { detectedLanguage, targetLanguage } = this.#translationState; 1290 1291 fromMenuList.value = tryAnotherSourceMenuList.value; 1292 1293 TranslationsParent.telemetry().selectTranslationsPanel().onTranslateButton({ 1294 detectedLanguage, 1295 sourceLanguage: fromMenuList.value, 1296 targetLanguage, 1297 }); 1298 1299 this.#maybeRequestTranslation(); 1300 } 1301 1302 /** 1303 * Handles events when the panel's translate-full-page button is clicked. 1304 */ 1305 onClickTranslateFullPageButton() { 1306 TranslationsParent.telemetry() 1307 .selectTranslationsPanel() 1308 .onTranslateFullPageButton(); 1309 1310 const { panel } = this.elements; 1311 const languagePair = this.#getSelectedLanguagePair(); 1312 1313 try { 1314 const actor = TranslationsParent.getTranslationsActor( 1315 gBrowser.selectedBrowser 1316 ); 1317 panel.addEventListener( 1318 "popuphidden", 1319 () => 1320 actor.translate( 1321 languagePair, 1322 false // reportAsAutoTranslate 1323 ), 1324 { once: true } 1325 ); 1326 } catch (error) { 1327 // This situation would only occur if the translate-full-page button as invoked 1328 // while Translations actor is not available. the logic within this class explicitly 1329 // hides the button in this case, and this should not be possible under normal conditions, 1330 // but if this button were to somehow still be invoked, the best thing we can do here is log 1331 // an error to the console because the FullPageTranslationsPanel assumes that the actor is available. 1332 this.console?.error(error); 1333 } 1334 1335 this.close(); 1336 } 1337 1338 /** 1339 * Handles events when the panel's try-again button is clicked. 1340 */ 1341 onClickTryAgainButton() { 1342 TranslationsParent.telemetry().selectTranslationsPanel().onTryAgainButton(); 1343 1344 switch (this.phase()) { 1345 case "translation-failure": { 1346 // If the translation failed, we just need to try translating again. 1347 this.#maybeRequestTranslation(); 1348 break; 1349 } 1350 case "init-failure": { 1351 // If the initialization failed, we need to close the panel and try reopening it 1352 // which will attempt to initialize everything again after failure. 1353 const { panel } = this.elements; 1354 const { 1355 event, 1356 screenX, 1357 screenY, 1358 sourceText, 1359 isTextSelected, 1360 langPairPromise, 1361 } = this.#translationState; 1362 1363 panel.addEventListener( 1364 "popuphidden", 1365 () => 1366 this.open( 1367 event, 1368 screenX, 1369 screenY, 1370 sourceText, 1371 isTextSelected, 1372 langPairPromise, 1373 /* maintainFlow */ true 1374 ), 1375 { once: true } 1376 ); 1377 1378 this.close(); 1379 break; 1380 } 1381 default: { 1382 this.console?.error( 1383 `Unexpected state "${this.phase()}" on try-again button click.` 1384 ); 1385 } 1386 } 1387 } 1388 1389 /** 1390 * Changes the copy button's visual icon to checked, and its localized text to "Copied". 1391 */ 1392 #checkCopyButton() { 1393 const { copyButton } = this.elements; 1394 copyButton.classList.add("copied"); 1395 document.l10n.setAttributes( 1396 copyButton, 1397 "select-translations-panel-copy-button-copied" 1398 ); 1399 } 1400 1401 /** 1402 * Changes the copy button's visual icon to unchecked, and its localized text to "Copy". 1403 */ 1404 #uncheckCopyButton() { 1405 const { copyButton } = this.elements; 1406 copyButton.classList.remove("copied"); 1407 document.l10n.setAttributes( 1408 copyButton, 1409 "select-translations-panel-copy-button" 1410 ); 1411 } 1412 1413 /** 1414 * Clears the selected language and ensures that the menu list displays 1415 * the proper placeholder text. 1416 * 1417 * @param {Element} menuList - The target menu list element to update. 1418 */ 1419 async #deselectLanguage(menuList) { 1420 menuList.value = ""; 1421 document.l10n.setAttributes(menuList, "translations-panel-choose-language"); 1422 await document.l10n.translateElements([menuList]); 1423 } 1424 1425 /** 1426 * Focuses on the given menu list if provided and empty, or defaults to focusing one 1427 * of the from-menu or to-menu lists if either is empty. 1428 * 1429 * @param {Element} [menuList] - The menu list to focus if specified. 1430 */ 1431 #maybeFocusMenuList(menuList) { 1432 if (menuList && !menuList.value) { 1433 menuList.focus({ focusVisible: false }); 1434 return; 1435 } 1436 1437 const { fromMenuList, toMenuList } = this.elements; 1438 if (!fromMenuList.value) { 1439 fromMenuList.focus({ focusVisible: false }); 1440 } else if (!toMenuList.value) { 1441 toMenuList.focus({ focusVisible: false }); 1442 } 1443 } 1444 1445 /** 1446 * Focuses the translated-text area and sets its overflow to auto post-animation. 1447 */ 1448 #indicateTranslatedTextArea({ overflow }) { 1449 const { textArea } = this.elements; 1450 textArea.focus({ focusVisible: true }); 1451 requestAnimationFrame(() => { 1452 // We want to set overflow to auto as the final animation, because if it is 1453 // set before the translated text is displayed, then the scrollTop will 1454 // move to the bottom as the text is populated. 1455 // 1456 // Setting scrollTop = 0 on its own works, but it sometimes causes an animation 1457 // of the text jumping from the bottom to the top. It looks a lot cleaner to 1458 // disable overflow before rendering the text, then re-enable it after it renders. 1459 requestAnimationFrame(() => { 1460 textArea.style.overflow = overflow; 1461 textArea.setSelectionRange(0, 0); 1462 textArea.scrollTop = 0; 1463 }); 1464 }); 1465 } 1466 1467 /** 1468 * Checks if the given language pair matches the panel's currently selected language pair. 1469 * 1470 * @param {string} sourceLanguage - The from-language to compare. 1471 * @param {string} targetLanguage - The to-language to compare. 1472 * 1473 * @returns {boolean} - True if the given language pair matches the selected languages in the panel UI, otherwise false. 1474 */ 1475 #isSelectedLangPair(sourceLanguage, targetLanguage) { 1476 const selected = this.#getSelectedLanguagePair(); 1477 return ( 1478 TranslationsUtils.langTagsMatch( 1479 sourceLanguage, 1480 selected.sourceLanguage 1481 ) && 1482 TranslationsUtils.langTagsMatch(targetLanguage, selected.targetLanguage) 1483 ); 1484 } 1485 1486 /** 1487 * Retrieves the currently selected language pair from the menu lists. 1488 * 1489 * @returns {LanguagePair} 1490 */ 1491 #getSelectedLanguagePair() { 1492 const { fromMenuList, toMenuList } = this.elements; 1493 const [sourceLanguage, sourceVariant] = fromMenuList.value.split(","); 1494 const [targetLanguage, targetVariant] = toMenuList.value.split(","); 1495 return { 1496 sourceLanguage, 1497 targetLanguage, 1498 sourceVariant, 1499 targetVariant, 1500 }; 1501 } 1502 1503 /** 1504 * Retrieves the source text from the translation state. 1505 * This value is not available when the panel is closed. 1506 * 1507 * @returns {string | undefined} The source text. 1508 */ 1509 getSourceText() { 1510 return this.#translationState?.sourceText; 1511 } 1512 1513 /** 1514 * Retrieves the source text from the translation state. 1515 * This value is only available in the translated phase. 1516 * 1517 * @returns {string | undefined} The translated text. 1518 */ 1519 getTranslatedText() { 1520 return this.#translationState?.translatedText; 1521 } 1522 1523 /** 1524 * Retrieves the current phase of the translation state. 1525 * 1526 * @returns {string} 1527 */ 1528 phase() { 1529 return this.#translationState.phase; 1530 } 1531 1532 /** 1533 * @returns {boolean} True if the panel is open, otherwise false. 1534 */ 1535 #isOpen() { 1536 return this.phase() !== "closed"; 1537 } 1538 1539 /** 1540 * @returns {boolean} True if the panel is closed, otherwise false. 1541 */ 1542 #isClosed() { 1543 return this.phase() === "closed"; 1544 } 1545 1546 /** 1547 * Changes the translation state to a new phase with options to retain or overwrite existing entries. 1548 * 1549 * @param {SelectTranslationsPanelState} phase - The new phase to transition to. 1550 * @param {boolean} [retainEntries] - Whether to retain existing state entries that are not overwritten. 1551 * @param {object | null} [data=null] - Additional data to merge into the state. 1552 * @throws {Error} If an invalid phase is specified. 1553 */ 1554 #changeStateTo(phase, retainEntries, data = null) { 1555 switch (phase) { 1556 case "closed": 1557 case "idle": 1558 case "init-failure": 1559 case "translation-failure": 1560 case "translatable": 1561 case "translating": 1562 case "translated": 1563 case "unsupported": { 1564 // Phase is valid, continue on. 1565 break; 1566 } 1567 default: { 1568 throw new Error(`Invalid state change to '${phase}'`); 1569 } 1570 } 1571 1572 const previousPhase = this.phase(); 1573 if (data && retainEntries) { 1574 // Change the phase and apply new entries from data, but retain non-overwritten entries from previous state. 1575 this.#translationState = { ...this.#translationState, phase, ...data }; 1576 } else if (data) { 1577 // Change the phase and apply new entries from data, but drop any entries that are not overwritten by data. 1578 this.#translationState = { phase, ...data }; 1579 } else if (retainEntries) { 1580 // Change only the phase and retain all entries from previous data. 1581 this.#translationState.phase = phase; 1582 } else { 1583 // Change the phase and delete all entries from previous data. 1584 this.#translationState = { phase }; 1585 } 1586 1587 if (previousPhase === this.phase()) { 1588 // Do not continue on to update the UI because the phase didn't change. 1589 return; 1590 } 1591 1592 const { sourceLanguage, targetLanguage, detectedLanguage } = 1593 this.#translationState; 1594 this.console?.debug( 1595 `SelectTranslationsPanel (${sourceLanguage ?? detectedLanguage ?? "??"}-${ 1596 targetLanguage ? targetLanguage : "??" 1597 }) state change (${previousPhase} => ${phase})` 1598 ); 1599 1600 this.#updatePanelUIFromState(); 1601 document.dispatchEvent( 1602 new CustomEvent("SelectTranslationsPanelStateChanged", { 1603 detail: { phase }, 1604 }) 1605 ); 1606 } 1607 1608 /** 1609 * Changes the phase to closed, discarding any entries in the translation state. 1610 */ 1611 #changeStateToClosed() { 1612 this.#changeStateTo("closed", /* retainEntries */ false); 1613 } 1614 1615 /** 1616 * Changes the phase from "translatable" to "translating". 1617 * 1618 * @throws {Error} If the current state is not "translatable". 1619 */ 1620 #changeStateToTranslating() { 1621 const phase = this.phase(); 1622 if (phase !== "translatable") { 1623 throw new Error(`Invalid state change (${phase} => translating)`); 1624 } 1625 this.#changeStateTo("translating", /* retainEntries */ true); 1626 } 1627 1628 /** 1629 * Changes the phase from "translating" to "translated". 1630 * 1631 * @throws {Error} If the current state is not "translating". 1632 */ 1633 #changeStateToTranslated(translatedText) { 1634 const phase = this.phase(); 1635 if (phase !== "translating") { 1636 throw new Error(`Invalid state change (${phase} => translated)`); 1637 } 1638 this.#changeStateTo("translated", /* retainEntries */ true, { 1639 translatedText, 1640 }); 1641 } 1642 1643 /** 1644 * Changes the phase to "init-failure". 1645 * 1646 * @param {Event} event - The triggering event for opening the panel. 1647 * @param {number} screenX - The x-axis location of the screen at which to open the popup. 1648 * @param {number} screenY - The y-axis location of the screen at which to open the popup. 1649 * @param {string} sourceText - The text to translate. 1650 * @param {boolean} isTextSelected - True if the text comes from a hyperlink, false if it is from a selection. 1651 * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. 1652 */ 1653 #changeStateToInitFailure( 1654 event, 1655 screenX, 1656 screenY, 1657 sourceText, 1658 isTextSelected, 1659 langPairPromise 1660 ) { 1661 this.#changeStateTo("init-failure", /* retainEntries */ true, { 1662 event, 1663 screenX, 1664 screenY, 1665 sourceText, 1666 isTextSelected, 1667 langPairPromise, 1668 }); 1669 } 1670 1671 /** 1672 * Changes the phase from "translating" to "translation-failure". 1673 */ 1674 #changeStateToTranslationFailure() { 1675 const phase = this.phase(); 1676 if (phase !== "translating") { 1677 this.console?.error( 1678 `Invalid state change (${phase} => translation-failure)` 1679 ); 1680 } 1681 this.#changeStateTo("translation-failure", /* retainEntries */ true); 1682 } 1683 1684 /** 1685 * Transitions the phase to "translatable" if the proper conditions are met, 1686 * otherwise retains the same phase as before. 1687 * 1688 * @param {string} sourceLanguage - The BCP-47 from-language tag. 1689 * @param {string} targetLanguage - The BCP-47 to-language tag. 1690 */ 1691 #maybeChangeStateToTranslatable(sourceLanguage, targetLanguage) { 1692 const previous = this.#translationState; 1693 1694 const langSelectionChanged = () => 1695 !TranslationsUtils.langTagsMatch( 1696 previous.sourceLanguage, 1697 sourceLanguage 1698 ) || 1699 !TranslationsUtils.langTagsMatch(previous.targetLanguage, targetLanguage); 1700 1701 const shouldTranslateEvenIfLangSelectionHasNotChanged = () => { 1702 const phase = this.phase(); 1703 return ( 1704 // The panel has just opened, and this is the initial translation. 1705 phase === "idle" || 1706 // The previous translation failed and we are about to try again. 1707 phase === "translation-failure" 1708 ); 1709 }; 1710 1711 if ( 1712 // A valid source language is actively selected. 1713 sourceLanguage && 1714 // A valid target language is actively selected. 1715 targetLanguage && 1716 // The language selection has changed, requiring a new translation. 1717 (langSelectionChanged() || 1718 // We should try to translate even if the language selection has not changed. 1719 shouldTranslateEvenIfLangSelectionHasNotChanged()) 1720 ) { 1721 this.#changeStateTo("translatable", /* retainEntries */ true, { 1722 sourceLanguage, 1723 targetLanguage, 1724 }); 1725 } 1726 } 1727 1728 /** 1729 * Handles changes to the copy button based on the current translation state. 1730 * 1731 * @param {string} phase - The current phase of the translation state. 1732 */ 1733 #handleCopyButtonChanges(phase) { 1734 switch (phase) { 1735 case "closed": 1736 case "translation-failure": 1737 case "translated": { 1738 this.#uncheckCopyButton(); 1739 break; 1740 } 1741 case "idle": 1742 case "init-failure": 1743 case "translatable": 1744 case "translating": 1745 case "unsupported": { 1746 // Do nothing. 1747 break; 1748 } 1749 default: { 1750 throw new Error(`Invalid state change to '${phase}'`); 1751 } 1752 } 1753 } 1754 1755 /** 1756 * Handles changes to the text area's background image based on the current translation state. 1757 * 1758 * @param {string} phase - The current phase of the translation state. 1759 */ 1760 #handleTextAreaBackgroundChanges(phase) { 1761 const { textArea } = this.elements; 1762 switch (phase) { 1763 case "translating": { 1764 textArea.classList.add("translating"); 1765 break; 1766 } 1767 case "closed": 1768 case "idle": 1769 case "init-failure": 1770 case "translation-failure": 1771 case "translatable": 1772 case "translated": 1773 case "unsupported": { 1774 textArea.classList.remove("translating"); 1775 break; 1776 } 1777 default: { 1778 throw new Error(`Invalid state change to '${phase}'`); 1779 } 1780 } 1781 } 1782 1783 /** 1784 * Handles changes to the primary UI components based on the current translation state. 1785 * 1786 * @param {string} phase - The current phase of the translation state. 1787 */ 1788 #handlePrimaryUIChanges(phase) { 1789 switch (phase) { 1790 case "closed": 1791 case "idle": { 1792 this.#displayIdlePlaceholder(); 1793 break; 1794 } 1795 case "init-failure": { 1796 this.#displayInitFailureMessage(); 1797 break; 1798 } 1799 case "translation-failure": { 1800 this.#displayTranslationFailureMessage(); 1801 break; 1802 } 1803 case "translatable": { 1804 // Do nothing. 1805 break; 1806 } 1807 case "translating": { 1808 this.#displayTranslatingPlaceholder(); 1809 break; 1810 } 1811 case "translated": { 1812 this.#displayTranslatedText(); 1813 break; 1814 } 1815 case "unsupported": { 1816 this.#displayUnsupportedLanguageMessage(); 1817 break; 1818 } 1819 default: { 1820 throw new Error(`Invalid state change to '${phase}'`); 1821 } 1822 } 1823 } 1824 1825 /** 1826 * Returns true if the translate-full-page button should be hidden in the current panel view. 1827 * 1828 * @returns {boolean} 1829 */ 1830 #shouldHideTranslateFullPageButton() { 1831 return ( 1832 // Do not offer to translate the full page if it is restricted on this page. 1833 this.#isFullPageTranslationsRestrictedForPage || 1834 // Do not offer to translate the full page if Full-Page Translations is already active. 1835 this.#activeFullPageTranslationsTargetLanguage 1836 ); 1837 } 1838 1839 /** 1840 * Determines whether translation should continue based on panel state and language pair. 1841 * 1842 * @param {number} translationId - The id of the translation request to match. 1843 * @param {string} sourceLanguage - The source language to analyze. 1844 * @param {string} targetLanguage - The target language to analyze. 1845 * 1846 * @returns {boolean} True if translation should continue with the given pair, otherwise false. 1847 */ 1848 #shouldContinueTranslation(translationId, sourceLanguage, targetLanguage) { 1849 return ( 1850 // Continue only if the panel is still open. 1851 this.#isOpen() && 1852 // Continue only if the current translationId matches. 1853 translationId === this.#translationId && 1854 // Continue only if the given language pair is still the actively selected pair. 1855 this.#isSelectedLangPair(sourceLanguage, targetLanguage) 1856 ); 1857 } 1858 1859 /** 1860 * Displays the placeholder text for the translation state's "idle" phase. 1861 */ 1862 #displayIdlePlaceholder() { 1863 this.#showMainContent(); 1864 1865 const { textArea } = SelectTranslationsPanel.elements; 1866 textArea.value = this.#idlePlaceholderText; 1867 this.#updateTextDirection(); 1868 this.#updateConditionalUIEnabledState(); 1869 this.#maybeFocusMenuList(); 1870 } 1871 1872 /** 1873 * Displays the placeholder text for the translation state's "translating" phase. 1874 */ 1875 #displayTranslatingPlaceholder() { 1876 this.#showMainContent(); 1877 1878 const { textArea } = SelectTranslationsPanel.elements; 1879 textArea.value = this.#translatingPlaceholderText; 1880 this.#updateTextDirection(); 1881 this.#updateConditionalUIEnabledState(); 1882 this.#indicateTranslatedTextArea({ overflow: "hidden" }); 1883 } 1884 1885 /** 1886 * Displays the translated text for the translation state's "translated" phase. 1887 */ 1888 #displayTranslatedText() { 1889 this.#showMainContent(); 1890 1891 const { targetLanguage } = this.#getSelectedLanguagePair(); 1892 const { textArea } = SelectTranslationsPanel.elements; 1893 textArea.value = this.getTranslatedText(); 1894 this.#updateTextDirection(targetLanguage); 1895 this.#updateConditionalUIEnabledState(); 1896 this.#indicateTranslatedTextArea({ overflow: "auto" }); 1897 this.#maybeEnableTextAreaResizer(); 1898 1899 const window = 1900 gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal; 1901 window.A11yUtils.announce({ 1902 id: "select-translations-panel-translation-complete-announcement", 1903 }); 1904 } 1905 1906 /** 1907 * Sets attributes on panel elements that are specifically relevant 1908 * to the SelectTranslationsPanel's state. 1909 * 1910 * @param {object} options - Options of which attributes to set. 1911 * @param {Record<string, Element[]>} options.makeHidden - Make these elements hidden. 1912 * @param {Record<string, Element[]>} options.makeVisible - Make these elements visible. 1913 */ 1914 #setPanelElementAttributes({ makeHidden = [], makeVisible = [] }) { 1915 for (const element of makeHidden) { 1916 element.hidden = true; 1917 } 1918 for (const element of makeVisible) { 1919 element.hidden = false; 1920 } 1921 } 1922 1923 /** 1924 * Enables or disables UI components that are conditional on a valid language pair being selected. 1925 */ 1926 #updateConditionalUIEnabledState() { 1927 const { sourceLanguage, targetLanguage } = this.#getSelectedLanguagePair(); 1928 const { 1929 copyButton, 1930 textArea, 1931 translateButton, 1932 translateFullPageButton, 1933 tryAnotherSourceMenuList, 1934 } = this.elements; 1935 1936 const invalidLangPairSelected = !sourceLanguage || !targetLanguage; 1937 const isTranslating = this.phase() === "translating"; 1938 1939 textArea.disabled = invalidLangPairSelected; 1940 copyButton.disabled = invalidLangPairSelected || isTranslating; 1941 translateButton.disabled = !tryAnotherSourceMenuList.value; 1942 translateFullPageButton.disabled = 1943 invalidLangPairSelected || 1944 TranslationsUtils.langTagsMatch(sourceLanguage, targetLanguage) || 1945 this.#shouldHideTranslateFullPageButton(); 1946 } 1947 1948 /** 1949 * Updates the panel UI based on the current phase of the translation state. 1950 */ 1951 #updatePanelUIFromState() { 1952 const phase = this.phase(); 1953 1954 this.#handlePrimaryUIChanges(phase); 1955 this.#handleCopyButtonChanges(phase); 1956 this.#handleTextAreaBackgroundChanges(phase); 1957 1958 this.#mostRecentUIPhase = phase; 1959 } 1960 1961 /** 1962 * Shows the panel's main-content group of elements. 1963 */ 1964 #showMainContent() { 1965 const { 1966 cancelButton, 1967 copyButton, 1968 doneButtonPrimary, 1969 doneButtonSecondary, 1970 initFailureContent, 1971 mainContent, 1972 unsupportedLanguageContent, 1973 textArea, 1974 translateButton, 1975 translateFullPageButton, 1976 translationFailureMessageBar, 1977 tryAgainButton, 1978 } = this.elements; 1979 this.#setPanelElementAttributes({ 1980 makeHidden: [ 1981 cancelButton, 1982 doneButtonSecondary, 1983 initFailureContent, 1984 translateButton, 1985 translationFailureMessageBar, 1986 tryAgainButton, 1987 unsupportedLanguageContent, 1988 ...(this.#shouldHideTranslateFullPageButton() 1989 ? [translateFullPageButton] 1990 : []), 1991 ], 1992 makeVisible: [ 1993 mainContent, 1994 copyButton, 1995 doneButtonPrimary, 1996 textArea, 1997 ...(this.#shouldHideTranslateFullPageButton() 1998 ? [] 1999 : [translateFullPageButton]), 2000 ], 2001 }); 2002 } 2003 2004 /** 2005 * Shows the panel's unsupported-language group of elements. 2006 */ 2007 #showUnsupportedLanguageContent() { 2008 const { 2009 cancelButton, 2010 copyButton, 2011 doneButtonPrimary, 2012 doneButtonSecondary, 2013 initFailureContent, 2014 mainContent, 2015 unsupportedLanguageContent, 2016 translateButton, 2017 translateFullPageButton, 2018 tryAgainButton, 2019 } = this.elements; 2020 this.#setPanelElementAttributes({ 2021 makeHidden: [ 2022 cancelButton, 2023 doneButtonPrimary, 2024 copyButton, 2025 initFailureContent, 2026 mainContent, 2027 translateFullPageButton, 2028 tryAgainButton, 2029 ], 2030 makeVisible: [ 2031 doneButtonSecondary, 2032 translateButton, 2033 unsupportedLanguageContent, 2034 ], 2035 }); 2036 } 2037 2038 /** 2039 * Displays the panel content for when the language dropdowns fail to populate. 2040 */ 2041 #displayInitFailureMessage() { 2042 if (this.#mostRecentUIPhase !== "init-failure") { 2043 TranslationsParent.telemetry() 2044 .selectTranslationsPanel() 2045 .onInitializationFailureMessage(); 2046 } 2047 2048 const { 2049 cancelButton, 2050 copyButton, 2051 doneButtonPrimary, 2052 doneButtonSecondary, 2053 initFailureContent, 2054 mainContent, 2055 unsupportedLanguageContent, 2056 translateButton, 2057 translateFullPageButton, 2058 tryAgainButton, 2059 } = this.elements; 2060 this.#setPanelElementAttributes({ 2061 makeHidden: [ 2062 doneButtonPrimary, 2063 doneButtonSecondary, 2064 copyButton, 2065 mainContent, 2066 translateButton, 2067 translateFullPageButton, 2068 unsupportedLanguageContent, 2069 ], 2070 makeVisible: [initFailureContent, cancelButton, tryAgainButton], 2071 }); 2072 tryAgainButton.setAttribute( 2073 "aria-describedby", 2074 "select-translations-panel-init-failure-message-bar" 2075 ); 2076 tryAgainButton.focus({ focusVisible: false }); 2077 } 2078 2079 /** 2080 * Displays the panel content for when a translation fails to complete. 2081 */ 2082 #displayTranslationFailureMessage() { 2083 if (this.#mostRecentUIPhase !== "translation-failure") { 2084 const { sourceLanguage, targetLanguage } = 2085 this.#getSelectedLanguagePair(); 2086 TranslationsParent.telemetry() 2087 .selectTranslationsPanel() 2088 .onTranslationFailureMessage({ sourceLanguage, targetLanguage }); 2089 } 2090 2091 const { 2092 cancelButton, 2093 copyButton, 2094 doneButtonPrimary, 2095 doneButtonSecondary, 2096 initFailureContent, 2097 mainContent, 2098 textArea, 2099 translateButton, 2100 translateFullPageButton, 2101 translationFailureMessageBar, 2102 tryAgainButton, 2103 unsupportedLanguageContent, 2104 } = this.elements; 2105 this.#setPanelElementAttributes({ 2106 makeHidden: [ 2107 doneButtonPrimary, 2108 doneButtonSecondary, 2109 copyButton, 2110 initFailureContent, 2111 translateButton, 2112 translateFullPageButton, 2113 textArea, 2114 unsupportedLanguageContent, 2115 ], 2116 makeVisible: [ 2117 cancelButton, 2118 mainContent, 2119 translationFailureMessageBar, 2120 tryAgainButton, 2121 ], 2122 }); 2123 tryAgainButton.setAttribute( 2124 "aria-describedby", 2125 "select-translations-panel-translation-failure-message-bar" 2126 ); 2127 tryAgainButton.focus({ focusVisible: false }); 2128 } 2129 2130 /** 2131 * Displays the panel's unsupported language message bar, showing 2132 * the panel's unsupported-language elements. 2133 */ 2134 #displayUnsupportedLanguageMessage() { 2135 const { detectedLanguage } = this.#translationState; 2136 2137 if (this.#mostRecentUIPhase !== "unsupported") { 2138 const { docLangTag } = this.#getLanguageInfo(); 2139 TranslationsParent.telemetry() 2140 .selectTranslationsPanel() 2141 .onUnsupportedLanguageMessage({ docLangTag, detectedLanguage }); 2142 } 2143 2144 const { unsupportedLanguageMessageBar, tryAnotherSourceMenuList } = 2145 this.elements; 2146 const languageDisplayNames = 2147 TranslationsParent.createLanguageDisplayNames(); 2148 try { 2149 const language = languageDisplayNames.of(detectedLanguage); 2150 if (language) { 2151 document.l10n.setAttributes( 2152 unsupportedLanguageMessageBar, 2153 "select-translations-panel-unsupported-language-message-known", 2154 { language } 2155 ); 2156 } else { 2157 // Will be immediately caught. 2158 throw new Error(); 2159 } 2160 } catch { 2161 // Either displayNames.of() threw, or we threw due to no display name found. 2162 // In either case, localize the message for an unknown language. 2163 document.l10n.setAttributes( 2164 unsupportedLanguageMessageBar, 2165 "select-translations-panel-unsupported-language-message-unknown" 2166 ); 2167 } 2168 this.#updateConditionalUIEnabledState(); 2169 this.#showUnsupportedLanguageContent(); 2170 this.#maybeFocusMenuList(tryAnotherSourceMenuList); 2171 } 2172 2173 /** 2174 * Sets the text direction attribute in the text areas based on the specified language. 2175 * Uses the given language tag if provided, otherwise uses the current app locale. 2176 * 2177 * @param {string} [langTag] - The language tag to determine text direction. 2178 */ 2179 #updateTextDirection(langTag) { 2180 const { textArea } = this.elements; 2181 if (langTag) { 2182 const scriptDirection = Services.intl.getScriptDirection(langTag); 2183 textArea.setAttribute("dir", scriptDirection); 2184 } else { 2185 textArea.removeAttribute("dir"); 2186 } 2187 } 2188 2189 /** 2190 * Requests a translations port for a given language pair. 2191 * 2192 * @param {LanguagePair} languagePair 2193 * @returns {Promise<MessagePort | undefined>} The message port promise. 2194 */ 2195 async #requestTranslationsPort(languagePair) { 2196 return TranslationsParent.requestTranslationsPort(languagePair); 2197 } 2198 2199 /** 2200 * Retrieves the existing translator for the specified language pair if it matches, 2201 * otherwise creates a new translator. 2202 * 2203 * @param {LanguagePair} languagePair 2204 * 2205 * @returns {Promise<Translator>} A promise that resolves to a `Translator` instance for the given language pair. 2206 */ 2207 async #createTranslator(languagePair) { 2208 this.console?.log( 2209 `Creating new Translator (${TranslationsUtils.serializeLanguagePair(languagePair)})` 2210 ); 2211 2212 const translator = await Translator.create({ 2213 languagePair, 2214 requestTranslationsPort: this.#requestTranslationsPort, 2215 allowSameLanguage: true, 2216 activeRequestCapacity: 1, 2217 }); 2218 2219 return translator; 2220 } 2221 2222 /** 2223 * Initiates the translation process if the panel state and selected languages 2224 * meet the conditions for translation. 2225 */ 2226 #maybeRequestTranslation() { 2227 if (this.#isClosed()) { 2228 return; 2229 } 2230 2231 const languagePair = this.#getSelectedLanguagePair(); 2232 const { sourceLanguage, targetLanguage } = languagePair; 2233 this.#maybeChangeStateToTranslatable(sourceLanguage, targetLanguage); 2234 2235 if (this.phase() !== "translatable") { 2236 return; 2237 } 2238 2239 const { docLangTag, topPreferredLanguage } = this.#getLanguageInfo(); 2240 const sourceText = this.getSourceText(); 2241 const translationId = ++this.#translationId; 2242 2243 TranslationsParent.storeMostRecentTargetLanguage(targetLanguage); 2244 2245 this.#createTranslator(languagePair) 2246 .then(translator => { 2247 if ( 2248 this.#shouldContinueTranslation( 2249 translationId, 2250 sourceLanguage, 2251 targetLanguage 2252 ) 2253 ) { 2254 this.#changeStateToTranslating(); 2255 return translator.translate(this.getSourceText()); 2256 } 2257 return null; 2258 }) 2259 .then(translatedText => { 2260 if ( 2261 translatedText && 2262 this.#shouldContinueTranslation( 2263 translationId, 2264 sourceLanguage, 2265 targetLanguage 2266 ) 2267 ) { 2268 this.#changeStateToTranslated(translatedText); 2269 } 2270 }) 2271 .catch(error => { 2272 this.console?.error(error); 2273 this.#changeStateToTranslationFailure(); 2274 }); 2275 2276 try { 2277 if (!this.#sourceTextWordCount) { 2278 this.#sourceTextWordCount = TranslationsParent.countWords( 2279 sourceLanguage, 2280 sourceText 2281 ); 2282 } 2283 } catch (error) { 2284 // Failed to create an Intl.Segmenter for the sourceLanguage. 2285 // Continue on to report undefined to telemetry. 2286 this.console?.warn(error); 2287 } 2288 2289 TranslationsParent.telemetry().onTranslate({ 2290 docLangTag, 2291 sourceLanguage, 2292 targetLanguage, 2293 topPreferredLanguage, 2294 autoTranslate: false, 2295 requestTarget: "select", 2296 sourceTextCodeUnits: sourceText.length, 2297 sourceTextWordCount: this.#sourceTextWordCount, 2298 }); 2299 } 2300 2301 /** 2302 * Reports to telemetry whether the source language or the target language has 2303 * changed based on whether the currently selected language is different 2304 * than the corresponding language that is stored in the panel's state. 2305 */ 2306 #maybeReportLanguageChangeToTelemetry() { 2307 const previous = this.#translationState; 2308 const selected = this.#getSelectedLanguagePair(); 2309 2310 if ( 2311 !TranslationsUtils.langTagsMatch( 2312 selected.sourceLanguage, 2313 previous.sourceLanguage 2314 ) 2315 ) { 2316 const { docLangTag } = this.#getLanguageInfo(); 2317 TranslationsParent.telemetry() 2318 .selectTranslationsPanel() 2319 .onChangeFromLanguage({ 2320 previousLangTag: previous.sourceLanguage, 2321 currentLangTag: selected.sourceLanguage, 2322 docLangTag, 2323 }); 2324 } 2325 if ( 2326 !TranslationsUtils.langTagsMatch( 2327 selected.targetLanguage, 2328 previous.targetLanguage 2329 ) 2330 ) { 2331 TranslationsParent.telemetry() 2332 .selectTranslationsPanel() 2333 .onChangeToLanguage(selected.targetLanguage); 2334 } 2335 } 2336 2337 /** 2338 * Attaches event listeners to the target element for initiating translation on specified event types. 2339 * 2340 * @param {string[]} eventTypes - An array of event types to listen for. 2341 * @param {object} target - The target element to attach event listeners to. 2342 * @throws {Error} If an unrecognized event type is provided. 2343 */ 2344 #maybeTranslateOnEvents(eventTypes, target) { 2345 if (!target.translationListenerCallbacks) { 2346 target.translationListenerCallbacks = []; 2347 } 2348 if (target.translationListenerCallbacks.length === 0) { 2349 for (const eventType of eventTypes) { 2350 let callback; 2351 switch (eventType) { 2352 case "focus": 2353 case "popuphidden": { 2354 callback = () => { 2355 this.#maybeReportLanguageChangeToTelemetry(); 2356 this.#maybeRequestTranslation(); 2357 }; 2358 break; 2359 } 2360 case "keypress": { 2361 callback = event => { 2362 if (event.key === "Enter") { 2363 this.#maybeReportLanguageChangeToTelemetry(); 2364 this.#maybeRequestTranslation(); 2365 } 2366 }; 2367 break; 2368 } 2369 default: { 2370 throw new Error( 2371 `Invalid translation event type given: '${eventType}` 2372 ); 2373 } 2374 } 2375 target.addEventListener(eventType, callback); 2376 target.translationListenerCallbacks.push({ eventType, callback }); 2377 } 2378 } 2379 } 2380 2381 /** 2382 * Removes all translation event listeners from any panel elements that would have one. 2383 */ 2384 #removeActiveTranslationListeners() { 2385 const { fromMenuList, fromMenuPopup, textArea, toMenuList, toMenuPopup } = 2386 SelectTranslationsPanel.elements; 2387 this.#removeTranslationListenersFrom(fromMenuList); 2388 this.#removeTranslationListenersFrom(fromMenuPopup); 2389 this.#removeTranslationListenersFrom(textArea); 2390 this.#removeTranslationListenersFrom(toMenuList); 2391 this.#removeTranslationListenersFrom(toMenuPopup); 2392 } 2393 2394 /** 2395 * Removes all translation event listeners from the target element. 2396 * 2397 * @param {Element} target - The element from which event listeners are to be removed. 2398 */ 2399 #removeTranslationListenersFrom(target) { 2400 if (!target.translationListenerCallbacks) { 2401 return; 2402 } 2403 2404 for (const { eventType, callback } of target.translationListenerCallbacks) { 2405 target.removeEventListener(eventType, callback); 2406 } 2407 2408 target.translationListenerCallbacks = []; 2409 } 2410 })();