Bug 1870316 - SelectTranslationsPanel Translate on open r=translations-reviewers,fluent-reviewers,bolsson,gregtatum

Differential Revision: https://phabricator.services.mozilla.com/D204978
This commit is contained in:
Erik Nordin
2024-04-06 02:07:28 +00:00
parent 12e2f6b52d
commit 94beb308b7
9 changed files with 783 additions and 46 deletions

View File

@@ -2517,7 +2517,11 @@ class nsContextMenu {
* @param {Event} event - The triggering event for opening the panel. * @param {Event} event - The triggering event for opening the panel.
*/ */
openSelectTranslationsPanel(event) { openSelectTranslationsPanel(event) {
SelectTranslationsPanel.open(event, this.#translationsLangPairPromise); SelectTranslationsPanel.open(
event,
this.#getTextToTranslate(),
this.#translationsLangPairPromise
);
} }
/** /**
@@ -2567,6 +2571,17 @@ class nsContextMenu {
); );
} }
/**
* Fetches text for translation, prioritizing selected text over link text.
*
* @returns {string} The text to translate.
*/
#getTextToTranslate() {
return this.isTextSelected
? this.selectionInfo.fullText.trim()
: this.linkTextStr.trim();
}
/** /**
* Displays or hides the translate-selection item in the context menu. * Displays or hides the translate-selection item in the context menu.
*/ */
@@ -2581,10 +2596,7 @@ class nsContextMenu {
"browser.translations.select.enable" "browser.translations.select.enable"
); );
// Selected text takes precedence over link text. const textToTranslate = this.#getTextToTranslate();
const textToTranslate = this.isTextSelected
? this.selectedText.trim()
: this.linkTextStr.trim();
translateSelectionItem.hidden = translateSelectionItem.hidden =
// Only show the item if the feature is enabled. // Only show the item if the feature is enabled.

View File

@@ -9,7 +9,9 @@
role="alertdialog" role="alertdialog"
noautofocus="true" noautofocus="true"
aria-labelledby="translations-panel-header" aria-labelledby="translations-panel-header"
orient="vertical"> orient="vertical"
onpopupshown="SelectTranslationsPanel.handlePanelPopupShownEvent(event)"
onpopuphidden="SelectTranslationsPanel.handlePanelPopupHiddenEvent(event)">
<panelmultiview id="select-translations-panel-multiview" mainViewId="select-translations-panel-view-default"> <panelmultiview id="select-translations-panel-multiview" mainViewId="select-translations-panel-view-default">
<panelview id="select-translations-panel-view-default" <panelview id="select-translations-panel-view-default"
class="PanelUI-subView translations-panel-view" class="PanelUI-subView translations-panel-view"
@@ -73,7 +75,7 @@
</vbox> </vbox>
<vbox class="select-translations-panel-content"> <vbox class="select-translations-panel-content">
<html:textarea id="select-translations-panel-translation-area" <html:textarea id="select-translations-panel-translation-area"
data-l10n-id="select-translations-panel-placeholder-text" data-l10n-id="select-translations-panel-idle-placeholder-text"
readonly="true" readonly="true"
tabindex="0"> tabindex="0">
</html:textarea> </html:textarea>

View File

@@ -4,11 +4,16 @@
/* eslint-env mozilla/browser-window */ /* eslint-env mozilla/browser-window */
/**
* @typedef {import("../../../../toolkit/components/translations/translations").SelectTranslationsPanelState} SelectTranslationsPanelState
*/
ChromeUtils.defineESModuleGetters(this, { ChromeUtils.defineESModuleGetters(this, {
LanguageDetector: LanguageDetector:
"resource://gre/modules/translation/LanguageDetector.sys.mjs", "resource://gre/modules/translation/LanguageDetector.sys.mjs",
TranslationsPanelShared: TranslationsPanelShared:
"chrome://browser/content/translations/TranslationsPanelShared.sys.mjs", "chrome://browser/content/translations/TranslationsPanelShared.sys.mjs",
Translator: "chrome://global/content/translations/Translator.mjs",
}); });
/** /**
@@ -39,6 +44,20 @@ var SelectTranslationsPanel = new (class {
return this.#console; return this.#console;
} }
/**
* The localized placeholder text to display when idle.
*
* @type {string}
*/
#idlePlaceholderText;
/**
* The localized placeholder text to display when translating.
*
* @type {string}
*/
#translatingPlaceholderText;
/** /**
* Where the lazy elements are stored. * Where the lazy elements are stored.
* *
@@ -46,6 +65,29 @@ var SelectTranslationsPanel = new (class {
*/ */
#lazyElements; #lazyElements;
/**
* The internal state of the SelectTranslationsPanel.
*
* @type {SelectTranslationsPanelState}
*/
#translationState = { phase: "closed" };
/**
* The Translator for the current language pair.
*
* @type {Translator}
*/
#translator;
/**
* An Id that increments with each translation, used to help keep track
* of whether an active translation request continue its progression or
* stop due to the existence of a newer translation request.
*
* @type {number}
*/
#translationId = 0;
/** /**
* Lazily creates the dom elements, and lazily selects them. * Lazily creates the dom elements, and lazily selects them.
* *
@@ -79,7 +121,7 @@ var SelectTranslationsPanel = new (class {
fromMenuList: "select-translations-panel-from", fromMenuList: "select-translations-panel-from",
header: "select-translations-panel-header", header: "select-translations-panel-header",
multiview: "select-translations-panel-multiview", multiview: "select-translations-panel-multiview",
textArea: "select-translations-panel-translation-area", translatedTextArea: "select-translations-panel-translation-area",
toLabel: "select-translations-panel-to-label", toLabel: "select-translations-panel-to-label",
toMenuList: "select-translations-panel-to", toMenuList: "select-translations-panel-to",
translateFullPageButton: translateFullPageButton:
@@ -182,18 +224,43 @@ var SelectTranslationsPanel = new (class {
} }
/** /**
* Opens the panel and populates the currently selected fromLang and toLang based * Opens the panel, ensuring the panel's UI and state are initialized correctly.
* on the result of the langPairPromise.
* *
* @param {Event} event - The triggering event for opening the panel. * @param {Event} event - The triggering event for opening the panel.
* @param {string} sourceText - The text to translate.
* @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns. * @param {Promise} langPairPromise - Promise resolving to language pair data for initializing dropdowns.
*
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async open(event, langPairPromise) { async open(event, sourceText, langPairPromise) {
this.console?.log("Showing a translation panel."); if (this.#isOpen()) {
return;
}
this.#registerSourceText(sourceText);
await this.#ensureLangListsBuilt(); await this.#ensureLangListsBuilt();
await this.#initializeLanguageMenuLists(langPairPromise);
await Promise.all([
this.#cachePlaceholderText(),
this.#initializeLanguageMenuLists(langPairPromise),
]);
this.#displayIdlePlaceholder();
this.#maybeRequestTranslation();
await this.#openPopup(event);
}
/**
* Opens a the panel popup.
*
* @param {Event} event - The event that triggers the popup opening.
*
* @returns {Promise<void>}
*/
async #openPopup(event) {
this.console?.log("Showing SelectTranslationsPanel");
const { panel } = this.elements;
// TODO(Bug 1878721) Rework the logic of where to open the panel. // TODO(Bug 1878721) Rework the logic of where to open the panel.
// //
@@ -201,17 +268,69 @@ var SelectTranslationsPanel = new (class {
// AppMenu Button, but it will eventually need to open near // AppMenu Button, but it will eventually need to open near
// to the selected content. // to the selected content.
const appMenuButton = document.getElementById("PanelUI-menu-button"); const appMenuButton = document.getElementById("PanelUI-menu-button");
const { panel, textArea } = this.elements;
panel.addEventListener("popupshown", () => textArea.focus(), {
once: true,
});
await PanelMultiView.openPopup(panel, appMenuButton, { await PanelMultiView.openPopup(panel, appMenuButton, {
position: "bottomright topright", position: "bottomright topright",
triggerEvent: event, triggerEvent: event,
}).catch(error => this.console?.error(error)); }).catch(error => this.console?.error(error));
} }
/**
* Adds the source text to the translation state.
*
* @param {string} sourceText - The text to translate.
*
* @returns {Promise<void>}
*/
#registerSourceText(sourceText) {
this.#changeStateTo("idle", /* retainEntries */ false, {
sourceText,
});
}
/**
* Caches the localized text to use as placeholders.
*/
async #cachePlaceholderText() {
const [idleText, translatingText] = await document.l10n.formatValues([
{ id: "select-translations-panel-idle-placeholder-text" },
{ id: "select-translations-panel-translating-placeholder-text" },
]);
this.#idlePlaceholderText = idleText;
this.#translatingPlaceholderText = translatingText;
}
/**
* Handles events when a popup is shown within the panel, including showing
* the panel itself.
*
* @param {Event} event - The event that triggered the popup to show.
*/
handlePanelPopupShownEvent(event) {
const { panel } = this.elements;
switch (event.target.id) {
case panel.id: {
this.#updatePanelUIFromState();
break;
}
}
}
/**
* Handles events when a popup is closed within the panel, including closing
* the panel itself.
*
* @param {Event} event - The event that triggered the popup to close.
*/
handlePanelPopupHiddenEvent(event) {
const { panel } = this.elements;
switch (event.target.id) {
case panel.id: {
this.#changeStateToClosed();
break;
}
}
}
/** /**
* Clears the selected language and ensures that the menu list displays * Clears the selected language and ensures that the menu list displays
* the proper placeholder text. * the proper placeholder text.
@@ -223,4 +342,419 @@ var SelectTranslationsPanel = new (class {
document.l10n.setAttributes(menuList, "translations-panel-choose-language"); document.l10n.setAttributes(menuList, "translations-panel-choose-language");
await document.l10n.translateElements([menuList]); await document.l10n.translateElements([menuList]);
} }
/**
* Focuses the translated-text area and sets its overflow to auto post-animation.
*/
#indicateTranslatedTextArea() {
const { translatedTextArea } = this.elements;
translatedTextArea.focus({ focusVisible: true });
requestAnimationFrame(() => {
// We want to set overflow to auto as the final animation, because if it is
// set before the translated text is displayed, then the scrollTop will
// move to the bottom as the text is populated.
//
// Setting scrollTop = 0 on its own works, but it sometimes causes an animation
// of the text jumping from the bottom to the top. It looks a lot cleaner to
// disable overflow before rendering the text, then re-enable it after it renders.
requestAnimationFrame(() => {
translatedTextArea.style.overflow = "auto";
translatedTextArea.scrollTop = 0;
});
});
}
/**
* Checks if the given language pair matches the panel's currently selected language pair.
*
* @param {string} fromLanguage - The from-language to compare.
* @param {string} toLanguage - The to-language to compare.
*
* @returns {boolean} - True if the given language pair matches the selected languages in the panel UI, otherwise false.
*/
#isSelectedLangPair(fromLanguage, toLanguage) {
const { fromLanguage: selectedFromLang, toLanguage: selectedToLang } =
this.#getSelectedLanguagePair();
return fromLanguage === selectedFromLang && toLanguage === selectedToLang;
}
/**
* Checks if the translator's language configuration matches the given language pair.
*
* @param {string} fromLanguage - The from-language to compare.
* @param {string} toLanguage - The to-language to compare.
*
* @returns {boolean} - True if the translator's languages match the given pair, otherwise false.
*/
#translatorMatchesLangPair(fromLanguage, toLanguage) {
return (
this.#translator?.fromLanguage === fromLanguage &&
this.#translator?.toLanguage === toLanguage
);
}
/**
* Retrieves the currently selected language pair from the menu lists.
*
* @returns {{fromLanguage: string, toLanguage: string}} An object containing the selected languages.
*/
#getSelectedLanguagePair() {
const { fromMenuList, toMenuList } = this.elements;
return {
fromLanguage: fromMenuList.value,
toLanguage: toMenuList.value,
};
}
/**
* Retrieves the source text from the translation state.
* This value is not available when the panel is closed.
*
* @returns {string | undefined} The source text.
*/
getSourceText() {
return this.#translationState?.sourceText;
}
/**
* Retrieves the source text from the translation state.
* This value is only available in the translated phase.
*
* @returns {string | undefined} The translated text.
*/
getTranslatedText() {
return this.#translationState?.translatedText;
}
/**
* Retrieves the current phase of the translation state.
*
* @returns {SelectTranslationsPanelState}
*/
#phase() {
return this.#translationState.phase;
}
/**
* @returns {boolean} True if the panel is open, otherwise false.
*/
#isOpen() {
return this.#phase() !== "closed";
}
/**
* @returns {boolean} True if the panel is closed, otherwise false.
*/
#isClosed() {
return this.#phase() === "closed";
}
/**
* Changes the translation state to a new phase with options to retain or overwrite existing entries.
*
* @param {SelectTranslationsPanelState} phase - The new phase to transition to.
* @param {boolean} [retainEntries] - Whether to retain existing state entries that are not overwritten.
* @param {object | null} [data=null] - Additional data to merge into the state.
* @throws {Error} If an invalid phase is specified.
*/
#changeStateTo(phase, retainEntries, data = null) {
switch (phase) {
case "closed":
case "idle":
case "translatable":
case "translating":
case "translated": {
break;
}
default: {
throw new Error(`Invalid state change to '${phase}'`);
}
}
const previousPhase = this.#phase();
if (data && retainEntries) {
// Change the phase and apply new entries from data, but retain non-overwritten entries from previous state.
this.#translationState = { ...this.#translationState, phase, ...data };
} else if (data) {
// Change the phase and apply new entries from data, but drop any entries that are not overwritten by data.
this.#translationState = { phase, ...data };
} else if (retainEntries) {
// Change only the phase and retain all entries from previous data.
this.#translationState.phase = phase;
} else {
// Change the phase and delete all entries from previous data.
this.#translationState = { phase };
}
if (previousPhase === this.#phase()) {
// Do not continue on to update the UI because the phase didn't change.
return;
}
const { fromLanguage, toLanguage } = this.#translationState;
this.console?.debug(
`SelectTranslationsPanel (${fromLanguage ? fromLanguage : "??"}-${
toLanguage ? toLanguage : "??"
}) state change (${previousPhase} => ${phase})`
);
this.#updatePanelUIFromState();
}
/**
* Changes the phase to closed, discarding any entries in the translation state.
*/
#changeStateToClosed() {
this.#changeStateTo("closed", /* retainEntries */ false);
}
/**
* Changes the phase from "translatable" to "translating".
*
* @throws {Error} If the current state is not "translatable".
*/
#changeStateToTranslating() {
const phase = this.#phase();
if (phase !== "translatable") {
throw new Error(`Invalid state change (${phase} => translating)`);
}
this.#changeStateTo("translating", /* retainEntries */ true);
}
/**
* Changes the phase from "translating" to "translated".
*
* @throws {Error} If the current state is not "translating".
*/
#changeStateToTranslated(translatedText) {
const phase = this.#phase();
if (phase !== "translating") {
throw new Error(`Invalid state change (${phase} => translated)`);
}
this.#changeStateTo("translated", /* retainEntries */ true, {
translatedText,
});
}
/**
* Transitions the phase of the state based on the given language pair.
*
* @param {string} fromLanguage - The BCP-47 from-language tag.
* @param {string} toLanguage - The BCP-47 to-language tag.
*
* @returns {SelectTranslationsPanelState} The new phase of the translation state.
*/
#changeStateByLanguagePair(fromLanguage, toLanguage) {
const {
phase: previousPhase,
fromLanguage: previousFromLanguage,
toLanguage: previousToLanguage,
} = this.#translationState;
let nextPhase = "translatable";
if (
// No from-language is selected, so we cannot translate.
!fromLanguage ||
// No to-language is selected, so we cannot translate.
!toLanguage ||
// The same language has been selected, so we cannot translate.
fromLanguage === toLanguage
) {
nextPhase = "idle";
} else if (
// The languages have not changed, so there is nothing to do.
previousFromLanguage === fromLanguage &&
previousToLanguage === toLanguage
) {
nextPhase = previousPhase;
}
this.#changeStateTo(nextPhase, /* retainEntries */ true, {
fromLanguage,
toLanguage,
});
return nextPhase;
}
/**
* Determines whether translation should continue based on panel state and language pair.
*
* @param {number} translationId - The id of the translation request to match.
* @param {string} fromLanguage - The from-language to analyze.
* @param {string} toLanguage - The to-language to analyze.
*
* @returns {boolean} True if translation should continue with the given pair, otherwise false.
*/
#shouldContinueTranslation(translationId, fromLanguage, toLanguage) {
return (
// Continue only if the panel is still open.
this.#isOpen() &&
// Continue only if the current translationId matches.
translationId === this.#translationId &&
// Continue only if the given language pair is still the actively selected pair.
this.#isSelectedLangPair(fromLanguage, toLanguage) &&
// Continue only if the given language pair matches the current translator.
this.#translatorMatchesLangPair(fromLanguage, toLanguage)
);
}
/**
* Displays text in the translated-text area.
*
* @param {string} textToDisplay - The text to be shown in the translated text area.
*/
#showTranslatedTextArea(textToDisplay) {
const { translatedTextArea } = this.elements;
translatedTextArea.value = textToDisplay;
}
/**
* Displays the placeholder text for the translation state's "idle" phase.
*/
#displayIdlePlaceholder() {
this.#showTranslatedTextArea(this.#idlePlaceholderText);
}
/**
* Displays the placeholder text for the translation state's "translating" phase.
*/
#displayTranslatingPlaceholder() {
const { translatedTextArea } = SelectTranslationsPanel.elements;
translatedTextArea.style.overflow = "hidden";
this.#showTranslatedTextArea(this.#translatingPlaceholderText);
}
/**
* Displays the translated text for the translation state's "translated" phase.
*/
#displayTranslatedText() {
const translatedText = this.getTranslatedText();
this.#showTranslatedTextArea(translatedText);
requestAnimationFrame(() => {
requestAnimationFrame(() => this.#indicateTranslatedTextArea());
});
}
/**
* Updates the panel UI based on the current phase of the translation state.
*/
#updatePanelUIFromState() {
switch (this.#phase()) {
case "idle": {
this.#displayIdlePlaceholder();
break;
}
case "translating": {
this.#displayTranslatingPlaceholder();
break;
}
case "translated": {
this.#displayTranslatedText();
break;
}
}
}
/**
* Requests a translations port for a given language pair.
*
* @param {string} fromLanguage - The from-language.
* @param {string} toLanguage - The to-language.
*
* @returns {Promise<MessagePort | undefined>} The message port promise.
*/
async #requestTranslationsPort(fromLanguage, toLanguage) {
const innerWindowId =
gBrowser.selectedBrowser.browsingContext.top.embedderElement
.innerWindowID;
if (!innerWindowId) {
return undefined;
}
const port = await TranslationsParent.requestTranslationsPort(
innerWindowId,
fromLanguage,
toLanguage
);
return port;
}
/**
* Retrieves the existing translator for the specified language pair if it matches,
* otherwise creates a new translator.
*
* @param {string} fromLanguage - The source language code.
* @param {string} toLanguage - The target language code.
*
* @returns {Promise<Translator>} A promise that resolves to a `Translator` instance for the given language pair.
*/
async #getOrCreateTranslator(fromLanguage, toLanguage) {
if (this.#translatorMatchesLangPair(fromLanguage, toLanguage)) {
return this.#translator;
}
this.console?.log(
`Creating new Translator (${fromLanguage}-${toLanguage})`
);
if (this.#translator) {
this.#translator.destroy();
this.#translator = null;
}
this.#translator = await Translator.create(
fromLanguage,
toLanguage,
this.#requestTranslationsPort
);
return this.#translator;
}
/**
* Initiates the translation process if the panel state and selected languages
* meet the conditions for translation.
*/
#maybeRequestTranslation() {
if (this.#isClosed()) {
return;
}
const { fromLanguage, toLanguage } = this.#getSelectedLanguagePair();
const nextState = this.#changeStateByLanguagePair(fromLanguage, toLanguage);
if (nextState !== "translatable") {
return;
}
const translationId = ++this.#translationId;
this.#getOrCreateTranslator(fromLanguage, toLanguage)
.then(translator => {
if (
this.#shouldContinueTranslation(
translationId,
fromLanguage,
toLanguage
)
) {
this.#changeStateToTranslating();
return translator.translate(this.getSourceText());
}
return null;
})
.then(translatedText => {
if (
translatedText &&
this.#shouldContinueTranslation(
translationId,
fromLanguage,
toLanguage
)
) {
this.#changeStateToTranslated(translatedText);
} else if (this.#isOpen()) {
this.#changeStateTo("idle", /* retainEntires */ false, {
sourceText: this.getSourceText(),
});
}
})
.catch(error => this.console?.error(error));
}
})(); })();

View File

@@ -108,3 +108,5 @@ skip-if = ["os == 'linux' && !debug"] # Bug 1863227
["browser_translations_select_context_menu_with_no_text_selected.js"] ["browser_translations_select_context_menu_with_no_text_selected.js"]
["browser_translations_select_context_menu_with_text_selected.js"] ["browser_translations_select_context_menu_with_text_selected.js"]
["browser_translations_select_panel_translate_on_open.js"]

View File

@@ -0,0 +1,58 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* This test case tests the case of opening the SelectTranslationsPanel to a valid
* language pair from a short selection of text, which should trigger a translation
* on panel open.
*/
add_task(
async function test_select_translations_panel_translate_sentence_on_open() {
const { cleanup, runInPage, resolveDownloads } = await loadTestPage({
page: SELECT_TEST_PAGE_URL,
languagePairs: LANGUAGE_PAIRS,
prefs: [["browser.translations.select.enable", true]],
});
await SelectTranslationsTestUtils.openPanel(runInPage, {
selectFrenchSentence: true,
openAtFrenchSentence: true,
expectedFromLanguage: "fr",
expectedToLanguage: "en",
downloadHandler: resolveDownloads,
onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated,
});
await SelectTranslationsTestUtils.clickDoneButton();
await cleanup();
}
);
/**
* This test case tests the case of opening the SelectTranslationsPanel to a valid
* language pair from hyperlink text, which should trigger a translation on panel open.
*/
add_task(
async function test_select_translations_panel_translate_link_text_on_open() {
const { cleanup, runInPage, resolveDownloads } = await loadTestPage({
page: SELECT_TEST_PAGE_URL,
languagePairs: LANGUAGE_PAIRS,
prefs: [["browser.translations.select.enable", true]],
});
await SelectTranslationsTestUtils.openPanel(runInPage, {
openAtSpanishHyperlink: true,
expectedFromLanguage: "es",
expectedToLanguage: "en",
downloadHandler: resolveDownloads,
onOpenPanel: SelectTranslationsTestUtils.assertPanelViewTranslated,
});
await SelectTranslationsTestUtils.clickDoneButton();
await cleanup();
}
);

View File

@@ -1415,6 +1415,22 @@ class SelectTranslationsTestUtils {
} }
} }
/**
* Elements that should always be visible in the SelectTranslationsPanel.
*/
static #alwaysPresentElements = {
betaIcon: true,
copyButton: true,
doneButton: true,
fromLabel: true,
fromMenuList: true,
header: true,
multiview: true,
toLabel: true,
toMenuList: true,
translatedTextArea: true,
};
/** /**
* Asserts that for each provided expectation, the visible state of the corresponding * Asserts that for each provided expectation, the visible state of the corresponding
* element in FullPageTranslationsPanel.elements both exists and matches the visibility expectation. * element in FullPageTranslationsPanel.elements both exists and matches the visibility expectation.
@@ -1426,16 +1442,7 @@ class SelectTranslationsTestUtils {
SharedTranslationsTestUtils._assertPanelElementVisibility( SharedTranslationsTestUtils._assertPanelElementVisibility(
SelectTranslationsPanel.elements, SelectTranslationsPanel.elements,
{ {
betaIcon: false, ...SelectTranslationsTestUtils.#alwaysPresentElements,
copyButton: false,
doneButton: false,
fromLabel: false,
fromMenuList: false,
header: false,
textArea: false,
toLabel: false,
toMenuList: false,
translateFullPageButton: false,
// Overwrite any of the above defaults with the passed in expectations. // Overwrite any of the above defaults with the passed in expectations.
...expectations, ...expectations,
} }
@@ -1455,25 +1462,60 @@ class SelectTranslationsTestUtils {
} }
/** /**
* Asserts that panel element visibility matches the default panel view. * Asserts that the SelectTranslationsPanel UI matches the expected
* state when the panel has completed its translation.
*/ */
static assertPanelViewDefault() { static assertPanelViewTranslated() {
info("Checking that the select-translations panel shows the default view");
SelectTranslationsTestUtils.#assertPanelMainViewId( SelectTranslationsTestUtils.#assertPanelMainViewId(
"select-translations-panel-view-default" "select-translations-panel-view-default"
); );
SelectTranslationsTestUtils.#assertPanelElementVisibility({ SelectTranslationsTestUtils.#assertPanelElementVisibility({
betaIcon: true, ...SelectTranslationsTestUtils.#alwaysPresentElements,
fromLabel: true,
fromMenuList: true,
header: true,
textArea: true,
toLabel: true,
toMenuList: true,
copyButton: true,
doneButton: true,
translateFullPageButton: true,
}); });
SelectTranslationsTestUtils.#assertPanelHasTranslatedText();
SelectTranslationsTestUtils.#assertPanelTextAreaOverflow();
}
/**
* Asserts that the SelectTranslationsPanel translated text area is
* both scrollable and scrolled to the top.
*/
static #assertPanelTextAreaOverflow() {
const { translatedTextArea } = SelectTranslationsPanel.elements;
is(
translatedTextArea.style.overflow,
"auto",
"The translated-text area should be scrollable."
);
if (translatedTextArea.scrollHeight > translatedTextArea.clientHeight) {
is(
translatedTextArea.scrollTop,
0,
"The translated-text area should be scrolled to the top."
);
}
}
/**
* Asserts that the SelectTranslationsPanel UI contains the
* translated text.
*/
static #assertPanelHasTranslatedText() {
const { translatedTextArea, fromMenuList, toMenuList } =
SelectTranslationsPanel.elements;
const fromLanguage = fromMenuList.value;
const toLanguage = toMenuList.value;
const translatedSuffix = ` [${fromLanguage} to ${toLanguage}]`;
ok(
translatedTextArea.value.endsWith(translatedSuffix),
`Translated text should match ${fromLanguage} to ${toLanguage}`
);
is(
SelectTranslationsPanel.getSourceText().length,
SelectTranslationsPanel.getTranslatedText().length -
translatedSuffix.length,
"Expected translated text length to correspond to the source text length."
);
} }
/** /**
@@ -1621,6 +1663,29 @@ class SelectTranslationsTestUtils {
await maybeOpenContextMenuAt("SpanishHyperlink"); await maybeOpenContextMenuAt("SpanishHyperlink");
} }
/**
* Handles language-model downloads for the SelectTranslationsPanel, ensuring that expected
* UI states match based on the resolved download state.
*
* @param {object} options - Configuration options for downloads.
* @param {function(number): Promise<void>} options.downloadHandler - The function to resolve or reject the downloads.
* @param {boolean} [options.pivotTranslation] - Whether to expect a pivot translation.
*
* @returns {Promise<void>}
*/
static async handleDownloads({ downloadHandler, pivotTranslation }) {
if (downloadHandler) {
const { translatedTextArea } = SelectTranslationsPanel.elements;
const overflowEnabled = BrowserTestUtils.waitForMutationCondition(
translatedTextArea,
{ attributes: true, attributeFilter: ["style"] },
() => translatedTextArea.style.overflow === "auto"
);
await downloadHandler(pivotTranslation ? 2 : 1);
await overflowEnabled;
}
}
/** /**
* Opens the Select Translations panel via the context menu based on specified options. * Opens the Select Translations panel via the context menu based on specified options.
* *
@@ -1665,13 +1730,31 @@ class SelectTranslationsTestUtils {
); );
const menuItem = getById("context-translate-selection"); const menuItem = getById("context-translate-selection");
const { onOpenPanel } = options;
await SelectTranslationsTestUtils.waitForPanelPopupEvent( await SelectTranslationsTestUtils.waitForPanelPopupEvent(
"popupshown", "popupshown",
() => click(menuItem), async () => {
onOpenPanel click(menuItem);
await closeContextMenuIfOpen();
},
async () => {
const { onOpenPanel } = options;
await SelectTranslationsTestUtils.handleDownloads(options);
if (onOpenPanel) {
await onOpenPanel();
}
}
); );
const { expectedFromLanguage, expectedToLanguage } = options;
if (expectedFromLanguage !== undefined) {
SelectTranslationsTestUtils.assertSelectedFromLanguage(
expectedFromLanguage
);
}
if (expectedToLanguage !== undefined) {
SelectTranslationsTestUtils.assertSelectedToLanguage(expectedToLanguage);
}
} }
/** /**

View File

@@ -47,5 +47,8 @@ select-translations-panel-done-button = Done
# Text displayed on translate-full-page button. # Text displayed on translate-full-page button.
select-translations-panel-translate-full-page-button = Translate full page select-translations-panel-translate-full-page-button = Translate full page
# Text displayed as a placeholder in the translated text area. # Text displayed as a placeholder when the panel is idle.
select-translations-panel-placeholder-text = Translated text will appear here. select-translations-panel-idle-placeholder-text = Translated text will appear here.
# Text displayed as a placeholder when the panel is actively translating.
select-translations-panel-translating-placeholder-text = Translating…

View File

@@ -688,6 +688,42 @@ export class TranslationsParent extends JSWindowActorParent {
return TranslationsParent.#preferredLanguages; return TranslationsParent.#preferredLanguages;
} }
/**
* Requests a new translations port.
*
* @param {number} innerWindowId - The id of the current window.
* @param {string} fromLanguage - The BCP-47 from-language tag.
* @param {string} toLanguage - The BCP-47 to-language tag.
*
* @returns {Promise<MessagePort | undefined>} The port for communication with the translation engine, or undefined on failure.
*/
static async requestTranslationsPort(
innerWindowId,
fromLanguage,
toLanguage
) {
let translationsEngineParent;
try {
translationsEngineParent =
await lazy.EngineProcess.getTranslationsEngineParent();
} catch (error) {
console.error("Failed to get the translation engine process", error);
return undefined;
}
// The MessageChannel will be used for communicating directly between the content
// process and the engine's process.
const { port1, port2 } = new MessageChannel();
translationsEngineParent.startTranslation(
fromLanguage,
toLanguage,
port1,
innerWindowId
);
return port2;
}
async receiveMessage({ name, data }) { async receiveMessage({ name, data }) {
switch (name) { switch (name) {
case "Translations:ReportLangTags": { case "Translations:ReportLangTags": {

View File

@@ -269,3 +269,10 @@ export interface SupportedLanguages {
} }
export type TranslationErrors = "engine-load-error"; export type TranslationErrors = "engine-load-error";
export type SelectTranslationsPanelState =
| { phase: "closed"; }
| { phase: "idle"; fromLanguage: string; toLanguage: string, sourceText: string, }
| { phase: "translatable"; fromLanguage: string; toLanguage: string, sourceText: string, }
| { phase: "translating"; fromLanguage: string; toLanguage: string, sourceText: string, }
| { phase: "translated"; fromLanguage: string; toLanguage: string, sourceText: string, translatedText: string, }